diff --git a/app/build.gradle b/app/build.gradle index e7d5f79..c6a5677 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,7 +1,10 @@ apply plugin: 'com.android.application' +apply plugin: "androidx.navigation.safeargs" apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-kapt' +apply plugin: 'io.fabric' +apply plugin: 'com.google.gms.google-services' android { compileSdkVersion 28 @@ -15,31 +18,69 @@ android { } buildTypes { release { - minifyEnabled false + minifyEnabled true + shrinkResources true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } + debug { + // TODO enabling minify breaks the debugger breakpoints, find a way to fix it and enable minify again + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + resValue("string", "PORT_NUMBER", "8082") + } + } + android.packagingOptions { + exclude 'lib/x86_64/darwin/libscrypt.dylib' + exclude 'lib/x86_64/freebsd/libscrypt.so' + exclude 'lib/x86_64/linux/libscrypt.so' } } dependencies { def lifecycle_version = "2.0.0" - def room_version = "2.1.0-alpha02" + def room_version = "2.1.0-alpha03" + def nav_version = "1.0.0-alpha09" + def rx_bindings_version = "3.0.0-alpha2" implementation fileTree(dir: 'libs', include: ['*.jar']) implementation project(':graphenejlib:graphenej') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation 'androidx.appcompat:appcompat:1.0.0' + // AndroidX + implementation 'androidx.appcompat:appcompat:1.0.2' implementation 'androidx.constraintlayout:constraintlayout:2.0.0-alpha2' - implementation 'com.google.android.material:material:1.0.0' - implementation 'androidx.legacy:legacy-support-v4:1.0.0' + // Google implementation 'com.google.zxing:core:3.3.1' - implementation 'me.dm7.barcodescanner:zxing:1.9.8' - + implementation 'com.google.code.gson:gson:2.8.5' + implementation 'com.google.android.material:material:1.0.0' + implementation 'com.google.android.gms:play-services-maps:16.0.0' + // AAC Lifecycle implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version" - + // AAC Room implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version" + implementation "androidx.room:room-rxjava2:$room_version" // RxJava support for Room + // AAC Navigation + implementation "android.arch.navigation:navigation-fragment-ktx:$nav_version" + implementation "android.arch.navigation:navigation-ui-ktx:$nav_version" + // RxBindings + implementation "com.jakewharton.rxbinding3:rxbinding:$rx_bindings_version" + implementation "com.jakewharton.rxbinding3:rxbinding-material:$rx_bindings_version" // Material Components widgets + implementation "com.jakewharton.rxbinding3:rxbinding-appcompat:$rx_bindings_version" // AndroidX appcompat widgets + // Retrofit + implementation 'com.squareup.retrofit2:retrofit:2.5.0' + implementation 'com.squareup.retrofit2:converter-gson:2.5.0' + //Firebase + implementation 'com.google.firebase:firebase-core:16.0.6' + implementation 'com.google.firebase:firebase-crash:16.2.1' + implementation 'com.crashlytics.sdk.android:crashlytics:2.9.7' + // Others + implementation 'org.bitcoinj:bitcoinj-core:0.14.3' + implementation 'com.moldedbits.r2d2:r2d2:1.0.1' + implementation 'me.dm7.barcodescanner:zxing:1.9.8' + implementation 'com.afollestad.material-dialogs:core:2.0.0-rc3' + // Android Debug Database + debugImplementation 'com.amitshekhar.android:debug-db:1.0.4' // TODO enable and make proper testing // testImplementation 'junit:junit:4.12' diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f1b4245..ab4af9b 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -19,3 +19,14 @@ # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile + +-dontwarn sun.misc.Unsafe +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement +-dontwarn org.bitcoinj.store** +-dontwarn org.slf4j.** +-dontwarn okhttp3.internal.platform.* + +# Firabase Crashlytics +-keepattributes *Annotation* +-keepattributes SourceFile,LineNumberTable +-keep public class * extends java.lang.Exception \ No newline at end of file diff --git a/app/src/debug/res/values/google_maps_api.xml b/app/src/debug/res/values/google_maps_api.xml new file mode 100644 index 0000000..358058e --- /dev/null +++ b/app/src/debug/res/values/google_maps_api.xml @@ -0,0 +1,24 @@ + + + AIzaSyDIYbjdkZqbLUINQXrAzNSjNwep5jGNjKA + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6fc1626..b243968 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,42 +3,64 @@ xmlns:tools="http://schemas.android.com/tools" package="cy.agorise.bitsybitshareswallet"> + + + + + + + + + + + android:name=".activities.SplashActivity" + android:theme="@style/SplashTheme"> + + - - - - - - - - - + android:name=".activities.MainActivity" + android:screenOrientation="portrait" + android:theme="@style/Theme.Bitsy.NoActionBar" + android:windowSoftInputMode="adjustPan"/> + + + + \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt new file mode 100644 index 0000000..9021743 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt @@ -0,0 +1,420 @@ +package cy.agorise.bitsybitshareswallet.activities + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.preference.PreferenceManager +import android.util.Log +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import cy.agorise.bitsybitshareswallet.database.entities.Balance +import cy.agorise.bitsybitshareswallet.processors.TransfersLoader +import cy.agorise.bitsybitshareswallet.repositories.AssetRepository +import cy.agorise.bitsybitshareswallet.utils.Constants +import cy.agorise.bitsybitshareswallet.viewmodels.BalanceViewModel +import cy.agorise.bitsybitshareswallet.viewmodels.TransferViewModel +import cy.agorise.bitsybitshareswallet.viewmodels.UserAccountViewModel +import cy.agorise.graphenej.Asset +import cy.agorise.graphenej.AssetAmount +import cy.agorise.graphenej.UserAccount +import cy.agorise.graphenej.api.ConnectionStatusUpdate +import cy.agorise.graphenej.api.android.NetworkService +import cy.agorise.graphenej.api.android.RxBus +import cy.agorise.graphenej.api.calls.* +import cy.agorise.graphenej.models.AccountProperties +import cy.agorise.graphenej.models.BlockHeader +import cy.agorise.graphenej.models.FullAccountDetails +import cy.agorise.graphenej.models.JsonRpcResponse +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.* +import kotlin.collections.ArrayList +import kotlin.collections.HashMap + +/** + * Class in charge of managing the connection to graphenej's NetworkService + */ +abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { + private val TAG = this.javaClass.simpleName + + private val RESPONSE_GET_FULL_ACCOUNTS = 1 + private val RESPONSE_GET_ACCOUNTS = 2 + private val RESPONSE_GET_ACCOUNT_BALANCES = 3 + private val RESPONSE_GET_ASSETS = 4 + private val RESPONSE_GET_BLOCK_HEADER = 5 + + private lateinit var mUserAccountViewModel: UserAccountViewModel + private lateinit var mBalanceViewModel: BalanceViewModel + private lateinit var mTransferViewModel: TransferViewModel + + private lateinit var mAssetRepository: AssetRepository + + /* Current user account */ + protected var mCurrentAccount: UserAccount? = null + + private val mHandler = Handler() + + // Disposable returned at the bus subscription + private var mDisposable: Disposable? = null + + private var storedOpCount: Long = -1 + + private var missingUserAccounts = ArrayList() + private var missingAssets = ArrayList() + + /* Network service connection */ + protected var mNetworkService: NetworkService? = null + + // Map used to keep track of request and response id pairs + private val responseMap = HashMap() + + /** Map used to keep track of request id and block number pairs */ + private val requestIdToBlockNumberMap = HashMap() + + private var blockNumberWithMissingTime = 0L + + /** + * Flag used to keep track of the NetworkService binding state + */ + private var mShouldUnbindNetwork: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val userId = PreferenceManager.getDefaultSharedPreferences(this) + .getString(Constants.KEY_CURRENT_ACCOUNT_ID, "") + if (userId != "") + mCurrentAccount = UserAccount(userId) + + mAssetRepository = AssetRepository(this) + + // Configure UserAccountViewModel to obtain the missing account ids + mUserAccountViewModel = ViewModelProviders.of(this).get(UserAccountViewModel::class.java) + + mUserAccountViewModel.getMissingUserAccountIds().observe(this, Observer>{ userAccountIds -> + if (userAccountIds.isNotEmpty()) { + missingUserAccounts.clear() + for (userAccountId in userAccountIds) + missingUserAccounts.add(UserAccount(userAccountId)) + + mHandler.postDelayed(mRequestMissingUserAccountsTask, Constants.NETWORK_SERVICE_RETRY_PERIOD) + } + }) + + // Configure UserAccountViewModel to obtain the missing account ids + mBalanceViewModel = ViewModelProviders.of(this).get(BalanceViewModel::class.java) + + mBalanceViewModel.getMissingAssetIds().observe(this, Observer>{ assetIds -> + if (assetIds.isNotEmpty()) { + missingAssets.clear() + for (assetId in assetIds) + missingAssets.add(Asset(assetId)) + + mHandler.postDelayed(mRequestMissingAssetsTask, Constants.NETWORK_SERVICE_RETRY_PERIOD) + } + }) + + //Configure TransferViewModel to obtain the Transfer's block numbers with missing time information, one by one + mTransferViewModel = ViewModelProviders.of(this).get(TransferViewModel::class.java) + + mTransferViewModel.getTransferBlockNumberWithMissingTime().observe(this, Observer{ blockNumber -> + if (blockNumber != null && blockNumber != blockNumberWithMissingTime) { + blockNumberWithMissingTime = blockNumber + Log.d(TAG, "Block number: $blockNumber, Time: ${System.currentTimeMillis()}") + mHandler.postDelayed(mRequestBlockMissingTimeTask, 10) + } + }) + + mDisposable = RxBus.getBusInstance() + .asFlowable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { handleIncomingMessage(it) } + } + + private fun handleIncomingMessage(message: Any?) { + if (message is JsonRpcResponse<*>) { + // Generic processing taken care by subclasses + handleJsonRpcResponse(message) + + if (message.error == null) { + if (responseMap.containsKey(message.id)) { + val responseType = responseMap[message.id] + when (responseType) { + RESPONSE_GET_FULL_ACCOUNTS -> + handleAccountDetails((message.result as List<*>)[0] as FullAccountDetails) + + RESPONSE_GET_ACCOUNTS -> + handleAccountProperties(message.result as List) + + RESPONSE_GET_ACCOUNT_BALANCES -> + handleBalanceUpdate(message.result as List) + + RESPONSE_GET_ASSETS -> + handleAssets(message.result as List) + + RESPONSE_GET_BLOCK_HEADER -> { + val blockNumber = requestIdToBlockNumberMap[message.id] ?: 0L + handleBlockHeader(message.result as BlockHeader, blockNumber) + requestIdToBlockNumberMap.remove(message.id) + } + } + responseMap.remove(message.id) + } + } else { + // In case of error + Log.e(TAG, "Got error message from full node. Msg: " + message.error.message) + Toast.makeText( + this@ConnectedActivity, + String.format("Error from full node. Msg: %s", message.error.message), + Toast.LENGTH_LONG + ).show() + } + } else if (message is ConnectionStatusUpdate) { + handleConnectionStatusUpdate(message) + if (message.updateCode == ConnectionStatusUpdate.DISCONNECTED) { + // If we got a disconnection notification, we should clear our response map, since + // all its stored request ids will now be reset + responseMap.clear() + } + } + } + + /** + * Method called whenever a response to the 'get_full_accounts' API call has been detected. + * @param accountDetails De-serialized account details object + */ + private fun handleAccountDetails(accountDetails: FullAccountDetails) { + val latestOpCount = accountDetails.statistics.total_ops + Log.d(TAG, "handleAccountDetails. prev count: $storedOpCount, current count: $latestOpCount") + + if (latestOpCount == 0L) { + Log.d(TAG, "The node returned 0 total_ops for current account and may not have installed the history plugin. " + + "\nAsk the NetworkService to remove the node from the list and connect to another one.") + mNetworkService!!.removeCurrentNodeAndReconnect() + } else if (storedOpCount == -1L) { + // Initial case when the app starts + storedOpCount = latestOpCount + PreferenceManager.getDefaultSharedPreferences(this) + .edit().putLong(Constants.KEY_ACCOUNT_OPERATION_COUNT, latestOpCount).apply() + TransfersLoader(this) + updateBalances() + } else if (latestOpCount > storedOpCount) { + storedOpCount = latestOpCount + TransfersLoader(this) + updateBalances() + } + } + + /** + * Receives a list of missing [AccountProperties] from which it extracts the required information to + * create a list of BiTSy's UserAccount objects and stores them into the database + */ + private fun handleAccountProperties(accountPropertiesList: List) { + val userAccounts = ArrayList() + + for (accountProperties in accountPropertiesList) { + val userAccount = cy.agorise.bitsybitshareswallet.database.entities.UserAccount( + accountProperties.id, + accountProperties.name, + accountProperties.membership_expiration_date == Constants.LIFETIME_EXPIRATION_DATE + ) + + userAccounts.add(userAccount) + } + + mUserAccountViewModel.insertAll(userAccounts) + missingUserAccounts.clear() + } + + private fun handleBalanceUpdate(assetAmountList: List) { + Log.d(TAG, "handleBalanceUpdate") + val now = System.currentTimeMillis() / 1000 + val balances = ArrayList() + for (assetAmount in assetAmountList) { + val balance = Balance( + assetAmount.asset.objectId, + assetAmount.amount.toLong(), + now + ) + + balances.add(balance) + } + + mBalanceViewModel.insertAll(balances) + } + + /** + * Receives a list of missing [Asset] from which it extracts the required information to + * create a list of BiTSy's Asset objects and stores them into the database + */ + private fun handleAssets(_assets: List) { + val assets = ArrayList() + + for (_asset in _assets) { + val asset = cy.agorise.bitsybitshareswallet.database.entities.Asset( + _asset.objectId, + _asset.symbol, + _asset.precision, + _asset.description ?: "", + _asset.bitassetId ?: "" + ) + + assets.add(asset) + } + + mAssetRepository.insertAll(assets) + missingAssets.clear() + } + + /** + * Receives the [BlockHeader] related to a Transfer's missing time and saves it into the database. + */ + private fun handleBlockHeader(blockHeader: BlockHeader, blockNumber: Long) { + + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US) + dateFormat.timeZone = TimeZone.getTimeZone("GMT") + + try { + val date = dateFormat.parse(blockHeader.timestamp) + mTransferViewModel.setBlockTime(blockNumber, date.time / 1000) + } catch (e: ParseException) { + Log.e(TAG, "ParseException. Msg: " + e.message) + } + } + + private fun updateBalances() { + if (mNetworkService!!.isConnected) { + val id = mNetworkService!!.sendMessage(GetAccountBalances(mCurrentAccount, ArrayList()), + GetAccountBalances.REQUIRED_API) + + responseMap[id] = RESPONSE_GET_ACCOUNT_BALANCES + } + } + + /** + * Task used to obtain the missing UserAccounts from Graphenej's NetworkService. + */ + private val mRequestMissingUserAccountsTask = object : Runnable { + override fun run() { + if (mNetworkService!!.isConnected) { + val id = mNetworkService!!.sendMessage(GetAccounts(missingUserAccounts), GetAccounts.REQUIRED_API) + + responseMap[id] = RESPONSE_GET_ACCOUNTS + } else if (missingUserAccounts.isNotEmpty()){ + mHandler.postDelayed(this, Constants.NETWORK_SERVICE_RETRY_PERIOD) + } + } + } + + /** + * Task used to obtain the missing Assets from Graphenej's NetworkService. + */ + private val mRequestMissingAssetsTask = object : Runnable { + override fun run() { + if (mNetworkService!!.isConnected) { + val id = mNetworkService!!.sendMessage(GetAssets(missingAssets), GetAssets.REQUIRED_API) + + responseMap[id] = RESPONSE_GET_ASSETS + } else if (missingAssets.isNotEmpty()){ + mHandler.postDelayed(this, Constants.NETWORK_SERVICE_RETRY_PERIOD) + } + } + } + + /** + * Task used to perform a redundant payment check. + */ + private val mCheckMissingPaymentsTask = object : Runnable { + override fun run() { + if (mNetworkService != null && mNetworkService!!.isConnected) { + if (mCurrentAccount != null) { + val userAccounts = ArrayList() + userAccounts.add(mCurrentAccount!!.objectId) + val id = mNetworkService!!.sendMessage(GetFullAccounts(userAccounts, false), + GetFullAccounts.REQUIRED_API) + + responseMap[id] = RESPONSE_GET_FULL_ACCOUNTS + } + } else { + Log.w(TAG, "NetworkService is null or is not connected. mNetworkService: $mNetworkService") + } + mHandler.postDelayed(this, Constants.MISSING_PAYMENT_CHECK_PERIOD) + + } + } + + /** + * Task used to obtain the missing time from a block from Graphenej's NetworkService. + */ + private val mRequestBlockMissingTimeTask = object : Runnable { + override fun run() { + + if (mNetworkService != null && mNetworkService!!.isConnected) { + val id = mNetworkService!!.sendMessage(GetBlockHeader(blockNumberWithMissingTime), + GetBlockHeader.REQUIRED_API) + + responseMap[id] = RESPONSE_GET_BLOCK_HEADER + requestIdToBlockNumberMap[id] = blockNumberWithMissingTime + } else { + mHandler.postDelayed(this, Constants.MISSING_PAYMENT_CHECK_PERIOD) + } + } + } + + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + // We've bound to LocalService, cast the IBinder and get LocalService instance + val binder = service as NetworkService.LocalBinder + mNetworkService = binder.service + } + + override fun onServiceDisconnected(name: ComponentName?) { } + + override fun onPause() { + super.onPause() + // Unbinding from network service + if (mShouldUnbindNetwork) { + unbindService(this) + mShouldUnbindNetwork = false + } + mHandler.removeCallbacks(mCheckMissingPaymentsTask) + mHandler.removeCallbacks(mRequestMissingUserAccountsTask) + mHandler.removeCallbacks(mRequestMissingAssetsTask) + } + + override fun onResume() { + super.onResume() + + val intent = Intent(this, NetworkService::class.java) + if (bindService(intent, this, Context.BIND_AUTO_CREATE)) { + mShouldUnbindNetwork = true + } else { + Log.e(TAG, "Binding to the network service failed.") + } + mHandler.postDelayed(mCheckMissingPaymentsTask, Constants.MISSING_PAYMENT_CHECK_PERIOD) + } + + override fun onDestroy() { + super.onDestroy() + if (!mDisposable!!.isDisposed) mDisposable!!.dispose() + } + + /** + * Method to be implemented by all subclasses in order to be notified of JSON-RPC responses. + * @param response + */ + internal abstract fun handleJsonRpcResponse(response: JsonRpcResponse<*>) + + /** + * Method to be implemented by all subclasses in order to be notified of connection status updates + * @param connectionStatusUpdate + */ + internal abstract fun handleConnectionStatusUpdate(connectionStatusUpdate: ConnectionStatusUpdate) +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ImportBrainkeyActivity.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ImportBrainkeyActivity.kt new file mode 100644 index 0000000..135ef7b --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ImportBrainkeyActivity.kt @@ -0,0 +1,357 @@ +package cy.agorise.bitsybitshareswallet.activities + +import android.content.Intent +import android.os.Bundle +import android.preference.PreferenceManager +import android.util.Log +import android.widget.Toast +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.list.listItemsSingleChoice +import com.jakewharton.rxbinding3.widget.textChanges +import cy.agorise.bitsybitshareswallet.R +import cy.agorise.bitsybitshareswallet.database.entities.Authority +import cy.agorise.bitsybitshareswallet.repositories.AuthorityRepository +import cy.agorise.bitsybitshareswallet.repositories.UserAccountRepository +import cy.agorise.bitsybitshareswallet.utils.Constants +import cy.agorise.bitsybitshareswallet.utils.CryptoUtils +import cy.agorise.graphenej.* +import cy.agorise.graphenej.api.ConnectionStatusUpdate +import cy.agorise.graphenej.api.calls.GetAccounts +import cy.agorise.graphenej.api.calls.GetKeyReferences +import cy.agorise.graphenej.models.AccountProperties +import cy.agorise.graphenej.models.JsonRpcResponse +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import kotlinx.android.synthetic.main.activity_import_brainkey.* +import org.bitcoinj.core.ECKey +import java.util.ArrayList +import java.util.concurrent.TimeUnit + +// TODO Add method to load the 20? most important assets +// TODO add progress bar or something while the user waits for the import response from the node + +class ImportBrainkeyActivity : ConnectedActivity() { + private val TAG = "ImportBrainkeyActivity" + + /** + * Private variable that will hold an instance of the [BrainKey] class + */ + private var mBrainKey: BrainKey? = null + + /** + * User account associated with the key derived from the brainkey that the user just typed in + */ + private var mUserAccount: UserAccount? = null + + /** + * List of user account candidates, this is required in order to allow the user to select a single + * user account in case one key (derived from the brainkey) controls more than one account. + */ + private var mUserAccountCandidates: List? = null + + private var mKeyReferencesAttempts = 0 + + private var keyReferencesRequestId: Long = 0 + private var getAccountsRequestId: Long = 0 + + private var mDisposables = CompositeDisposable() + + private var isPINValid = false + private var isPINConfirmationValid = false + private var isBrainKeyValid = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_import_brainkey) + + // Use RxJava Debounce to update the PIN error only after the user stops writing for > 500 ms + mDisposables.add( + tietPin.textChanges() + .skipInitialValue() + .debounce(500, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { validatePIN() } + ) + + // Use RxJava Debounce to update the PIN Confirmation error only after the user stops writing for > 500 ms + mDisposables.add( + tietPinConfirmation.textChanges() + .skipInitialValue() + .debounce(500, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { validatePINConfirmation() } + ) + + // Use RxJava Debounce to update the BrainKey error only after the user stops writing for > 500 ms + mDisposables.add( + tietBrainKey.textChanges() + .skipInitialValue() + .debounce(500, TimeUnit.MILLISECONDS) + .map { it.toString().trim() } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { validateBrainKey(it) } + ) + + btnImport.isEnabled = false + btnImport.setOnClickListener { verifyBrainKey(false) } + } + + private fun validatePIN() { + val pin = tietPin.text.toString() + + if (pin.length < Constants.MIN_PIN_LENGTH) { + tilPin.error = getString(R.string.error__pin_too_short) + isPINValid = false + } else { + tilPin.isErrorEnabled = false + isPINValid = true + } + + validatePINConfirmation() + } + + private fun validatePINConfirmation() { + val pinConfirmation = tietPinConfirmation.text.toString() + + if (pinConfirmation != tietPin.text.toString()) { + tilPinConfirmation.error = getString(R.string.error__pin_mismatch) + isPINConfirmationValid = false + } else { + tilPinConfirmation.isErrorEnabled = false + isPINConfirmationValid = true + } + + enableDisableImportButton() + } + + private fun validateBrainKey(brainKey: String) { + if (brainKey.isEmpty() || !brainKey.contains(" ") || brainKey.split(" ").size !in 12..16) { + tilBrainKey.error = getString(R.string.error__enter_correct_brainkey) + isBrainKeyValid = false + } else { + tilBrainKey.isErrorEnabled = false + isBrainKeyValid = true + } + + enableDisableImportButton() + } + + private fun enableDisableImportButton() { + btnImport.isEnabled = (isPINValid && isPINConfirmationValid && isBrainKeyValid) + } + + /** + * Method that will verify the provided brain key, and if valid will retrieve the account information + * associated to the user id. + * + * This method will perform a network lookup to look which accounts use the public key associated + * with the user provided brainkey. + * + * Some sources use brainkeys in capital letters, while others use lowercase. The activity should + * initially call this method with the 'switchCase' parameter as false, in order to try the + * brainkey as it was provided by the user. + * + * But in case this lookup fails, it is expected that the activity makes another attempt. This time + * with the 'switchCase' argument set to true. + * + * If both attempts fail, then we can be certain that the provided brainkey is not currently + * associated with any account. + * + * @param switchCase Whether to switch the case used in the brainkey or not. + */ + private fun verifyBrainKey(switchCase: Boolean) { + //showDialog("", getString(R.string.importing_your_wallet)) + val brainKey = tietBrainKey.text.toString() + // Should we switch the brainkey case? + if (switchCase) { + if (Character.isUpperCase(brainKey.toCharArray()[brainKey.length - 1])) { + // If the last character is an uppercase, we assume the whole brainkey + // was given in capital letters and turn it to lowercase + getAccountFromBrainkey(brainKey.toLowerCase()) + } else { + // Otherwise we turn the whole brainkey to capital letters + getAccountFromBrainkey(brainKey.toUpperCase()) + } + } else { + // If no case switching should take place, we perform the network call with + // the brainkey as it was provided to us. + getAccountFromBrainkey(brainKey) + } + } + + /** + * Method that will send a network request asking for all the accounts that make use of the + * key derived from a give brain key. + * + * @param brainKey The brain key the user has just typed + */ + private fun getAccountFromBrainkey(brainKey: String) { + mBrainKey = BrainKey(brainKey, 0) + val address = Address(ECKey.fromPublicOnly(mBrainKey!!.privateKey.pubKey)) + Log.d(TAG, String.format("Brainkey would generate address: %s", address.toString())) + keyReferencesRequestId = mNetworkService!!.sendMessage(GetKeyReferences(address), GetKeyReferences.REQUIRED_API) + } + + override fun handleJsonRpcResponse(response: JsonRpcResponse<*>) { + Log.d(TAG, "handleResponse.Thread: " + Thread.currentThread().name) + if (response.id == keyReferencesRequestId) { + val resp = response.result as List> + val accountList: List = resp[0].distinct() + if (accountList.isEmpty() && mKeyReferencesAttempts == 0) { + mKeyReferencesAttempts++ + verifyBrainKey(true) + } else { + if (accountList.isEmpty()) { + //hideDialog() + Toast.makeText(applicationContext, R.string.error__invalid_brainkey, Toast.LENGTH_SHORT).show() + } else { + if (accountList.size == 1) { + // If we only found one account linked to this key, then we just proceed + // trying to find out the account name + mUserAccount = accountList[0] + getAccountsRequestId = + mNetworkService!!.sendMessage(GetAccounts(mUserAccount), GetAccounts.REQUIRED_API) + } else { + // If we found more than one account linked to this key, we must also + // find out the account names, but the procedure is a bit different in + // that after having those, we must still ask the user to decide which + // account should be imported. + mUserAccountCandidates = accountList + getAccountsRequestId = mNetworkService!!.sendMessage( + GetAccounts(mUserAccountCandidates), + GetAccounts.REQUIRED_API + ) + } + } + } + } else if (response.id == getAccountsRequestId) { + val accountPropertiesList = response.result as List + if (accountPropertiesList.size > 1) { + val candidates = ArrayList() + for (accountProperties in accountPropertiesList) { + candidates.add(accountProperties.name) + } +// hideDialog() + MaterialDialog(this) + .title(R.string.dialog__account_candidates_title) + .message(R.string.dialog__account_candidates_content) + .listItemsSingleChoice (items = candidates, initialSelection = -1) { _, index, _ -> + if (index >= 0) { + // If one account was selected, we keep a reference to it and + // store the account properties + mUserAccount = mUserAccountCandidates!![index] + onAccountSelected(accountPropertiesList[index]) + } + } + .positiveButton(android.R.string.ok) + .negativeButton(android.R.string.cancel) { + mKeyReferencesAttempts = 0 + } + .cancelable(false) + .show() + } else if (accountPropertiesList.size == 1) { + onAccountSelected(accountPropertiesList[0]) + } else { + Toast.makeText(applicationContext, R.string.error__try_again, Toast.LENGTH_SHORT).show() + } + } + } + + override fun handleConnectionStatusUpdate(connectionStatusUpdate: ConnectionStatusUpdate) { + Log.d(TAG, "handleConnectionStatusUpdate. code: " + connectionStatusUpdate.updateCode) + } + + /** + * Method called internally once an account has been detected. This method will store internally + * the following details: + * + * - Account name in the database + * - Account authorities in the database + * - The current account id in the shared preferences + * + * @param accountProperties Account properties object + */ + private fun onAccountSelected(accountProperties: AccountProperties) { + mUserAccount!!.name = accountProperties.name + + val encryptedPIN = CryptoUtils.encrypt(this, tietPin.text!!.toString()) + + // Stores the user selected PIN encrypted + PreferenceManager.getDefaultSharedPreferences(this) + .edit() + .putString(Constants.KEY_ENCRYPTED_PIN, encryptedPIN) + .apply() + + // Stores the accounts this key refers to + val id = accountProperties.id + val name = accountProperties.name + val isLTM = accountProperties.membership_expiration_date == Constants.LIFETIME_EXPIRATION_DATE + + val userAccount = cy.agorise.bitsybitshareswallet.database.entities.UserAccount(id, name, isLTM) + + val userAccountRepository = UserAccountRepository(application) + userAccountRepository.insert(userAccount) + + // Stores the id of the currently active user account + PreferenceManager.getDefaultSharedPreferences(this) + .edit() + .putString(Constants.KEY_CURRENT_ACCOUNT_ID, mUserAccount!!.objectId) + .apply() + + // Trying to store all possible authorities (owner, active and memo) into the database + val ownerAuthority = accountProperties.owner + val activeAuthority = accountProperties.active + val options = accountProperties.options + + for (i in 0..2) { + mBrainKey!!.sequenceNumber = i + val publicKey = PublicKey(ECKey.fromPublicOnly(mBrainKey!!.privateKey.pubKey)) + + if (ownerAuthority.keyAuths.keys.contains(publicKey)) { + addAuthorityToDatabase(accountProperties.id, AuthorityType.OWNER.ordinal, mBrainKey!!) + } + if (activeAuthority.keyAuths.keys.contains(publicKey)) { + addAuthorityToDatabase(accountProperties.id, AuthorityType.ACTIVE.ordinal, mBrainKey!!) + } + if (options.memoKey == publicKey) { + addAuthorityToDatabase(accountProperties.id, AuthorityType.MEMO.ordinal, mBrainKey!!) + } + } + + // Stores a flag into the SharedPreferences to tell the app there is an active account and there is no need + // to show this activity again, until the account is removed. + PreferenceManager.getDefaultSharedPreferences(this) + .edit() + .putBoolean(Constants.KEY_INITIAL_SETUP_DONE, true) + .apply() + + // Send the user to the MainActivity + val intent = Intent(this, MainActivity::class.java) + startActivity(intent) + finish() + } + + /** + * Adds the given BrainKey encrypted as AuthorityType of userId. + */ + private fun addAuthorityToDatabase(userId: String, authorityType: Int, brainKey: BrainKey) { + val brainKeyWords = brainKey.brainKey + val wif = brainKey.walletImportFormat + val sequenceNumber = brainKey.sequenceNumber + + val encryptedBrainKey = CryptoUtils.encrypt(this, brainKeyWords) + val encryptedSequenceNumber = CryptoUtils.encrypt(this, sequenceNumber.toString()) + val encryptedWIF = CryptoUtils.encrypt(this, wif) + + val authority = Authority(0, userId, authorityType, encryptedWIF, encryptedBrainKey, encryptedSequenceNumber) + + val authorityRepository = AuthorityRepository(this) + authorityRepository.insert(authority) + } + + override fun onDestroy() { + super.onDestroy() + + if (!mDisposables.isDisposed) mDisposables.dispose() + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/LicenseActivity.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/LicenseActivity.kt new file mode 100644 index 0000000..5bf6d61 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/LicenseActivity.kt @@ -0,0 +1,55 @@ +package cy.agorise.bitsybitshareswallet.activities + +import android.content.Intent +import android.os.Bundle +import android.preference.PreferenceManager +import androidx.appcompat.app.AppCompatActivity +import cy.agorise.bitsybitshareswallet.R +import cy.agorise.bitsybitshareswallet.utils.Constants +import kotlinx.android.synthetic.main.activity_license.* + +class LicenseActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_license) + + // Get version number of the last agreed license version + val agreedLicenseVersion = PreferenceManager.getDefaultSharedPreferences(this) + .getInt(Constants.KEY_LAST_AGREED_LICENSE_VERSION, 0) + + // If the last agreed license version is the actual one then proceed to the following Activities + if (agreedLicenseVersion == Constants.CURRENT_LICENSE_VERSION) { + agree() + } else { + wbLA.loadData(getString(R.string.licence_html), "text/html", "UTF-8") + + btnDisagree.setOnClickListener { finish() } + + btnAgree.setOnClickListener { agree() } + } + } + + /** + * This function stores the version of the current accepted license version into the Shared Preferences and + * sends the user to import/create account if there is no active account or to the MainActivity otherwise. + */ + private fun agree() { + PreferenceManager.getDefaultSharedPreferences(this).edit() + .putInt(Constants.KEY_LAST_AGREED_LICENSE_VERSION, Constants.CURRENT_LICENSE_VERSION).apply() + + val intent : Intent? + + val initialSetupDone = PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean(Constants.KEY_INITIAL_SETUP_DONE, false) + + intent = if (!initialSetupDone) + Intent(this, ImportBrainkeyActivity::class.java) + else + Intent(this, MainActivity::class.java) + + + startActivity(intent) + finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/MainActivity.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/MainActivity.kt index be29647..ca4c948 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/MainActivity.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/MainActivity.kt @@ -1,90 +1,114 @@ package cy.agorise.bitsybitshareswallet.activities -import android.content.Intent -import androidx.appcompat.app.AppCompatActivity - -import androidx.fragment.app.FragmentPagerAdapter import android.os.Bundle -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import cy.agorise.bitsybitshareswallet.BuildConfig +import android.os.Handler +import android.preference.PreferenceManager +import android.view.MenuItem +import androidx.navigation.findNavController +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.navigateUp +import androidx.navigation.ui.onNavDestinationSelected +import androidx.navigation.ui.setupActionBarWithNavController import cy.agorise.bitsybitshareswallet.R -import cy.agorise.bitsybitshareswallet.fragments.BalancesFragment -import cy.agorise.bitsybitshareswallet.fragments.MerchantsFragment -import cy.agorise.bitsybitshareswallet.fragments.TransactionsFragment - +import cy.agorise.bitsybitshareswallet.utils.Constants +import cy.agorise.graphenej.api.ConnectionStatusUpdate +import cy.agorise.graphenej.models.JsonRpcResponse import kotlinx.android.synthetic.main.activity_main.* -class MainActivity : AppCompatActivity() { +class MainActivity : ConnectedActivity() { + private val TAG = this.javaClass.simpleName - /** - * The [androidx.fragment.app.FragmentPagerAdapter] that will provide - * fragments for each of the sections. We use a - * {@link FragmentPagerAdapter} derivative, which will keep every - * loaded fragment in memory. If this becomes too memory intensive, it - * may be best to switch to a - * [androidx.fragment.app.FragmentStatePagerAdapter]. - */ - private var mSectionsPagerAdapter: SectionsPagerAdapter? = null + private lateinit var appBarConfiguration : AppBarConfiguration + + // Handler and Runnable used to add a timer for user inaction and close the app if enough time has passed + private lateinit var mHandler: Handler + private lateinit var mRunnable: Runnable override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Sets the theme to night mode if it has been selected by the user + if (PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean(Constants.KEY_NIGHT_MODE_ACTIVATED, false)) { + setTheme(R.style.Theme_Bitsy_Dark_NoActionBar) + } setContentView(R.layout.activity_main) - // Create the adapter that will return a fragment for each of the three - // primary sections of the activity. - mSectionsPagerAdapter = SectionsPagerAdapter(supportFragmentManager) + setSupportActionBar(toolbar) - // Set up the ViewPager with the sections adapter. - viewPager.adapter = mSectionsPagerAdapter - tabLayout.setupWithViewPager(viewPager) + val host: NavHostFragment = supportFragmentManager + .findFragmentById(R.id.navHostFragment) as NavHostFragment? ?: return - // Force first tab to show BTS icon - tabLayout.getTabAt(0)?.setIcon(R.drawable.tab_home_selector) + // Set up Action Bar with Navigation's controller + val navController = host.navController - initBottomBar() + appBarConfiguration = AppBarConfiguration(navController.graph) - ivSettings.setOnClickListener { - val intent = Intent(this, SettingsActivity::class.java) - startActivity(intent) + // Sets up the ActionBar with the navigation controller so that it automatically responds to clicks on toolbar + // menu items and shows the up navigation button on all fragments except home (Balances) + setupActionBarWithNavController(navController, appBarConfiguration) + + mHandler = Handler() + + // When this runnable finishes it first verifies if the auto close feature is enabled and if it is then it + // closes the app, if not then it just restarts the Handler (timer) + mRunnable = Runnable { + if (PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean(Constants.KEY_AUTO_CLOSE_ACTIVATED, true)) + finish() + else + restartHandler() } - } - - private fun initBottomBar() { - // Show app version number in bottom bar - tvBuildVersion.text = String.format("v%s", BuildConfig.VERSION_NAME) - - // Show block number in bottom bar - tvBlockNumber.text = getString(R.string.block_number_bottom_bar, "-----") - - // TODO add listener to update block number + startHandler() } /** - * A [FragmentPagerAdapter] that returns a fragment corresponding to - * one of the sections/tabs/pages. + * Restarts the Handler (timer) each time there is user's interaction */ - inner class SectionsPagerAdapter(fm: FragmentManager) : FragmentPagerAdapter(fm) { + override fun onUserInteraction() { + super.onUserInteraction() + restartHandler() + } - override fun getItem(position: Int): Fragment { - return when (position) { - 0 -> BalancesFragment() - 1 -> TransactionsFragment() - else -> MerchantsFragment() - } - } + /** + * Stops and then restarts the Handler + */ + private fun restartHandler() { + stopHandler() + startHandler() + } - override fun getCount(): Int { - // Show 3 total pages. - return 3 - } + private fun stopHandler() { + mHandler.removeCallbacks(mRunnable) + } + + private fun startHandler() { + mHandler.postDelayed(mRunnable, 3 * 60 * 1000) //for 3 minutes + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + // Have the NavigationUI look for an action or destination matching the menu + // item id and navigate there if found. + // Otherwise, bubble up to the parent. + return item.onNavDestinationSelected(findNavController(R.id.navHostFragment)) + || super.onOptionsItemSelected(item) + } + + override fun onSupportNavigateUp(): Boolean { + // Allows NavigationUI to support proper up navigation or the drawer layout + // drawer menu, depending on the situation + return findNavController(R.id.navHostFragment).navigateUp(appBarConfiguration) + } + + override fun handleJsonRpcResponse(response: JsonRpcResponse<*>) { + + } + + /** + * Private method called whenever there's an update to the connection status + * @param connectionStatusUpdate Connection status update. + */ + override fun handleConnectionStatusUpdate(connectionStatusUpdate: ConnectionStatusUpdate) { - override fun getPageTitle(position: Int): CharSequence? { - return when (position) { - 0 -> "" - 1 -> getString(R.string.title_transactions) - else -> getString(R.string.title_merchants) - } - } } } diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/SendTransactionActivity.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/SendTransactionActivity.kt deleted file mode 100644 index a6969d6..0000000 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/SendTransactionActivity.kt +++ /dev/null @@ -1,13 +0,0 @@ -package cy.agorise.bitsybitshareswallet.activities - -import androidx.appcompat.app.AppCompatActivity -import android.os.Bundle -import cy.agorise.bitsybitshareswallet.R - -class SendTransactionActivity : AppCompatActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_send_transaction) - } -} diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/SettingsActivity.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/SettingsActivity.kt deleted file mode 100644 index 173bc70..0000000 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/SettingsActivity.kt +++ /dev/null @@ -1,65 +0,0 @@ -package cy.agorise.bitsybitshareswallet.activities - -import android.os.Bundle -import android.preference.PreferenceManager -import android.view.MenuItem -import androidx.appcompat.app.AppCompatActivity -import cy.agorise.bitsybitshareswallet.R -import cy.agorise.bitsybitshareswallet.utils.Constants -import kotlinx.android.synthetic.main.activity_settings.* - -/** - * A simple activity for the user to select his preferences - */ -class SettingsActivity : AppCompatActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // TODO move the below to a BaseActivity to apply it to all activities - // Sets the theme to night mode if it has been selected by the user - if (PreferenceManager.getDefaultSharedPreferences(this) - .getBoolean(Constants.KEY_NIGHT_MODE_ACTIVATED, false) - ) { - setTheme(R.style.AppTheme_Dark) - } - - setContentView(R.layout.activity_settings) - - setupActionBar() - - initNightModeSwitch() - } - - /** - * Set up the [android.app.ActionBar], if the API is available. - */ - private fun setupActionBar() { - supportActionBar?.setDisplayHomeAsUpEnabled(true) - } - - private fun initNightModeSwitch() { - val nightModeOn = PreferenceManager.getDefaultSharedPreferences(this) - .getBoolean(Constants.KEY_NIGHT_MODE_ACTIVATED, false) - - switchNightMode.isChecked = nightModeOn - - switchNightMode.setOnCheckedChangeListener { buttonView, isChecked -> - - PreferenceManager.getDefaultSharedPreferences(buttonView.context).edit() - .putBoolean(Constants.KEY_NIGHT_MODE_ACTIVATED, isChecked).apply() - - // Recreates the activity to apply the selected theme - this.recreate() - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - onBackPressed() - return true - } - return super.onOptionsItemSelected(item) - } - -} diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ReceiveTransactionActivity.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/SplashActivity.kt similarity index 55% rename from app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ReceiveTransactionActivity.kt rename to app/src/main/java/cy/agorise/bitsybitshareswallet/activities/SplashActivity.kt index 841493c..ccb60d4 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ReceiveTransactionActivity.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/SplashActivity.kt @@ -1,13 +1,16 @@ package cy.agorise.bitsybitshareswallet.activities -import androidx.appcompat.app.AppCompatActivity +import android.content.Intent import android.os.Bundle -import cy.agorise.bitsybitshareswallet.R +import androidx.appcompat.app.AppCompatActivity -class ReceiveTransactionActivity : AppCompatActivity() { +class SplashActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_receive_transaction) + + val intent = Intent(this, LicenseActivity::class.java) + startActivity(intent) + finish() } -} +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/AssetsAdapter.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/AssetsAdapter.kt new file mode 100644 index 0000000..ccfd3cc --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/AssetsAdapter.kt @@ -0,0 +1,40 @@ +package cy.agorise.bitsybitshareswallet.adapters + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.TextView +import cy.agorise.bitsybitshareswallet.database.entities.Asset + +class AssetsAdapter(context: Context, resource: Int, data: List) : + ArrayAdapter(context, resource, data) { + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + var cv = convertView + + if (cv == null) { + val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + cv = inflater.inflate(android.R.layout.simple_spinner_item, parent, false) + } + + val text: TextView = cv!!.findViewById(android.R.id.text1) + + val asset = getItem(position) + text.text = asset!!.symbol + + return cv + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + val v = inflater.inflate(android.R.layout.simple_spinner_dropdown_item, parent, false) + val text: TextView = v.findViewById(android.R.id.text1) + + val asset = getItem(position) + text.text = asset!!.symbol + + return v + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/AutoSuggestAssetAdapter.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/AutoSuggestAssetAdapter.kt new file mode 100644 index 0000000..a80229b --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/AutoSuggestAssetAdapter.kt @@ -0,0 +1,24 @@ +package cy.agorise.bitsybitshareswallet.adapters + +import android.content.Context +import android.widget.ArrayAdapter +import cy.agorise.bitsybitshareswallet.database.entities.Asset + +class AutoSuggestAssetAdapter(context: Context, resource: Int): + ArrayAdapter(context, resource) { + + private var mAssets = ArrayList() + + fun setData(assets: List) { + mAssets.clear() + mAssets.addAll(assets) + } + + override fun getCount(): Int { + return mAssets.size + } + + override fun getItem(position: Int): Asset? { + return mAssets[position] + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/BalancesAdapter.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/BalancesAdapter.kt new file mode 100644 index 0000000..58451f0 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/BalancesAdapter.kt @@ -0,0 +1,107 @@ +package cy.agorise.bitsybitshareswallet.adapters + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SortedList +import cy.agorise.bitsybitshareswallet.R +import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetail + +class BalancesAdapter(private val context: Context) : + RecyclerView.Adapter() { + + private val mComparator = + Comparator { a, b -> a.symbol.compareTo(b.symbol) } + + private val mSortedList = + SortedList(BalanceDetail::class.java, object : SortedList.Callback() { + override fun onInserted(position: Int, count: Int) { + notifyItemRangeInserted(position, count) + } + + override fun onRemoved(position: Int, count: Int) { + notifyItemRangeRemoved(position, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + notifyItemMoved(fromPosition, toPosition) + } + + override fun onChanged(position: Int, count: Int) { + notifyItemRangeChanged(position, count) + } + + override fun compare(a: BalanceDetail, b: BalanceDetail): Int { + return mComparator.compare(a, b) + } + + override fun areContentsTheSame(oldItem: BalanceDetail, newItem: BalanceDetail): Boolean { + return oldItem == newItem + } + + override fun areItemsTheSame(item1: BalanceDetail, item2: BalanceDetail): Boolean { + return item1.id == item2.id + } + }) + + inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val tvSymbol: TextView = itemView.findViewById(R.id.tvSymbol) + val tvAmount: TextView = itemView.findViewById(R.id.tvAmount) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BalancesAdapter.ViewHolder { + val inflater = LayoutInflater.from(context) + + val balanceView = inflater.inflate(R.layout.item_balance, parent, false) + + return ViewHolder(balanceView) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + val balance = mSortedList.get(position) + + viewHolder.tvSymbol.text = balance.symbol + + val amount = balance.amount.toDouble() / Math.pow(10.0, balance.precision.toDouble()) + viewHolder.tvAmount.text = String.format("%." + Math.min(balance.precision, 8) + "f", amount) + } + + fun add(balance: BalanceDetail) { + mSortedList.add(balance) + } + + fun remove(balance: BalanceDetail) { + mSortedList.remove(balance) + } + + fun add(balances: List) { + mSortedList.addAll(balances) + } + + fun remove(balances: List) { + mSortedList.beginBatchedUpdates() + for (balance in balances) { + mSortedList.remove(balance) + } + mSortedList.endBatchedUpdates() + } + + fun replaceAll(balances: List) { + mSortedList.beginBatchedUpdates() + for (i in mSortedList.size() - 1 downTo 0) { + val balance = mSortedList.get(i) + if (!balances.contains(balance)) { + mSortedList.remove(balance) + } + } + mSortedList.addAll(balances) + mSortedList.endBatchedUpdates() + } + + override fun getItemCount(): Int { + return mSortedList.size() + } +} diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/BalancesDetailsAdapter.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/BalancesDetailsAdapter.kt new file mode 100644 index 0000000..c939027 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/BalancesDetailsAdapter.kt @@ -0,0 +1,42 @@ +package cy.agorise.bitsybitshareswallet.adapters + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.TextView +import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetail + + + +class BalancesDetailsAdapter(context: Context, resource: Int, data: List) : + ArrayAdapter(context, resource, data) { + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + var cv = convertView + + if (cv == null) { + val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + cv = inflater.inflate(android.R.layout.simple_spinner_item, parent, false) + } + + val text: TextView = cv!!.findViewById(android.R.id.text1) + + val balanceDetail = getItem(position) + text.text = balanceDetail!!.symbol + + return cv + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + val v = inflater.inflate(android.R.layout.simple_spinner_dropdown_item, parent, false) + val text: TextView = v.findViewById(android.R.id.text1) + + val balanceDetail = getItem(position) + text.text = balanceDetail!!.symbol + + return v + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/FullNodesAdapter.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/FullNodesAdapter.kt new file mode 100644 index 0000000..ae46c79 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/FullNodesAdapter.kt @@ -0,0 +1,140 @@ +package cy.agorise.bitsybitshareswallet.adapters + +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.SortedList +import androidx.recyclerview.widget.RecyclerView +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import cy.agorise.bitsybitshareswallet.R +import cy.agorise.graphenej.network.FullNode +import java.util.* + + +/** + * Adapter used to populate the elements of the Bitshares nodes dialog in order to show a list of + * nodes with their latency. + */ +class FullNodesAdapter(private val context: Context) : RecyclerView.Adapter() { + val TAG: String = this.javaClass.name + + private val mComparator = + Comparator { a, b -> java.lang.Double.compare(a.latencyValue, b.latencyValue) } + + inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val tvNodeName: TextView = itemView.findViewById(R.id.tvNodeName) + val ivNodeStatus: ImageView = itemView.findViewById(R.id.ivNodeStatus) + } + + private val mSortedList = SortedList(FullNode::class.java, object : SortedList.Callback() { + override fun onInserted(position: Int, count: Int) { + notifyItemRangeInserted(position, count) + } + + override fun onRemoved(position: Int, count: Int) { + notifyItemRangeRemoved(position, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + notifyItemMoved(fromPosition, toPosition) + } + + override fun onChanged(position: Int, count: Int) { + notifyItemRangeChanged(position, count) + } + + override fun compare(a: FullNode, b: FullNode): Int { + return mComparator.compare(a, b) + } + + override fun areContentsTheSame(oldItem: FullNode, newItem: FullNode): Boolean { + return oldItem.latencyValue == newItem.latencyValue + } + + override fun areItemsTheSame(item1: FullNode, item2: FullNode): Boolean { + return item1.url == item2.url + } + }) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FullNodesAdapter.ViewHolder { + val inflater = LayoutInflater.from(context) + + val transactionView = inflater.inflate(R.layout.item_node, parent, false) + + return ViewHolder(transactionView) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + val fullNode = mSortedList[position] + + // Show the green check mark before the node name if that node is the one being used + if (fullNode.isConnected) + viewHolder.ivNodeStatus.setImageResource(R.drawable.ic_connected) + else + viewHolder.ivNodeStatus.setImageDrawable(null) + + val latency = fullNode.latencyValue + + // Select correct color span according to the latency value + val colorSpan = when { + latency < 400 -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.ppGreen)) + latency < 800 -> ForegroundColorSpan(Color.rgb(255,136,0)) // Holo orange + else -> ForegroundColorSpan(Color.rgb(204,0,0)) // Holo red + } + + // Create a string with the latency number colored according to their amount + val ssb = SpannableStringBuilder() + ssb.append(fullNode.url.replace("wss://", ""), StyleSpan(Typeface.BOLD), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + ssb.append(" (") + + // 2000 ms is the timeout of the websocket used to calculate the latency, therefore if the + // received latency is greater than such value we can assume the node was not reachable. + val ms = if(latency < 2000) "%.0f ms".format(latency) else "??" + + ssb.append(ms, colorSpan, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + ssb.append(")") + + viewHolder.tvNodeName.text = ssb + } + + /** + * Functions that adds/updates a FullNode to the SortedList + */ + fun add(fullNode: FullNode) { + // Remove the old instance of the FullNode before adding a new one. My understanding is that + // the sorted list should be able to automatically find repeated elements and update them + // instead of adding duplicates but it wasn't working so I opted for manually removing old + // instances of FullNodes before adding the updated ones. + var removed = 0 + for (i in 0 until mSortedList.size()) + if (mSortedList[i-removed].url == (fullNode.url)) + mSortedList.removeItemAt(i-removed++) + + mSortedList.add(fullNode) + } + + /** + * Function that adds a whole list of nodes to the SortedList. It should only be used at the + * moment of populating the SortedList for the first time. + */ + fun add(fullNodes: List) { + mSortedList.addAll(fullNodes) + } + + fun remove(fullNode: FullNode) { + mSortedList.remove(fullNode) + } + + override fun getItemCount(): Int { + return mSortedList.size() + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/TransfersDetailsAdapter.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/TransfersDetailsAdapter.kt new file mode 100644 index 0000000..42429da --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/adapters/TransfersDetailsAdapter.kt @@ -0,0 +1,173 @@ +package cy.agorise.bitsybitshareswallet.adapters + +import android.content.Context +import android.preference.PreferenceManager +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SortedList +import cy.agorise.bitsybitshareswallet.R +import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail +import cy.agorise.bitsybitshareswallet.utils.Constants +import java.math.RoundingMode +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.text.SimpleDateFormat +import java.util.* + +class TransfersDetailsAdapter(private val context: Context) : + RecyclerView.Adapter() { + + val userId = PreferenceManager.getDefaultSharedPreferences(context) + .getString(Constants.KEY_CURRENT_ACCOUNT_ID, "")!! + + private val mComparator = + Comparator { a, b -> b.id.compareTo(a.id) } + + private val mSortedList = + SortedList(TransferDetail::class.java, object : SortedList.Callback() { + override fun onInserted(position: Int, count: Int) { + notifyItemRangeInserted(position, count) + } + + override fun onRemoved(position: Int, count: Int) { + notifyItemRangeRemoved(position, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + notifyItemMoved(fromPosition, toPosition) + } + + override fun onChanged(position: Int, count: Int) { + notifyItemRangeChanged(position, count) + } + + override fun compare(a: TransferDetail, b: TransferDetail): Int { + return mComparator.compare(a, b) + } + + override fun areContentsTheSame(oldItem: TransferDetail, newItem: TransferDetail): Boolean { + return oldItem == newItem + } + + override fun areItemsTheSame(item1: TransferDetail, item2: TransferDetail): Boolean { + return item1.id == item2.id + } + }) + + private val dateFormat: SimpleDateFormat + private val timeFormat: SimpleDateFormat + + init { + val locale = context.resources.configuration.locale + dateFormat = SimpleDateFormat("dd MMM", locale) + timeFormat = SimpleDateFormat("HH:mm:ss z", locale) + } + + inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val rootView: ConstraintLayout = itemView.findViewById(R.id.rootView) + val vPaymentDirection: View = itemView.findViewById(R.id.vPaymentDirection) + val tvFrom: TextView = itemView.findViewById(R.id.tvFrom) + val ivDirectionArrow: ImageView = itemView.findViewById(R.id.ivDirectionArrow) + val tvTo: TextView = itemView.findViewById(R.id.tvTo) + val llMemo: LinearLayout = itemView.findViewById(R.id.llMemo) + val tvMemo: TextView = itemView.findViewById(R.id.tvMemo) + val tvDate: TextView = itemView.findViewById(R.id.tvDate) + val tvTime: TextView = itemView.findViewById(R.id.tvTime) + val tvCryptoAmount: TextView = itemView.findViewById(R.id.tvCryptoAmount) + val tvFiatEquivalent: TextView = itemView.findViewById(R.id.tvFiatEquivalent) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TransfersDetailsAdapter.ViewHolder { + val inflater = LayoutInflater.from(context) + + val transactionView = inflater.inflate(R.layout.item_transaction, parent, false) + + return ViewHolder(transactionView) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + val transferDetail = mSortedList.get(position) + + viewHolder.vPaymentDirection.setBackgroundColor(context.resources.getColor( + if(transferDetail.direction) R.color.colorReceive else R.color.colorSend + )) + + viewHolder.tvFrom.text = transferDetail.from ?: "" + viewHolder.tvTo.text = transferDetail.to ?: "" + + // Shows memo if available + val memo = transferDetail.memo + if (memo == "") { + viewHolder.tvMemo.text = "" + viewHolder.llMemo.visibility = View.GONE + } else { + viewHolder.tvMemo.text = memo + viewHolder.llMemo.visibility = View.VISIBLE + } + + // Format date and time + val date = Date(transferDetail.date * 1000) + + viewHolder.tvDate.text = dateFormat.format(date) + viewHolder.tvTime.text = timeFormat.format(date) + + // Show the crypto amount correctly formatted + // TODO lift the DecimalFormat declaration to other place to make things more efficient + val df = DecimalFormat("####."+("#".repeat(transferDetail.cryptoPrecision))) + df.roundingMode = RoundingMode.CEILING + df.decimalFormatSymbols = DecimalFormatSymbols(Locale.getDefault()) + + val amount = transferDetail.cryptoAmount.toDouble() / + Math.pow(10.toDouble(), transferDetail.cryptoPrecision.toDouble()) + val cryptoAmount = "${df.format(amount)} ${transferDetail.cryptoSymbol}" + viewHolder.tvCryptoAmount.text = cryptoAmount + + viewHolder.tvFiatEquivalent.text = "$4119.75" + + viewHolder.ivDirectionArrow.setImageDrawable(context.getDrawable( + if(transferDetail.direction) R.drawable.ic_arrow_receive else R.drawable.ic_arrow_send + )) + } + + fun add(transferDetail: TransferDetail) { + mSortedList.add(transferDetail) + } + + fun remove(transferDetail: TransferDetail) { + mSortedList.remove(transferDetail) + } + + fun add(transfersDetails: List) { + mSortedList.addAll(transfersDetails) + } + + fun remove(transfersDetails: List) { + mSortedList.beginBatchedUpdates() + for (transferDetail in transfersDetails) { + mSortedList.remove(transferDetail) + } + mSortedList.endBatchedUpdates() + } + + fun replaceAll(transfersDetails: List) { + mSortedList.beginBatchedUpdates() + for (i in mSortedList.size() - 1 downTo 0) { + val transferDetail = mSortedList.get(i) + if (!transfersDetails.contains(transferDetail)) { + mSortedList.remove(transferDetail) + } + } + mSortedList.addAll(transfersDetails) + mSortedList.endBatchedUpdates() + } + + override fun getItemCount(): Int { + return mSortedList.size() + } +} diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/BitsyDatabase.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/BitsyDatabase.kt new file mode 100644 index 0000000..8cfd754 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/BitsyDatabase.kt @@ -0,0 +1,52 @@ +package cy.agorise.bitsybitshareswallet.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import cy.agorise.bitsybitshareswallet.database.daos.* +import cy.agorise.bitsybitshareswallet.database.entities.* +import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetailDao +import cy.agorise.bitsybitshareswallet.database.joins.TransferDetailDao + +@Database(entities = [ + Asset::class, + Authority::class, + Balance::class, + EquivalentValue::class, + Transfer::class, + UserAccount::class + ], + version = 1, exportSchema = false) +abstract class BitsyDatabase : RoomDatabase() { + + abstract fun assetDao(): AssetDao + abstract fun authorityDao(): AuthorityDao + abstract fun balanceDao(): BalanceDao + abstract fun equivalentValueDao(): EquivalentValueDao + abstract fun transferDao(): TransferDao + abstract fun userAccountDao(): UserAccountDao + abstract fun balanceDetailDao(): BalanceDetailDao + abstract fun transferDetailDao(): TransferDetailDao + + companion object { + + // To make sure there is always only one instance of the database open + @Volatile + private var INSTANCE: BitsyDatabase? = null + + internal fun getDatabase(context: Context): BitsyDatabase? { + if (INSTANCE == null) { + synchronized(BitsyDatabase::class.java) { + INSTANCE = Room.databaseBuilder( + context.applicationContext, + BitsyDatabase::class.java, "BiTSyWallet.db" + ) + .build() + } + } + + return INSTANCE + } + } +} diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/AssetDao.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/AssetDao.kt new file mode 100644 index 0000000..b966ae2 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/AssetDao.kt @@ -0,0 +1,20 @@ +package cy.agorise.bitsybitshareswallet.database.daos + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import cy.agorise.bitsybitshareswallet.database.entities.Asset + +@Dao +interface AssetDao { + @Insert + fun insert(asset: Asset) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(assets: List) + + @Query("SELECT * FROM assets") + fun getAll(): LiveData> +} diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/AuthorityDao.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/AuthorityDao.kt new file mode 100644 index 0000000..fecff8c --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/AuthorityDao.kt @@ -0,0 +1,23 @@ +package cy.agorise.bitsybitshareswallet.database.daos + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import cy.agorise.bitsybitshareswallet.database.entities.Authority +import io.reactivex.Single + +@Dao +interface AuthorityDao { + @Insert + fun insert(authority: Authority) + + @Query("SELECT * FROM authorities WHERE user_id=:userId LIMIT 1") + fun get(userId: String): Single + + @Query("SELECT * FROM authorities") + fun getAll(): LiveData> + + @Query("SELECT encrypted_wif FROM authorities WHERE user_id=:userId AND authority_type=:authorityType") + fun getWIF(userId: String, authorityType: Int): Single +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/BalanceDao.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/BalanceDao.kt new file mode 100644 index 0000000..237fee1 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/BalanceDao.kt @@ -0,0 +1,24 @@ +package cy.agorise.bitsybitshareswallet.database.daos + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import cy.agorise.bitsybitshareswallet.database.entities.Balance + +@Dao +interface BalanceDao { + @Insert + fun insert(balance: Balance) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(balances: List) + + @Query("SELECT * FROM balances") + fun getAll(): LiveData> + + // TODO not sure if this is the best place for this query as it involves two entities + @Query("SELECT DISTINCT asset_id FROM balances WHERE asset_id NOT IN (SELECT id FROM assets)") + fun getMissingAssetIds(): LiveData> +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/EquivalentValueDao.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/EquivalentValueDao.kt new file mode 100644 index 0000000..314c18a --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/EquivalentValueDao.kt @@ -0,0 +1,16 @@ +package cy.agorise.bitsybitshareswallet.database.daos + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import cy.agorise.bitsybitshareswallet.database.entities.EquivalentValue + +@Dao +interface EquivalentValueDao { + @Insert + fun insert(equivalentValue: EquivalentValue) + + @Query("SELECT * FROM equivalent_values") + fun getAllEquivalentValues(): LiveData> +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/TransferDao.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/TransferDao.kt new file mode 100644 index 0000000..63aeaee --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/TransferDao.kt @@ -0,0 +1,34 @@ +package cy.agorise.bitsybitshareswallet.database.daos + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import cy.agorise.bitsybitshareswallet.database.entities.Transfer +import io.reactivex.Single + +@Dao +interface TransferDao { + @Insert + fun insert(transfer: Transfer) + + // TODO find a way to return number of added rows + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insertAll(transfers: List) + + @Query("UPDATE transfers SET timestamp=:timestamp WHERE block_number=:blockNumber") + fun setBlockTime(blockNumber: Long, timestamp: Long) + + @Query("SELECT * FROM transfers") + fun getAll(): LiveData> + + @Query("SELECT COUNT(*) FROM transfers") + fun getCount(): Single + + @Query("SELECT block_number FROM transfers WHERE timestamp='0' LIMIT 1") + fun getTransferBlockNumberWithMissingTime(): LiveData + + @Query("DELETE FROM transfers") + fun deleteAll() +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/UserAccountDao.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/UserAccountDao.kt new file mode 100644 index 0000000..e121dc3 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/daos/UserAccountDao.kt @@ -0,0 +1,27 @@ +package cy.agorise.bitsybitshareswallet.database.daos + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import cy.agorise.bitsybitshareswallet.database.entities.UserAccount + +@Dao +interface UserAccountDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(userAccount: UserAccount) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(userAccounts: List) + + @Query("SELECT * FROM user_accounts WHERE user_accounts.id = :id") + fun getUserAccount(id: String): LiveData + + @Query("SELECT * FROM user_accounts") + fun getAll(): LiveData> + + // TODO not sure if this is the best place for this query as it involves two entities + @Query("SELECT DISTINCT destination FROM transfers WHERE destination NOT IN (SELECT id FROM user_accounts) UNION SELECT DISTINCT source FROM transfers WHERE source NOT IN (SELECT id FROM user_accounts)") + fun getMissingAccountIds(): LiveData> +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/Asset.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/Asset.kt new file mode 100644 index 0000000..e065ac6 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/Asset.kt @@ -0,0 +1,19 @@ +package cy.agorise.bitsybitshareswallet.database.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "assets") +data class Asset( + @PrimaryKey + @ColumnInfo(name = "id") val id: String, + @ColumnInfo(name = "symbol") val symbol: String, + @ColumnInfo(name = "precision") val precision: Int, + @ColumnInfo(name = "description") val description: String, + @ColumnInfo(name = "bit_asset_id") val bitAssetId: String +) { + override fun toString(): String { + return symbol + } +} diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/Authority.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/Authority.kt new file mode 100644 index 0000000..50d22c2 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/Authority.kt @@ -0,0 +1,16 @@ +package cy.agorise.bitsybitshareswallet.database.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "authorities") +data class Authority ( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long, + @ColumnInfo(name = "user_id") val userId: String, + @ColumnInfo(name = "authority_type") val authorityType: Int, + @ColumnInfo(name = "encrypted_wif") val encryptedWIF: String, + @ColumnInfo(name = "encrypted_brain_key") val encryptedBrainKey: String, + @ColumnInfo(name = "encrypted_sequence_number") val encryptedSequenceNumber: String +) \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/Balance.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/Balance.kt new file mode 100644 index 0000000..b0ae9ad --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/Balance.kt @@ -0,0 +1,13 @@ +package cy.agorise.bitsybitshareswallet.database.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "balances") +data class Balance( + @PrimaryKey + @ColumnInfo(name = "asset_id") val assetId: String, // TODO should be foreign key? + @ColumnInfo(name = "asset_amount") val assetAmount: Long, + @ColumnInfo(name = "last_update") val lastUpdate: Long +) \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/EquivalentValue.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/EquivalentValue.kt new file mode 100644 index 0000000..a702950 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/EquivalentValue.kt @@ -0,0 +1,25 @@ +package cy.agorise.bitsybitshareswallet.database.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Entity(tableName = "equivalent_values",foreignKeys = + [ForeignKey( + entity = Transfer::class, + parentColumns = ["id"], + childColumns = ["transfer_id"] + ), ForeignKey( + entity = Asset::class, + parentColumns = ["id"], + childColumns = ["asset_id"] + )] +) +data class EquivalentValue ( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long, + @ColumnInfo(name = "transfer_id") val transferId: String, + @ColumnInfo(name = "value") val value: Long, + @ColumnInfo(name = "asset_id") val assetId: String +) \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/Transfer.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/Transfer.kt new file mode 100644 index 0000000..f8caf61 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/Transfer.kt @@ -0,0 +1,20 @@ +package cy.agorise.bitsybitshareswallet.database.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "transfers") +data class Transfer ( + @PrimaryKey + @ColumnInfo(name = "id") val id: String, + @ColumnInfo(name = "block_number") val blockNumber: Long, + @ColumnInfo(name = "timestamp") val timestamp: Long, + @ColumnInfo(name = "fee_amount") val feeAmount: Long, + @ColumnInfo(name = "fee_asset_id") val feeAssetId: String, // TODO should be foreign key to Asset + @ColumnInfo(name = "source") val source: String, // TODO should be foreign key to UserAccount + @ColumnInfo(name = "destination") val destination: String, // TODO should be foreign key to UserAccount + @ColumnInfo(name = "transfer_amount") val transferAmount: Long, + @ColumnInfo(name = "transfer_asset_id") val transferAssetId: String, // TODO should be foreign key to Asset + @ColumnInfo(name = "memo") val memo: String +) \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/UserAccount.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/UserAccount.kt new file mode 100644 index 0000000..df063d2 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/entities/UserAccount.kt @@ -0,0 +1,13 @@ +package cy.agorise.bitsybitshareswallet.database.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "user_accounts") +data class UserAccount ( + @PrimaryKey + @ColumnInfo(name = "id") val id: String, + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "is_ltm") val isLtm: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/joins/BalanceDetail.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/joins/BalanceDetail.kt new file mode 100644 index 0000000..ab9e2f9 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/joins/BalanceDetail.kt @@ -0,0 +1,8 @@ +package cy.agorise.bitsybitshareswallet.database.joins + +data class BalanceDetail( + val id: String, + val amount: Long, + val precision: Int, + val symbol: String +) \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/joins/BalanceDetailDao.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/joins/BalanceDetailDao.kt new file mode 100644 index 0000000..dd7ec5d --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/joins/BalanceDetailDao.kt @@ -0,0 +1,12 @@ +package cy.agorise.bitsybitshareswallet.database.joins + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Query + +@Dao +interface BalanceDetailDao { + @Query("SELECT assets.id AS id, balances.asset_amount AS amount, assets.precision, assets.symbol " + + "FROM balances INNER JOIN assets on balances.asset_id = assets.id") + fun getAll(): LiveData> +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/joins/TransferDetail.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/joins/TransferDetail.kt new file mode 100644 index 0000000..f2483fa --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/joins/TransferDetail.kt @@ -0,0 +1,15 @@ +package cy.agorise.bitsybitshareswallet.database.joins + +data class TransferDetail( + val id: String, + val from: String?, + val to: String?, + val direction: Boolean, // True -> Received, False -> Sent + val memo: String, + val date: Long, + val cryptoAmount: Long, + val cryptoPrecision: Int, + val cryptoSymbol: String +// val fiatAmount: Long, +// val fiatCurrency: String +) \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/database/joins/TransferDetailDao.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/joins/TransferDetailDao.kt new file mode 100644 index 0000000..f33c365 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/database/joins/TransferDetailDao.kt @@ -0,0 +1,12 @@ +package cy.agorise.bitsybitshareswallet.database.joins + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Query + +@Dao +interface TransferDetailDao { + + @Query("SELECT transfers.id, (SELECT name FROM user_accounts WHERE user_accounts.id=transfers.source) AS `from`, (SELECT name FROM user_accounts WHERE user_accounts.id=transfers.destination) AS `to`, (CASE WHEN destination=:userId THEN 1 ELSE 0 END) AS `direction`, transfers.memo AS `memo`, transfers.timestamp AS `date`, transfers.transfer_amount AS `cryptoAmount`, assets.precision AS `cryptoPrecision`, assets.symbol AS cryptoSymbol FROM transfers INNER JOIN assets WHERE transfers.transfer_asset_id = assets.id") + fun getAll(userId: String): LiveData> +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/BalancesFragment.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/BalancesFragment.kt index 7d6f00e..0f0ecd6 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/BalancesFragment.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/BalancesFragment.kt @@ -1,52 +1,55 @@ package cy.agorise.bitsybitshareswallet.fragments -import androidx.lifecycle.ViewModelProviders -import android.content.Intent import android.os.Bundle -import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup - +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager import cy.agorise.bitsybitshareswallet.R -import cy.agorise.bitsybitshareswallet.activities.ReceiveTransactionActivity -import cy.agorise.bitsybitshareswallet.activities.SendTransactionActivity -import cy.agorise.bitsybitshareswallet.viewmodels.BalancesViewModel +import cy.agorise.bitsybitshareswallet.adapters.BalancesAdapter +import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetail +import cy.agorise.bitsybitshareswallet.viewmodels.BalanceDetailViewModel import kotlinx.android.synthetic.main.fragment_balances.* -class BalancesFragment : Fragment() { +class BalancesFragment: Fragment() { + private val TAG = this.javaClass.simpleName - companion object { - fun newInstance() = BalancesFragment() - } - - private lateinit var viewModel: BalancesViewModel + private lateinit var mBalanceDetailViewModel: BalanceDetailViewModel override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - return inflater.inflate(R.layout.fragment_balances, container, false) - } + setHasOptionsMenu(true) - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) - viewModel = ViewModelProviders.of(this).get(BalancesViewModel::class.java) - // TODO: Use the ViewModel + return inflater.inflate(R.layout.fragment_balances, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - btnReceive.setOnClickListener { - val intent = Intent(view.context, ReceiveTransactionActivity::class.java) - startActivity(intent) - } + // Configure BalanceDetailViewModel to show the current balances + mBalanceDetailViewModel = ViewModelProviders.of(this).get(BalanceDetailViewModel::class.java) - btnSend.setOnClickListener { - val intent = Intent(view.context, SendTransactionActivity::class.java) - startActivity(intent) - } + val balancesAdapter = BalancesAdapter(context!!) + rvBalances.adapter = balancesAdapter + rvBalances.layoutManager = LinearLayoutManager(context!!) + rvBalances.addItemDecoration(DividerItemDecoration(context!!, DividerItemDecoration.VERTICAL)) + + mBalanceDetailViewModel.getAll().observe(this, Observer> { balancesDetails -> + balancesAdapter.replaceAll(balancesDetails) + }) } -} + override fun setUserVisibleHint(isVisibleToUser: Boolean) { + super.setUserVisibleHint(isVisibleToUser) + if (isVisibleToUser) { + // TODO find a better way to recreate the fragment, that does it only when the theme has been changed + fragmentManager!!.beginTransaction().detach(this).attach(this).commit() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/FilterOptionsDialog.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/FilterOptionsDialog.kt new file mode 100644 index 0000000..d7c3b94 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/FilterOptionsDialog.kt @@ -0,0 +1,316 @@ +package cy.agorise.bitsybitshareswallet.fragments + + +import android.app.Dialog +import android.content.res.Resources +import android.os.Bundle +import android.os.Handler +import android.os.Message +import androidx.fragment.app.DialogFragment +import android.widget.* +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import cy.agorise.bitsybitshareswallet.R +import java.text.SimpleDateFormat +import java.util.* +import kotlin.ClassCastException + + +/** + * Creates a Dialog that communicates with {@link TransactionsActivity} to give it parameters about + * how to filter the list of Transactions + */ +class FilterOptionsDialog : DialogFragment() { + + // Widgets TODO use android-kotlin-extensions {onViewCreated} + lateinit var rbTransactionAll: RadioButton + lateinit var rbTransactionSent: RadioButton + lateinit var rbTransactionReceived: RadioButton + lateinit var cbDateRange: CheckBox + lateinit var llDateRange: LinearLayout + lateinit var tvStartDate: TextView + lateinit var tvEndDate: TextView + lateinit var cbCryptocurrency: CheckBox + lateinit var sCryptocurrency: Spinner + lateinit var cbFiatAmount: CheckBox + lateinit var llFiatAmount: LinearLayout +// lateinit var etFromFiatAmount: CurrencyEditText +// lateinit var etToFiatAmount: CurrencyEditText + + private var mCallback: OnFilterOptionsSelectedListener? = null + + private var mDatePickerHandler: DatePickerHandler? = null + + private var dateFormat: SimpleDateFormat = SimpleDateFormat("d/MMM/yyyy", + Resources.getSystem().configuration.locale) + + private var startDate: Long = 0 + private var endDate: Long = 0 + +// /** +// * Variable used to keep track of the current user's currency +// */ +// private val mUserCurrency = RuntimeData.EXTERNAL_CURRENCY + + companion object { + + const val KEY_FILTER_TRANSACTION_DIRECTION = "key_filter_transaction_direction" + const val KEY_FILTER_DATE_RANGE_ALL = "key_filter_date_range_all" + const val KEY_FILTER_START_DATE = "key_filter_start_date" + const val KEY_FILTER_END_DATE = "key_filter_end_date" + const val KEY_FILTER_CRYPTOCURRENCY_ALL = "key_filter_cryptocurrency_all" + const val KEY_FILTER_CRYPTOCURRENCY = "key_filter_cryptocurrency" + const val KEY_FILTER_FIAT_AMOUNT_ALL = "key_filter_fiat_amount_all" + const val KEY_FILTER_FROM_FIAT_AMOUNT = "filter_from_fiat_amount" + const val KEY_FILTER_TO_FIAT_AMOUNT = "filter_to_fiat_amount" + + const val KEY_TIMESTAMP = "key_timestamp" + + const val START_DATE_PICKER = 0 + const val END_DATE_PICKER = 1 + + fun newInstance(filterTransactionsDirection: Int, filterDateRangeAll: Boolean, + filterStartDate: Long, filterEndDate: Long, filterCryptocurrencyAll: Boolean, + filterCryptocurrency: String, filterFiatAmountAll: Boolean, + filterFromFiatAmount: Long, filterToFiatAmount: Long): FilterOptionsDialog { + val frag = FilterOptionsDialog() + val args = Bundle() + args.putInt(KEY_FILTER_TRANSACTION_DIRECTION, filterTransactionsDirection) + args.putBoolean(KEY_FILTER_DATE_RANGE_ALL, filterDateRangeAll) + args.putLong(KEY_FILTER_START_DATE, filterStartDate) + args.putLong(KEY_FILTER_END_DATE, filterEndDate) + args.putBoolean(KEY_FILTER_CRYPTOCURRENCY_ALL, filterCryptocurrencyAll) + args.putString(KEY_FILTER_CRYPTOCURRENCY, filterCryptocurrency) + args.putBoolean(KEY_FILTER_FIAT_AMOUNT_ALL, filterFiatAmountAll) + args.putLong(KEY_FILTER_FROM_FIAT_AMOUNT, filterFromFiatAmount) + args.putLong(KEY_FILTER_TO_FIAT_AMOUNT, filterToFiatAmount) + frag.arguments = args + return frag + } + + } + + /** + * DatePicker message handler. + */ + inner class DatePickerHandler : Handler() { + + override fun handleMessage(msg: Message) { + super.handleMessage(msg) + val bundle = msg.data + val timestamp = bundle.get(KEY_TIMESTAMP) as Long + //Log.d(TAG, "timestamp: $timestamp") + when (msg.arg1) { + START_DATE_PICKER -> { + startDate = timestamp + + updateDateTextViews() + } + END_DATE_PICKER -> { + endDate = timestamp + + // Make sure there is at least one moth difference between start and end time + val calendar = Calendar.getInstance() + calendar.timeInMillis = endDate + calendar.add(Calendar.MONTH, -1) + + val tmpTime = calendar.timeInMillis + + if (tmpTime < startDate) + startDate = tmpTime + + updateDateTextViews() + } + } + } + } + + private fun updateDateTextViews() { + var date = Date(startDate) + tvStartDate.text = dateFormat.format(date) + + date = Date(endDate) + tvEndDate.text = dateFormat.format(date) + } + + // Container Activity must implement this interface + interface OnFilterOptionsSelectedListener { + fun onFilterOptionsSelected(filterTransactionsDirection: Int, + filterDateRangeAll: Boolean, + filterStartDate: Long, + filterEndDate: Long, + filterCryptocurrencyAll: Boolean, + filterCryptocurrency: String, + filterFiatAmountAll: Boolean, + filterFromFiatAmount: Long, + filterToFiatAmount: Long) + } + + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + onAttachToParentFragment(parentFragment!!) + + // Initialize handler for communication with the DatePicker + mDatePickerHandler = DatePickerHandler() + + val builder = AlertDialog.Builder(context!!) + .setTitle("Filter options") + .setPositiveButton("Filter") { _, _ -> validateFields() } + .setNegativeButton("Cancel") { _, _ -> dismiss() } + + // Inflate layout + val inflater = activity!!.layoutInflater + val view = inflater.inflate(R.layout.dialog_filter_options, null) + + // Initialize Transactions direction + rbTransactionAll = view.findViewById(R.id.rbTransactionAll) + rbTransactionSent = view.findViewById(R.id.rbTransactionSent) + rbTransactionReceived = view.findViewById(R.id.rbTransactionReceived) + val radioButtonChecked = arguments!!.getInt(KEY_FILTER_TRANSACTION_DIRECTION, 0) + when (radioButtonChecked) { + 0 -> rbTransactionAll.isChecked = true + 1 -> rbTransactionSent.isChecked = true + 2 -> rbTransactionReceived.isChecked = true + } + + // Initialize Date range + cbDateRange = view.findViewById(R.id.cbDateRange) +// llDateRange = view.findViewById(R.id.llDateRange) +// cbDateRange.setOnCheckedChangeListener { _, isChecked -> +// llDateRange.visibility = if(isChecked) View.GONE else View.VISIBLE } + cbDateRange.isChecked = arguments!!.getBoolean(KEY_FILTER_DATE_RANGE_ALL, true) +// +// tvStartDate = view.findViewById(R.id.tvStartDate) +// tvEndDate = view.findViewById(R.id.tvEndDate) +// +// startDate = arguments!!.getLong(KEY_FILTER_START_DATE, 0) +// tvStartDate.setOnClickListener(mDateClickListener) +// +// endDate = arguments!!.getLong(KEY_FILTER_END_DATE, 0) +// tvEndDate.setOnClickListener(mDateClickListener) +// +// updateDateTextViews() + + // Initialize Cryptocurrency + cbCryptocurrency = view.findViewById(R.id.cbCryptocurrency) +// sCryptocurrency = view.findViewById(R.id.sCryptocurrency) +// cbCryptocurrency.setOnCheckedChangeListener { _, isChecked -> +// sCryptocurrency.visibility = if(isChecked) View.GONE else View.VISIBLE } + cbCryptocurrency.isChecked = arguments!!.getBoolean(KEY_FILTER_CRYPTOCURRENCY_ALL, true) + +// sCryptocurrency = view.findViewById(R.id.sCryptocurrency) +// initializeCryptocurrencySpinner() + + + // Initialize Fiat amount + cbFiatAmount = view.findViewById(R.id.cbFiatAmount) +// llFiatAmount = view.findViewById(R.id.llFiatAmount) +// cbFiatAmount.setOnCheckedChangeListener { _, isChecked -> +// llFiatAmount.visibility = if(isChecked) View.GONE else View.VISIBLE } + cbFiatAmount.isChecked = arguments!!.getBoolean(KEY_FILTER_FIAT_AMOUNT_ALL, true) + +// val locale = Resources.getSystem().configuration.locale +// +// etFromFiatAmount = view.findViewById(R.id.etFromFiatAmount) +// etFromFiatAmount.locale = locale +// val fromFiatAmount = arguments!!.getLong(KEY_FILTER_FROM_FIAT_AMOUNT, 0) +// etFromFiatAmount.setText("$fromFiatAmount", TextView.BufferType.EDITABLE) +// +// etToFiatAmount = view.findViewById(R.id.etToFiatAmount) +// etToFiatAmount.locale = locale +// val toFiatAmount = arguments!!.getLong(KEY_FILTER_TO_FIAT_AMOUNT, 0) +// etToFiatAmount.setText("$toFiatAmount", TextView.BufferType.EDITABLE) + + builder.setView(view) + + return builder.create() + } + + /** + * Attaches the current [DialogFragment] to its [Fragment] parent, to initialize the + * [OnFilterOptionsSelectedListener] interface + */ + private fun onAttachToParentFragment(fragment: Fragment) { + try { + mCallback = fragment as OnFilterOptionsSelectedListener + } catch (e: ClassCastException) { + throw ClassCastException(fragment.toString() + " must implement OnFilterOptionsSelectedListener") + } + } + +// private fun initializeCryptocurrencySpinner() { +// val cryptoCurrencyList = database!!.getSortedCryptoCurrencies(false, +// SortType.DESCENDING, true) +// +// val cryptocurrencySpinnerAdapter = CryptocurrencySpinnerAdapter(context!!, +// R.layout.item_cryptocurrency, +// R.id.tvCryptocurrencyName, +// cryptoCurrencyList) +// +// sCryptocurrency.adapter = cryptocurrencySpinnerAdapter +// +// val cryptocurrencySelected = arguments!!.getString(KEY_FILTER_CRYPTOCURRENCY) +// +// val index = Math.max(cryptocurrencySpinnerAdapter.getPosition(database!!.getCryptocurrencyBySymbol( +// cryptocurrencySelected)), 0) +// +// sCryptocurrency.setSelection(index) +// } + +// private val mDateClickListener = View.OnClickListener { v -> +// val calendar = Calendar.getInstance() +// +// // Variable used to select that date on the calendar +// var currentTime = calendar.timeInMillis +// var maxTime = currentTime +// +// var which = -1 +// if (v.id == R.id.tvStartDate) { +// which = START_DATE_PICKER +// currentTime = startDate +// calendar.timeInMillis = endDate +// calendar.add(Calendar.MONTH, -1) +// maxTime = calendar.timeInMillis +// } else if (v.id == R.id.tvEndDate) { +// which = END_DATE_PICKER +// currentTime = endDate +// } +// +// val datePickerFragment = DatePickerFragment.newInstance(which, currentTime, +// maxTime, mDatePickerHandler) +// datePickerFragment.show(activity!!.supportFragmentManager, "date-picker") +// } + + private fun validateFields() { + val filterTransactionsDirection = when { + rbTransactionAll.isChecked -> 0 + rbTransactionSent.isChecked -> 1 + rbTransactionReceived.isChecked -> 2 + else -> { 0 } + } + + val filterDateRangeAll = cbDateRange.isChecked + + val filterCryptocurrencyAll = cbCryptocurrency.isChecked + + val filterCryptocurrency = "" //(sCryptocurrency.selectedItem as CryptoCurrency).symbol + + val filterFiatAmountAll = cbFiatAmount.isChecked + + val filterFromFiatAmount = 0L//(etFromFiatAmount.currencyDouble * +// Math.pow(10.0, mUserCurrency.defaultFractionDigits.toDouble())).toLong() + + var filterToFiatAmount = 1L//(etToFiatAmount.currencyDouble * +// Math.pow(10.0, mUserCurrency.defaultFractionDigits.toDouble())).toLong() + + // Make sure ToFiatAmount is at least 50 units bigger than FromFiatAmount +// if (!filterFiatAmountAll && filterToFiatAmount <= filterFromFiatAmount) { +// filterToFiatAmount = filterFromFiatAmount + 50 * +// Math.pow(10.0, mUserCurrency.defaultFractionDigits.toDouble()).toLong() +// } + + mCallback!!.onFilterOptionsSelected(filterTransactionsDirection, filterDateRangeAll, + startDate, endDate, filterCryptocurrencyAll, filterCryptocurrency, filterFiatAmountAll, + filterFromFiatAmount, filterToFiatAmount) + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/HomeFragment.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/HomeFragment.kt new file mode 100644 index 0000000..a531972 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/HomeFragment.kt @@ -0,0 +1,101 @@ +package cy.agorise.bitsybitshareswallet.fragments + +import androidx.lifecycle.ViewModelProviders +import android.os.Bundle +import android.preference.PreferenceManager +import android.util.Log +import android.view.* +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentPagerAdapter +import androidx.lifecycle.Observer +import androidx.navigation.Navigation +import androidx.navigation.fragment.findNavController + +import cy.agorise.bitsybitshareswallet.R +import cy.agorise.bitsybitshareswallet.database.entities.UserAccount +import cy.agorise.bitsybitshareswallet.utils.Constants +import cy.agorise.bitsybitshareswallet.viewmodels.UserAccountViewModel +import kotlinx.android.synthetic.main.fragment_home.* + +class HomeFragment : Fragment() { + + private lateinit var mUserAccountViewModel: UserAccountViewModel + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + setHasOptionsMenu(true) + + val toolbar: Toolbar? = activity?.findViewById(R.id.toolbar) + toolbar?.navigationIcon = resources.getDrawable(R.drawable.ic_bitsy_logo_2, null) + + return inflater.inflate(R.layout.fragment_home, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Configure UserAccountViewModel to show the current account + mUserAccountViewModel = ViewModelProviders.of(this).get(UserAccountViewModel::class.java) + + val userId = PreferenceManager.getDefaultSharedPreferences(context) + .getString(Constants.KEY_CURRENT_ACCOUNT_ID, "") + + mUserAccountViewModel.getUserAccount(userId!!).observe(this, Observer{ user -> + tvAccountName.text = user.name + }) + + // Navigate to the Receive Transaction Fragment + fabReceiveTransaction.setOnClickListener ( + Navigation.createNavigateOnClickListener(R.id.receive_action) + ) + + // Navigate to the Send Transaction Fragment without activating the camera + fabSendTransaction.setOnClickListener( + Navigation.createNavigateOnClickListener(R.id.send_action) + ) + + // Navigate to the Send Transaction Fragment using Navigation's SafeArgs to activate the camera + fabSendTransactionCamera.setOnClickListener { + val action = HomeFragmentDirections.sendActionCamera() + action.setOpenCamera(true) + findNavController().navigate(action) + } + + // Configure ViewPager with PagerAdapter and TabLayout to display the Balances/NetWorth section + val pagerAdapter = PagerAdapter(fragmentManager!!) + viewPager.adapter = pagerAdapter + tabLayout.setupWithViewPager(viewPager) + // Set the pie chart icon for the third tab + tabLayout.getTabAt(2)?.setIcon(R.drawable.ic_pie_chart) + } + + /** + * Pager adapter to create the placeholder fragments + */ + private inner class PagerAdapter internal constructor(fm: FragmentManager) : FragmentPagerAdapter(fm) { + + override fun getItem(position: Int): Fragment { + // getItem is called to instantiate the fragment for the given page. + return if (position == 0) + BalancesFragment() + else + NetWorthFragment() + } + + override fun getPageTitle(position: Int): CharSequence? { + return listOf(getString(R.string.title_balances), getString(R.string.title_net_worth), "")[position] + } + + override fun getCount(): Int { + return 3 + } + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.menu_home, menu) + } +} diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/MerchantsFragment.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/MerchantsFragment.kt index 4714120..635f421 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/MerchantsFragment.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/MerchantsFragment.kt @@ -1,22 +1,42 @@ package cy.agorise.bitsybitshareswallet.fragments -import androidx.lifecycle.ViewModelProviders import android.os.Bundle +import android.util.Log import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.OnMapReadyCallback +import com.google.android.gms.maps.SupportMapFragment +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.MarkerOptions +import com.google.gson.GsonBuilder import cy.agorise.bitsybitshareswallet.R -import cy.agorise.bitsybitshareswallet.viewmodels.MerchantsViewModel +import cy.agorise.bitsybitshareswallet.models.Merchant +import cy.agorise.bitsybitshareswallet.network.AmbassadorService +import cy.agorise.bitsybitshareswallet.network.FeathersResponse +import retrofit2.Call +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.io.IOException +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import com.google.android.gms.maps.model.BitmapDescriptor +import cy.agorise.bitsybitshareswallet.utils.Constants -class MerchantsFragment : Fragment() { - companion object { - fun newInstance() = MerchantsFragment() - } +class MerchantsFragment : Fragment(), OnMapReadyCallback, retrofit2.Callback> { - private lateinit var viewModel: MerchantsViewModel + private lateinit var mMap: GoogleMap + + private var merchants: List? = null + + private lateinit var merchantIcon: BitmapDescriptor override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -25,10 +45,73 @@ class MerchantsFragment : Fragment() { return inflater.inflate(R.layout.fragment_merchants, container, false) } - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) - viewModel = ViewModelProviders.of(this).get(MerchantsViewModel::class.java) - // TODO: Use the ViewModel + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Obtain the SupportMapFragment and get notified when the map is ready to be used. + val mapFragment = childFragmentManager.findFragmentById(R.id.map) as SupportMapFragment + mapFragment.getMapAsync(this) + + merchantIcon = getMarkerIconFromDrawable(resources.getDrawable(R.drawable.ic_pin_merchants, null)) + + // TODO https://github.com/Agorise/bitsy-wallet/blob/feat_merchants/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/MapFragment.kt } + /** + * Manipulates the map once available. + * This callback is triggered when the map is ready to be used. + * This is where we can add markers or lines, add listeners or move the camera. In this case, + * we just add a marker near Sydney, Australia. + * If Google Play services is not installed on the device, the user will be prompted to install + * it inside the SupportMapFragment. This method will only be triggered once the user has + * installed Google Play services and returned to the app. + */ + override fun onMapReady(googleMap: GoogleMap) { + mMap = googleMap + + val gson = GsonBuilder() + .setLenient() + .create() + val retrofit = Retrofit.Builder() + .baseUrl(Constants.MERCHANTS_WEBSERVICE_URL) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + + val ambassadorService = retrofit.create(AmbassadorService::class.java) + val call = ambassadorService.allMerchants + call.enqueue(this) + } + + override fun onResponse(call: Call>, response: Response>) { + if (response.isSuccessful) { + val res: FeathersResponse? = response.body() + merchants = res!!.data + for (mer in merchants!!) { + val location = LatLng(mer.lat.toDouble(), mer.lon.toDouble()) + mMap.addMarker( + MarkerOptions().position(location).title(mer.name).snippet(mer.address).icon(merchantIcon) + ) + } + } else { + try { + Log.e("error_bitsy", response.errorBody()?.string()) + } catch (e: IOException) { + e.printStackTrace() + } + + } + } + + override fun onFailure(call: Call>, t: Throwable) { + + } + + private fun getMarkerIconFromDrawable(drawable: Drawable): BitmapDescriptor { + val canvas = Canvas() + val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888) + canvas.setBitmap(bitmap) + drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) + drawable.draw(canvas) + return BitmapDescriptorFactory.fromBitmap(bitmap) + } } diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/NetWorthFragment.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/NetWorthFragment.kt new file mode 100644 index 0000000..114d84a --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/NetWorthFragment.kt @@ -0,0 +1,28 @@ +package cy.agorise.bitsybitshareswallet.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import cy.agorise.bitsybitshareswallet.R + +class NetWorthFragment: Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + setHasOptionsMenu(true) + + return inflater.inflate(R.layout.fragment_net_worth, container, false) + } + + override fun setUserVisibleHint(isVisibleToUser: Boolean) { + super.setUserVisibleHint(isVisibleToUser) + if (isVisibleToUser) { + // TODO find a better way to recreate the fragment, that does it only when the theme has been changed + fragmentManager!!.beginTransaction().detach(this).attach(this).commit() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/ReceiveTransactionFragment.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/ReceiveTransactionFragment.kt new file mode 100644 index 0000000..9b81fdb --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/ReceiveTransactionFragment.kt @@ -0,0 +1,444 @@ +package cy.agorise.bitsybitshareswallet.fragments + +import android.Manifest +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Color +import android.os.Bundle +import android.os.IBinder +import android.preference.PreferenceManager +import android.util.Log +import android.view.* +import android.widget.AdapterView +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import com.google.common.primitives.UnsignedLong +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.MultiFormatWriter +import com.google.zxing.WriterException +import com.google.zxing.common.BitMatrix +import com.jakewharton.rxbinding3.widget.textChanges +import cy.agorise.bitsybitshareswallet.R +import cy.agorise.bitsybitshareswallet.adapters.AssetsAdapter +import cy.agorise.bitsybitshareswallet.adapters.AutoSuggestAssetAdapter +import cy.agorise.bitsybitshareswallet.utils.Constants +import cy.agorise.bitsybitshareswallet.utils.Helper +import cy.agorise.bitsybitshareswallet.viewmodels.AssetViewModel +import cy.agorise.bitsybitshareswallet.viewmodels.UserAccountViewModel +import cy.agorise.graphenej.* +import cy.agorise.graphenej.api.ConnectionStatusUpdate +import cy.agorise.graphenej.api.android.NetworkService +import cy.agorise.graphenej.api.android.RxBus +import cy.agorise.graphenej.api.calls.ListAssets +import cy.agorise.graphenej.models.JsonRpcResponse +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import kotlinx.android.synthetic.main.fragment_receive_transaction.* +import java.lang.Exception +import java.math.RoundingMode +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.* +import java.util.concurrent.TimeUnit +import kotlin.collections.ArrayList + +class ReceiveTransactionFragment : Fragment(), ServiceConnection { + private val TAG = this.javaClass.simpleName + + private val RESPONSE_LIST_ASSETS = 1 + private val REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION = 100 + + /** Number of assets to request from the NetworkService to show as suggestions in the AutoCompleteTextView */ + private val AUTO_SUGGEST_ASSET_LIMIT = 5 + + private val OTHER_ASSET = "other_asset" + + private lateinit var mUserAccountViewModel: UserAccountViewModel + private lateinit var mAssetViewModel: AssetViewModel + + /** Current user account */ + private var mUserAccount: UserAccount? = null + + private var mDisposables = CompositeDisposable() + + private var mAsset: Asset? = null + + private var mAssetsAdapter: AssetsAdapter? = null + + private lateinit var mAutoSuggestAssetAdapter: AutoSuggestAssetAdapter + + private var mAssets = ArrayList() + + private var selectedAssetSymbol = "" + + /** Used to avoid erasing the QR code when the user selects an item from the AutoComplete suggestions */ + private var selectedInAutoCompleteTextView = false + + // Map used to keep track of request and response id pairs + private val responseMap = HashMap() + + /* Network service connection */ + private var mNetworkService: NetworkService? = null + + /** Flag used to keep track of the NetworkService binding state */ + private var mShouldUnbindNetwork: Boolean = false + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + setHasOptionsMenu(true) + + return inflater.inflate(R.layout.fragment_receive_transaction, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Configure UserAccountViewModel to show the current account + mUserAccountViewModel = ViewModelProviders.of(this).get(UserAccountViewModel::class.java) + + val userId = PreferenceManager.getDefaultSharedPreferences(context) + .getString(Constants.KEY_CURRENT_ACCOUNT_ID, "") + + mUserAccountViewModel.getUserAccount(userId!!).observe(this, + Observer{ user -> + mUserAccount = UserAccount(user.id, user.name) + }) + + // Configure Assets spinner to show Assets already saved into the db + mAssetViewModel = ViewModelProviders.of(this).get(AssetViewModel::class.java) + + mAssetViewModel.getAll().observe(this, + Observer> { assets -> + mAssets.clear() + mAssets.addAll(assets) + + // Add an option at the end so the user can search for an asset other than the ones saved in the db + val asset = cy.agorise.bitsybitshareswallet.database.entities.Asset( + OTHER_ASSET, "Other...", 0, "", "" + ) + mAssets.add(asset) + + mAssetsAdapter = AssetsAdapter(context!!, android.R.layout.simple_spinner_item, mAssets) + spAsset.adapter = mAssetsAdapter + + // Try to select the selectedAssetSymbol + for (i in 0 until mAssetsAdapter!!.count) { + if (mAssetsAdapter!!.getItem(i)!!.symbol == selectedAssetSymbol) { + spAsset.setSelection(i) + break + } + } + }) + + spAsset.onItemSelectedListener = object : AdapterView.OnItemSelectedListener{ + override fun onNothingSelected(parent: AdapterView<*>?) { } + + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + val asset = mAssetsAdapter!!.getItem(position)!! + + if (asset.id == OTHER_ASSET) { + tilAsset.visibility = View.VISIBLE + mAsset = null + } else { + tilAsset.visibility = View.GONE + selectedAssetSymbol = asset.symbol + + mAsset = Asset(asset.id, asset.symbol, asset.precision) + } + updateQR() + } + } + + // Use RxJava Debounce to create QR code only after the user stopped typing an amount + mDisposables.add( + tietAmount.textChanges() + .debounce(1000, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { updateQR() } + ) + + // Add adapter to the Assets AutoCompleteTextView + mAutoSuggestAssetAdapter = AutoSuggestAssetAdapter(context!!, android.R.layout.simple_dropdown_item_1line) + actvAsset.setAdapter(mAutoSuggestAssetAdapter) + + // Use RxJava Debounce to avoid making calls to the NetworkService on every text change event and also avoid + // the first call when the View is created + mDisposables.add( + actvAsset.textChanges() + .skipInitialValue() + .debounce(500, TimeUnit.MILLISECONDS) + .map { it.toString().trim().toUpperCase() } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + if (!selectedInAutoCompleteTextView) { + mAsset = null + updateQR() + } + selectedInAutoCompleteTextView = false + + // Get a list of assets that match the already typed string by the user + if (it.length > 1 && mNetworkService != null) { + val id = mNetworkService!!.sendMessage(ListAssets(it, AUTO_SUGGEST_ASSET_LIMIT), + ListAssets.REQUIRED_API) + responseMap[id] = RESPONSE_LIST_ASSETS + } + } + ) + + actvAsset.setOnItemClickListener { parent, _, position, _ -> + val asset = parent.adapter.getItem(position) as cy.agorise.bitsybitshareswallet.database.entities.Asset + mAsset = Asset(asset.id, asset.symbol, asset.precision) + selectedInAutoCompleteTextView = true + updateQR() + } + + // Connect to the RxBus, which receives events from the NetworkService + mDisposables.add( + RxBus.getBusInstance() + .asFlowable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { handleIncomingMessage(it) } + ) + } + + private fun handleIncomingMessage(message: Any?) { + if (message is JsonRpcResponse<*>) { + if (responseMap.containsKey(message.id)) { + val responseType = responseMap[message.id] + when (responseType) { + RESPONSE_LIST_ASSETS -> handleListAssets(message.result as List) + } + responseMap.remove(message.id) + } + } else if (message is ConnectionStatusUpdate) { + if (message.updateCode == ConnectionStatusUpdate.DISCONNECTED) { + // If we got a disconnection notification, we should clear our response map, since + // all its stored request ids will now be reset + responseMap.clear() + } + } + } + + private fun handleListAssets(assetList: List) { + Log.d(TAG, "handleListAssets") + val assets = ArrayList() + for (_asset in assetList) { + val asset = cy.agorise.bitsybitshareswallet.database.entities.Asset( + _asset.objectId, + _asset.symbol, + _asset.precision, + _asset.description ?: "", + _asset.bitassetId ?: "" + ) + + assets.add(asset) + } + mAutoSuggestAssetAdapter.setData(assets) + mAutoSuggestAssetAdapter.notifyDataSetChanged() + } + + private fun updateQR() { + if (mAsset == null) { + ivQR.setImageDrawable(null) + // TODO clean the please pay and to text at the bottom too + return + } + + // Try to obtain the amount from the Amount Text Field or make it zero otherwise + val amount: Long = try { + val tmpAmount = tietAmount.text.toString().toDouble() + (tmpAmount * Math.pow(10.0, mAsset!!.precision.toDouble())).toLong() + }catch (e: Exception) { + 0 + } + + val total = AssetAmount(UnsignedLong.valueOf(amount), mAsset!!) + val totalInDouble = Util.fromBase(total) + val items = arrayOf(LineItem("transfer", 1, totalInDouble)) + val invoice = Invoice(mUserAccount!!.name, "", "#bitsy", mAsset!!.symbol, items, "", "") + Log.d(TAG, "invoice: " + invoice.toJsonString()) + try { + val bitmap = encodeAsBitmap(Invoice.toQrCode(invoice), "#139657") // PalmPay green + ivQR.setImageBitmap(bitmap) + updateAmountAddressUI(total, mUserAccount!!.name) + } catch (e: WriterException) { + Log.e(TAG, "WriterException. Msg: " + e.message) + } + } + + /** + * Encodes the provided data as a QR-code. Used to provide payment requests. + * @param data: Data containing payment request data as the recipient's address and the requested amount. + * @param color: The color used for the QR-code + * @return Bitmap with the QR-code encoded data + * @throws WriterException if QR Code cannot be generated + */ + @Throws(WriterException::class) + internal fun encodeAsBitmap(data: String, color: String): Bitmap? { + val result: BitMatrix + + // Get measured width and height of the ImageView where the QR code will be placed + var w = ivQR.width + var h = ivQR.height + + // Gets minimum side length and sets both width and height to that value so the final + // QR code has a squared shape + val minSide = if (w < h) w else h + h = minSide + w = h + + try { + val hints = HashMap() + hints[EncodeHintType.MARGIN] = 0 + result = MultiFormatWriter().encode( + data, + BarcodeFormat.QR_CODE, w, h, hints + ) + } catch (iae: IllegalArgumentException) { + // Unsupported format + return null + } + + val pixels = IntArray(w * h) + for (y in 0 until h) { + val offset = y * w + for (x in 0 until w) { + pixels[offset + x] = if (result.get(x, y)) Color.parseColor(color) else Color.WHITE + } + } + val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + bitmap.setPixels(pixels, 0, w, 0, 0, w, h) + return bitmap + } + + /** + * Updates the UI to show amount and account to send the payment + * + * @param total Total Amount in crypto to be paid + * @param account Account to pay total + */ + private fun updateAmountAddressUI(total: AssetAmount, account: String) { + val df = DecimalFormat("####."+("#".repeat(total.asset.precision))) + df.roundingMode = RoundingMode.CEILING + df.decimalFormatSymbols = DecimalFormatSymbols(Locale.getDefault()) + + val amount = total.amount.toDouble() / Math.pow(10.toDouble(), total.asset.precision.toDouble()) + val strAmount = df.format(amount) + + val txtAmount = getString(R.string.template__please_pay, strAmount, total.asset.symbol) + val txtAccount = getString(R.string.template__to, account) + + tvPleasePay.text = txtAmount + tvTo.text = txtAccount + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.menu_receive_transaction, menu) + } + + override fun onOptionsItemSelected(item: MenuItem?): Boolean { + if (item?.itemId == R.id.menu_share) { + verifyStoragePermission() + return true + } + return super.onOptionsItemSelected(item) + } + + private fun verifyStoragePermission() { + if (ContextCompat.checkSelfPermission(activity!!, Manifest.permission.WRITE_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + // Permission is not already granted + requestPermissions(arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE), + REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION) + } else { + // Permission is already granted + shareQRScreenshot() + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + if (requestCode == REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION) { + if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) { + shareQRScreenshot() + } else { + // TODO extract string resource + Toast.makeText(context!!, "Storage permission is necessary to share QR codes.", Toast.LENGTH_SHORT).show() + } + return + } + } + + /** + * This function takes a screenshot as a bitmap, saves it into a temporal cache image and then + * sends an intent so the user can select the desired method to share the image. + */ + private fun shareQRScreenshot() { + // TODO improve, show errors where necessary so the user can fix it + // Avoid sharing the QR code image if the fields are not filled correctly + if (mAsset == null) + return + + // Get Screenshot + val screenshot = Helper.loadBitmapFromView(container) + val imageUri = Helper.saveTemporalBitmap(context!!, screenshot) + + // Prepare information for share intent + val subject = getString(R.string.msg__invoice_subject, mUserAccount?.name) + val content = tvPleasePay.text.toString() + "\n" + + tvTo.text.toString() + + // Create share intent and call it + val shareIntent = Intent() + shareIntent.action = Intent.ACTION_SEND + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) // temp permission for receiving app to read this file + shareIntent.putExtra(Intent.EXTRA_STREAM, imageUri) + shareIntent.putExtra(Intent.EXTRA_SUBJECT, subject) + shareIntent.putExtra(Intent.EXTRA_TEXT, content) + shareIntent.type = "*/*" + startActivity(Intent.createChooser(shareIntent, getString(R.string.text__share_with))) + } + + override fun onResume() { + super.onResume() + + val intent = Intent(context, NetworkService::class.java) + if (context?.bindService(intent, this, Context.BIND_AUTO_CREATE) == true) { + mShouldUnbindNetwork = true + } else { + Log.e(TAG, "Binding to the network service failed.") + } + } + + override fun onPause() { + super.onPause() + + // Unbinding from network service + if (mShouldUnbindNetwork) { + context?.unbindService(this) + mShouldUnbindNetwork = false + } + } + + override fun onDestroy() { + super.onDestroy() + + if (!mDisposables.isDisposed) mDisposables.dispose() + } + + override fun onServiceDisconnected(name: ComponentName?) { } + + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + // We've bound to LocalService, cast the IBinder and get LocalService instance + val binder = service as NetworkService.LocalBinder + mNetworkService = binder.service + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/SendTransactionFragment.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/SendTransactionFragment.kt new file mode 100644 index 0000000..80db5b1 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/SendTransactionFragment.kt @@ -0,0 +1,476 @@ +package cy.agorise.bitsybitshareswallet.fragments + +import android.Manifest +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.os.Bundle +import android.os.IBinder +import android.preference.PreferenceManager +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import com.google.common.primitives.UnsignedLong +import com.google.zxing.BarcodeFormat +import com.google.zxing.Result +import com.jakewharton.rxbinding3.widget.textChanges +import cy.agorise.bitsybitshareswallet.R +import cy.agorise.bitsybitshareswallet.adapters.BalancesDetailsAdapter +import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetail +import cy.agorise.bitsybitshareswallet.repositories.AuthorityRepository +import cy.agorise.bitsybitshareswallet.utils.Constants +import cy.agorise.bitsybitshareswallet.utils.CryptoUtils +import cy.agorise.bitsybitshareswallet.viewmodels.BalanceDetailViewModel +import cy.agorise.graphenej.* +import cy.agorise.graphenej.api.ConnectionStatusUpdate +import cy.agorise.graphenej.api.android.NetworkService +import cy.agorise.graphenej.api.android.RxBus +import cy.agorise.graphenej.api.calls.BroadcastTransaction +import cy.agorise.graphenej.api.calls.GetAccountByName +import cy.agorise.graphenej.api.calls.GetDynamicGlobalProperties +import cy.agorise.graphenej.api.calls.GetRequiredFees +import cy.agorise.graphenej.models.AccountProperties +import cy.agorise.graphenej.models.DynamicGlobalProperties +import cy.agorise.graphenej.models.JsonRpcResponse +import cy.agorise.graphenej.operations.TransferOperationBuilder +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.schedulers.Schedulers +import kotlinx.android.synthetic.main.fragment_send_transaction.* +import me.dm7.barcodescanner.zxing.ZXingScannerView +import org.bitcoinj.core.DumpedPrivateKey +import org.bitcoinj.core.ECKey +import java.math.RoundingMode +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.ArrayList +import java.util.Locale +import java.util.concurrent.TimeUnit +import javax.crypto.AEADBadTagException + +class SendTransactionFragment : Fragment(), ZXingScannerView.ResultHandler, ServiceConnection { + private val TAG = this.javaClass.simpleName + + // Camera Permission + private val REQUEST_CAMERA_PERMISSION = 1 + + private val RESPONSE_GET_ACCOUNT_BY_NAME = 1 + private val RESPONSE_GET_DYNAMIC_GLOBAL_PARAMETERS = 2 + private val RESPONSE_GET_REQUIRED_FEES = 3 + private val RESPONSE_BROADCAST_TRANSACTION = 4 + + private var isCameraPreviewVisible = false + private var isToAccountCorrect = false + private var isAmountCorrect = false + + private var mBalancesDetails: List? = null + + private lateinit var mBalanceDetailViewModel: BalanceDetailViewModel + + private var mBalancesDetailsAdapter: BalancesDetailsAdapter? = null + + private var selectedAssetSymbol = "" + + /** Current user account */ + private var mUserAccount: UserAccount? = null + + /** User account to which send the funds */ + private var mSelectedUserAccount: UserAccount? = null + + private var mDisposables = CompositeDisposable() + + /* Network service connection */ + private var mNetworkService: NetworkService? = null + + /** Flag used to keep track of the NetworkService binding state */ + private var mShouldUnbindNetwork: Boolean = false + + // Map used to keep track of request and response id pairs + private val responseMap = HashMap() + + private var transaction: Transaction? = null + + /** Variable holding the current user's private key in the WIF format */ + private var wifKey: String? = null + + /** Repository to access and update Authorities */ + private var authorityRepository: AuthorityRepository? = null + + /* This is one of the of the recipient account's public key, it will be used for memo encoding */ + private var destinationPublicKey: PublicKey? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_send_transaction, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val userId = PreferenceManager.getDefaultSharedPreferences(context) + .getString(Constants.KEY_CURRENT_ACCOUNT_ID, "") + + if (userId != "") + mUserAccount = UserAccount(userId) + + // Use Navigation SafeArgs to decide if we should activate or not the camera feed + val safeArgs = SendTransactionFragmentArgs.fromBundle(arguments) + if (safeArgs.openCamera) + verifyCameraPermission() + + fabOpenCamera.setOnClickListener { if (isCameraPreviewVisible) stopCameraPreview() else verifyCameraPermission() } + + // Configure BalanceDetailViewModel to show the current balances + mBalanceDetailViewModel = ViewModelProviders.of(this).get(BalanceDetailViewModel::class.java) + + mBalanceDetailViewModel.getAll().observe(this, Observer> { balancesDetails -> + mBalancesDetails = balancesDetails + mBalancesDetailsAdapter = BalancesDetailsAdapter(context!!, android.R.layout.simple_spinner_item, mBalancesDetails!!) + spAsset.adapter = mBalancesDetailsAdapter + + // Try to select the selectedAssetSymbol + for (i in 0 until mBalancesDetailsAdapter!!.count) { + if (mBalancesDetailsAdapter!!.getItem(i)!!.symbol == selectedAssetSymbol) { + spAsset.setSelection(i) + break + } + } + }) + + spAsset.onItemSelectedListener = object : AdapterView.OnItemSelectedListener{ + override fun onNothingSelected(parent: AdapterView<*>?) { } + + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + val balance = mBalancesDetailsAdapter!!.getItem(position)!! + selectedAssetSymbol = balance.symbol + + val amount = balance.amount.toDouble() / Math.pow(10.0, balance.precision.toDouble()) + + tvAvailableAssetAmount.text = + String.format("%." + Math.min(balance.precision, 8) + "f %s", amount, balance.symbol) + } + } + + fabSendTransaction.setOnClickListener { startSendTransferOperation() } + fabSendTransaction.hide() + + authorityRepository = AuthorityRepository(context!!) + + mDisposables.add( + authorityRepository!!.getWIF(userId!!, AuthorityType.ACTIVE.ordinal) + .subscribeOn(Schedulers.computation()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { encryptedWIF -> + try { + wifKey = CryptoUtils.decrypt(context!!, encryptedWIF) + } catch (e: AEADBadTagException) { + Log.e(TAG, "AEADBadTagException. Class: " + e.javaClass + ", Msg: " + e.message) + } + + } + ) + + // Use RxJava Debounce to avoid making calls to the NetworkService on every text change event + mDisposables.add( + tietTo.textChanges() + .debounce(500, TimeUnit.MILLISECONDS) + .map { it.toString().trim() } + .filter { it.length > 1 } + .subscribe { + val id = mNetworkService!!.sendMessage(GetAccountByName(it!!), GetAccountByName.REQUIRED_API) + responseMap[id] = RESPONSE_GET_ACCOUNT_BY_NAME + } + ) + + // Use RxJava Debounce to update the Amount error only after the user stops writing for > 500 ms + mDisposables.add( + tietAmount.textChanges() + .debounce(500, TimeUnit.MILLISECONDS) + .filter { it.isNotEmpty() } + .map { it.toString().trim().toDouble() } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { validateAmount(it!!) } + ) + + // Connect to the RxBus, which receives events from the NetworkService + mDisposables.add( + RxBus.getBusInstance() + .asFlowable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { handleIncomingMessage(it) } + ) + } + + private fun handleIncomingMessage(message: Any?) { + if (message is JsonRpcResponse<*>) { + if (responseMap.containsKey(message.id)) { + val responseType = responseMap[message.id] + when (responseType) { + RESPONSE_GET_ACCOUNT_BY_NAME -> handleAccountName(message.result) + RESPONSE_GET_DYNAMIC_GLOBAL_PARAMETERS -> handleDynamicGlobalProperties(message.result) + RESPONSE_GET_REQUIRED_FEES -> handleRequiredFees(message.result) + RESPONSE_BROADCAST_TRANSACTION -> handleBroadcastTransaction(message) + } + responseMap.remove(message.id) + } + } else if (message is ConnectionStatusUpdate) { + if (message.updateCode == ConnectionStatusUpdate.DISCONNECTED) { + // If we got a disconnection notification, we should clear our response map, since + // all its stored request ids will now be reset + responseMap.clear() + } + } + } + + private fun handleAccountName(result: Any?) { + if (result is AccountProperties) { + mSelectedUserAccount = UserAccount(result.id, result.name) + destinationPublicKey = result.active.keyAuths.keys.iterator().next() + tilTo.isErrorEnabled = false + isToAccountCorrect = true + } else { + mSelectedUserAccount = null + destinationPublicKey = null + tilTo.error = "Invalid account" + isToAccountCorrect = false + } + + enableDisableSendFAB() + } + + private fun handleDynamicGlobalProperties(result: Any?) { + if (result is DynamicGlobalProperties) { + val expirationTime = (result.time.time / 1000) + Transaction.DEFAULT_EXPIRATION_TIME + val headBlockId = result.head_block_id + val headBlockNumber = result.head_block_number + + transaction!!.blockData = BlockData(headBlockNumber, headBlockId, expirationTime) + + val asset = Asset(mBalancesDetailsAdapter!!.getItem(spAsset.selectedItemPosition)!!.id) + + val id = mNetworkService!!.sendMessage(GetRequiredFees(transaction!!, asset), GetRequiredFees.REQUIRED_API) + responseMap[id] = RESPONSE_GET_REQUIRED_FEES + } else { + // TODO unableToSendTransactionError() + } + } + + private fun handleRequiredFees(result: Any?) { + if (result is List<*> && result[0] is AssetAmount) { + Log.d(TAG, "GetRequiredFees: " + transaction.toString()) + transaction!!.setFees(result as List) // TODO find how to remove this warning + + val id = mNetworkService!!.sendMessage(BroadcastTransaction(transaction), BroadcastTransaction.REQUIRED_API) + responseMap[id] = RESPONSE_BROADCAST_TRANSACTION + } else { + // TODO unableToSendTransactionError() + } + } + + private fun handleBroadcastTransaction(message: JsonRpcResponse<*>) { + if (message.result == null && message.error == null) { + // TODO extract string resources + Toast.makeText(context!!, "Transaction sent!", Toast.LENGTH_SHORT).show() + + // Remove information from the text fields and disable send button + tietTo.setText("") + tietAmount.setText("") + tietMemo.setText("") + isToAccountCorrect = false + isAmountCorrect = false + enableDisableSendFAB() + } else { + // TODO extract error messages to show a better explanation to the user + Toast.makeText(context!!, message.error.message, Toast.LENGTH_LONG).show() + } + } + + private fun verifyCameraPermission() { + if (ContextCompat.checkSelfPermission(activity!!, Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED) { + // Permission is not already granted + requestPermissions(arrayOf(android.Manifest.permission.CAMERA), REQUEST_CAMERA_PERMISSION) + } else { + // Permission is already granted + startCameraPreview() + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + if (requestCode == REQUEST_CAMERA_PERMISSION) { + if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) { + startCameraPreview() + } else { + // TODO extract string resource + Toast.makeText(context!!, "Camera permission is necessary to read QR codes.", Toast.LENGTH_SHORT).show() + } + return + } + } + + private fun startCameraPreview() { + cameraPreview.visibility = View.VISIBLE + fabOpenCamera.setImageResource(R.drawable.ic_close) + isCameraPreviewVisible = true + + // Configure QR scanner + cameraPreview.setFormats(listOf(BarcodeFormat.QR_CODE)) + cameraPreview.setAspectTolerance(0.5f) + cameraPreview.setAutoFocus(true) + cameraPreview.setLaserColor(R.color.colorAccent) + cameraPreview.setMaskColor(R.color.colorAccent) + cameraPreview.setResultHandler(this) + cameraPreview.startCamera() + } + + private fun stopCameraPreview() { + cameraPreview.visibility = View.INVISIBLE + fabOpenCamera.setImageResource(R.drawable.ic_camera) + isCameraPreviewVisible = false + cameraPreview.stopCamera() + } + + override fun handleResult(result: Result?) { + try { + val invoice = Invoice.fromQrCode(result!!.text) + + Log.d(TAG, "QR Code read: " + invoice.toJsonString()) + + tietTo.setText(invoice.to) + + for (i in 0 until mBalancesDetailsAdapter!!.count) { + if (mBalancesDetailsAdapter!!.getItem(i)!!.symbol == invoice.currency.toUpperCase()) { + spAsset.setSelection(i) + break + } + } + tietMemo.setText(invoice.memo) + + + var amount = 0.0 + for (nextItem in invoice.lineItems) { + amount += nextItem.quantity * nextItem.price + } + // TODO Improve pattern to account for different asset precisions + val df = DecimalFormat("####.#####") + df.roundingMode = RoundingMode.CEILING + df.decimalFormatSymbols = DecimalFormatSymbols(Locale.getDefault()) + tietAmount.setText(df.format(amount)) + + }catch (e: Exception) { + Log.d(TAG, "Invoice error: " + e.message) + } + } + + private fun validateAmount(amount: Double) { + if (mBalancesDetailsAdapter?.isEmpty != false) return + val balance = mBalancesDetailsAdapter?.getItem(spAsset.selectedItemPosition) ?: return + val currentAmount = balance.amount.toDouble() / Math.pow(10.0, balance.precision.toDouble()) + + if (currentAmount < amount) { + // TODO extract string resource + tilAmount.error = "Not enough funds" + isAmountCorrect = false + } else { + tilAmount.isErrorEnabled = false + isAmountCorrect = true + } + + enableDisableSendFAB() + } + + private fun enableDisableSendFAB() { + if (isToAccountCorrect && isAmountCorrect) + fabSendTransaction.show() + else + fabSendTransaction.hide() + } + + private fun startSendTransferOperation() { + // Create TransferOperation + if (mNetworkService!!.isConnected) { + val balance = mBalancesDetailsAdapter!!.getItem(spAsset.selectedItemPosition)!! + val amount = (tietAmount.text.toString().toDouble() * Math.pow(10.0, balance.precision.toDouble())).toLong() + + val transferAmount = AssetAmount(UnsignedLong.valueOf(amount), Asset(balance.id)) + + val operationBuilder = TransferOperationBuilder() + .setSource(mUserAccount) + .setDestination(mSelectedUserAccount) + .setTransferAmount(transferAmount) + + val privateKey = ECKey.fromPrivate(DumpedPrivateKey.fromBase58(null, wifKey).key.privKeyBytes) + + // Add memo if exists TODO enable memo +// val memoMsg = tietMemo.text.toString() +// if (memoMsg.isNotEmpty()) { +// val nonce = SecureRandomGenerator.getSecureRandom().nextLong().toBigInteger() +// val encryptedMemo = Memo.encryptMessage(privateKey, destinationPublicKey!!, nonce, memoMsg) +// val from = Address(ECKey.fromPublicOnly(privateKey.pubKey)) +// val to = Address(destinationPublicKey!!.key) +// val memo = Memo(from, to, nonce, encryptedMemo) +// operationBuilder.setMemo(memo) +// } + + val operations = ArrayList() + operations.add(operationBuilder.build()) + + transaction = Transaction(privateKey, null, operations) + + val id = mNetworkService!!.sendMessage(GetDynamicGlobalProperties(), + GetDynamicGlobalProperties.REQUIRED_API) + responseMap[id] = RESPONSE_GET_DYNAMIC_GLOBAL_PARAMETERS + } else + Log.d(TAG, "Network Service is not connected") + } + + override fun onResume() { + super.onResume() + if (isCameraPreviewVisible) + startCameraPreview() + + val intent = Intent(context, NetworkService::class.java) + if (context?.bindService(intent, this, Context.BIND_AUTO_CREATE) == true) { + mShouldUnbindNetwork = true + } else { + Log.e(TAG, "Binding to the network service failed.") + } + } + + override fun onPause() { + super.onPause() + + // Unbinding from network service + if (mShouldUnbindNetwork) { + context?.unbindService(this) + mShouldUnbindNetwork = false + } + + if (!isCameraPreviewVisible) + stopCameraPreview() + } + + override fun onDestroy() { + super.onDestroy() + + if (!mDisposables.isDisposed) mDisposables.dispose() + } + + override fun onServiceDisconnected(name: ComponentName?) { } + + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + // We've bound to LocalService, cast the IBinder and get LocalService instance + val binder = service as NetworkService.LocalBinder + mNetworkService = binder.service + } +} diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/SettingsFragment.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/SettingsFragment.kt new file mode 100644 index 0000000..5cd77c5 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/SettingsFragment.kt @@ -0,0 +1,282 @@ +package cy.agorise.bitsybitshareswallet.fragments + +import android.content.* +import android.content.Context.CLIPBOARD_SERVICE +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.preference.PreferenceManager +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.Fragment +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView +import com.afollestad.materialdialogs.list.customListAdapter +import cy.agorise.bitsybitshareswallet.BuildConfig +import cy.agorise.bitsybitshareswallet.R +import cy.agorise.bitsybitshareswallet.adapters.FullNodesAdapter +import cy.agorise.bitsybitshareswallet.repositories.AuthorityRepository +import cy.agorise.bitsybitshareswallet.utils.Constants +import cy.agorise.bitsybitshareswallet.utils.CryptoUtils +import cy.agorise.graphenej.BrainKey +import cy.agorise.graphenej.api.android.NetworkService +import cy.agorise.graphenej.api.android.RxBus +import cy.agorise.graphenej.api.calls.GetDynamicGlobalProperties +import cy.agorise.graphenej.models.DynamicGlobalProperties +import cy.agorise.graphenej.models.JsonRpcResponse +import cy.agorise.graphenej.network.FullNode +import io.reactivex.Observer +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import kotlinx.android.synthetic.main.fragment_settings.* +import java.text.NumberFormat + +class SettingsFragment : Fragment(), ServiceConnection { + private val TAG = this.javaClass.simpleName + + private var mDisposables = CompositeDisposable() + + /* Network service connection */ + private var mNetworkService: NetworkService? = null + + /** Flag used to keep track of the NetworkService binding state */ + private var mShouldUnbindNetwork: Boolean = false + + // Dialog displaying the list of nodes and their latencies + private var mNodesDialog: MaterialDialog? = null + + /** Adapter that holds the FullNode list used in the Bitshares nodes modal */ + private var nodesAdapter: FullNodesAdapter? = null + + private val mHandler = Handler() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + setHasOptionsMenu(true) + + return inflater.inflate(R.layout.fragment_settings, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initAutoCloseSwitch() + + initNightModeSwitch() + + btnViewBrainKey.setOnClickListener { getBrainkey(it) } + + tvNetworkStatus.setOnClickListener { v -> + if (mNetworkService != null) { + // PublishSubject used to announce full node latencies updates + val fullNodePublishSubject = mNetworkService!!.nodeLatencyObservable + fullNodePublishSubject?.observeOn(AndroidSchedulers.mainThread())?.subscribe(nodeLatencyObserver) + + val fullNodes = mNetworkService!!.nodes + + nodesAdapter = FullNodesAdapter(v.context) + nodesAdapter!!.add(fullNodes) + + mNodesDialog = MaterialDialog(v.context) + .title(text = String.format("%s v%s", getString(R.string.app_name), BuildConfig.VERSION_NAME)) + .message(text = getString(R.string.title__bitshares_nodes_dialog, "-------")) + .customListAdapter(nodesAdapter as FullNodesAdapter) + .negativeButton(android.R.string.ok) { + mHandler.removeCallbacks(mRequestDynamicGlobalPropertiesTask) + } + + mNodesDialog?.show() + + // Registering a recurrent task used to poll for dynamic global properties requests + mHandler.post(mRequestDynamicGlobalPropertiesTask) + } + } + + // Connect to the RxBus, which receives events from the NetworkService + mDisposables.add( + RxBus.getBusInstance() + .asFlowable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { handleIncomingMessage(it) } + ) + } + + /** + * Observer used to be notified about node latency measurement updates. + */ + private val nodeLatencyObserver = object : Observer { + override fun onSubscribe(d: Disposable) { + mDisposables.add(d) + } + + override fun onNext(fullNode: FullNode) { + if (!fullNode.isRemoved) + nodesAdapter?.add(fullNode) + else + nodesAdapter?.remove(fullNode) + } + + override fun onError(e: Throwable) { + Log.e(TAG, "nodeLatencyObserver.onError.Msg: " + e.message) + } + + override fun onComplete() {} + } + + private fun handleIncomingMessage(message: Any?) { + if (message is JsonRpcResponse<*>) { + if (message.result is DynamicGlobalProperties) { + val dynamicGlobalProperties = message.result as DynamicGlobalProperties + if (mNodesDialog != null && mNodesDialog?.isShowing == true) { + val blockNumber = NumberFormat.getInstance().format(dynamicGlobalProperties.head_block_number) + mNodesDialog?.message(text = getString(R.string.title__bitshares_nodes_dialog, blockNumber)) + } + } + } + } + + /** + * Task used to obtain frequent updates on the global dynamic properties object + */ + private val mRequestDynamicGlobalPropertiesTask = object : Runnable { + override fun run() { + if (mNetworkService != null) { + if (mNetworkService?.isConnected == true) { + mNetworkService?.sendMessage(GetDynamicGlobalProperties(), GetDynamicGlobalProperties.REQUIRED_API) + } else { + Log.d(TAG, "NetworkService exists but is not connected") + } + } else { + Log.d(TAG, "NetworkService reference is null") + } + mHandler.postDelayed(this, Constants.BLOCK_PERIOD) + } + } + + /** + * Fetches the relevant preference from the SharedPreferences and configures the corresponding switch accordingly, + * and adds a listener to the said switch to store the preference in case the user changes it. + */ + private fun initAutoCloseSwitch() { + val autoCloseOn = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(Constants.KEY_AUTO_CLOSE_ACTIVATED, true) + + switchAutoClose.isChecked = autoCloseOn + + switchAutoClose.setOnCheckedChangeListener { buttonView, isChecked -> + PreferenceManager.getDefaultSharedPreferences(buttonView.context).edit() + .putBoolean(Constants.KEY_AUTO_CLOSE_ACTIVATED, isChecked).apply() + } + } + + /** + * Fetches the relevant preference from the SharedPreferences and configures the corresponding switch accordingly, + * and adds a listener to the said switch to store the preference in case the user changes it. Also makes a call to + * recreate the activity and apply the selected theme. + */ + private fun initNightModeSwitch() { + val nightModeOn = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(Constants.KEY_NIGHT_MODE_ACTIVATED, false) + + switchNightMode.isChecked = nightModeOn + + switchNightMode.setOnCheckedChangeListener { buttonView, isChecked -> + + PreferenceManager.getDefaultSharedPreferences(buttonView.context).edit() + .putBoolean(Constants.KEY_NIGHT_MODE_ACTIVATED, isChecked).apply() + + // Recreates the activity to apply the selected theme + activity?.recreate() + } + } + + /** + * Obtains the brainKey from the authorities db table for the current user account and if it is not null it passes + * the brainKey to a method to show it in a nice MaterialDialog + */ + private fun getBrainkey(view: View) { + val userId = PreferenceManager.getDefaultSharedPreferences(view.context) + .getString(Constants.KEY_CURRENT_ACCOUNT_ID, "") ?: "" + + val authorityRepository = AuthorityRepository(view.context) + + mDisposables.add(authorityRepository.get(userId) + .subscribeOn(Schedulers.computation()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { authority -> + if (authority != null) { + val plainBrainKey = CryptoUtils.decrypt(view.context, authority.encryptedBrainKey) + val plainSequenceNumber = CryptoUtils.decrypt(view.context, authority.encryptedSequenceNumber) + val sequenceNumber = Integer.parseInt(plainSequenceNumber) + val brainKey = BrainKey(plainBrainKey, sequenceNumber) + showBrainKeyDialog(view, brainKey) + } + } + ) + } + + /** + * Shows the plain brainkey in a dialog so that the user can view and Copy it. + */ + private fun showBrainKeyDialog(view: View, brainKey: BrainKey) { + MaterialDialog(view.context).show { + title(text = "BrainKey") + message(text = brainKey.brainKey) + customView(R.layout.dialog_copy_brainkey) + cancelable(false) + positiveButton(android.R.string.copy) { + Toast.makeText(it.context, "Copied to clipboard", Toast.LENGTH_SHORT).show() + val clipboard = it.context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("label", brainKey.brainKey) + clipboard.primaryClip = clip + it.dismiss() + } + } + } + + override fun onResume() { + super.onResume() + + val intent = Intent(context, NetworkService::class.java) + if (context?.bindService(intent, this, Context.BIND_AUTO_CREATE) == true) { + mShouldUnbindNetwork = true + } else { + Log.e(TAG, "Binding to the network service failed.") + } + } + + override fun onPause() { + super.onPause() + + // Unbinding from network service + if (mShouldUnbindNetwork) { + context?.unbindService(this) + mShouldUnbindNetwork = false + } + } + + override fun onDestroy() { + super.onDestroy() + + if (!mDisposables.isDisposed) mDisposables.dispose() + } + + override fun onServiceDisconnected(name: ComponentName?) { + tvNetworkStatus.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, + resources.getDrawable(R.drawable.ic_disconnected, null), null) + } + + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + // We've bound to LocalService, cast the IBinder and get LocalService instance + val binder = service as NetworkService.LocalBinder + mNetworkService = binder.service + + tvNetworkStatus.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, + resources.getDrawable(R.drawable.ic_connected, null), null) + } +} + diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/TransactionsFragment.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/TransactionsFragment.kt index 68f9083..817f4ec 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/TransactionsFragment.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/TransactionsFragment.kt @@ -1,34 +1,216 @@ package cy.agorise.bitsybitshareswallet.fragments -import androidx.lifecycle.ViewModelProviders +import android.graphics.Point import android.os.Bundle +import android.preference.PreferenceManager +import android.view.* +import androidx.appcompat.widget.SearchView import androidx.fragment.app.Fragment -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup - +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.LinearLayoutManager +import com.jakewharton.rxbinding3.appcompat.queryTextChangeEvents import cy.agorise.bitsybitshareswallet.R -import cy.agorise.bitsybitshareswallet.viewmodels.TransactionsViewModel +import cy.agorise.bitsybitshareswallet.adapters.TransfersDetailsAdapter +import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail +import cy.agorise.bitsybitshareswallet.utils.BounceTouchListener +import cy.agorise.bitsybitshareswallet.utils.Constants +import cy.agorise.bitsybitshareswallet.viewmodels.TransferDetailViewModel +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import kotlinx.android.synthetic.main.fragment_transactions.* +import java.util.* +import java.util.concurrent.TimeUnit +import kotlin.collections.ArrayList -class TransactionsFragment : Fragment() { +class TransactionsFragment : Fragment(), FilterOptionsDialog.OnFilterOptionsSelectedListener { - companion object { - fun newInstance() = TransactionsFragment() - } + private lateinit var mTransferDetailViewModel: TransferDetailViewModel - private lateinit var viewModel: TransactionsViewModel + private lateinit var transfersDetailsAdapter: TransfersDetailsAdapter + + private val transfersDetails = ArrayList() + private val filteredTransfersDetails = ArrayList() + + /** Variables used to filter the transaction items */ + private var filterQuery = "" + private var filterTransactionsDirection = 0 + private var filterDateRangeAll = true + private var filterStartDate = 0L + private var filterEndDate = 0L + private var filterCryptocurrencyAll = true + private var filterCryptocurrency = "BTS" + private var filterFiatAmountAll = true + private var filterFromFiatAmount = 0L + private var filterToFiatAmount = 500L + + private var mDisposables = CompositeDisposable() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + setHasOptionsMenu(true) - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { return inflater.inflate(R.layout.fragment_transactions, container, false) } - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) - viewModel = ViewModelProviders.of(this).get(TransactionsViewModel::class.java) - // TODO: Use the ViewModel + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val userId = PreferenceManager.getDefaultSharedPreferences(context) + .getString(Constants.KEY_CURRENT_ACCOUNT_ID, "") ?: "" + + transfersDetailsAdapter = TransfersDetailsAdapter(context!!) + rvTransactions.adapter = transfersDetailsAdapter + rvTransactions.layoutManager = LinearLayoutManager(context) + + // Configure TransferDetailViewModel to fetch the transaction history + mTransferDetailViewModel = ViewModelProviders.of(this).get(TransferDetailViewModel::class.java) + + mTransferDetailViewModel.getAll(userId).observe(this, Observer> { transfersDetails -> + this.transfersDetails.clear() + this.transfersDetails.addAll(transfersDetails) + applyFilterOptions(false) + }) + + // Set custom touch listener to handle bounce/stretch effect + val bounceTouchListener = BounceTouchListener(rvTransactions) + rvTransactions.setOnTouchListener(bounceTouchListener) + + // Initialize filter options + val calendar = Calendar.getInstance() + filterEndDate = calendar.timeInMillis / 1000 + calendar.add(Calendar.MONTH, -2) + filterStartDate = calendar.timeInMillis / 1000 } + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.menu_transactions, menu) + + // Adds listener for the SearchView + val searchItem = menu.findItem(R.id.menu_search) + val searchView = searchItem.actionView as SearchView + mDisposables.add( + searchView.queryTextChangeEvents() + .skipInitialValue() + .debounce(500, TimeUnit.MILLISECONDS) + .map { it.queryText.toString().toLowerCase() } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + filterQuery = it + applyFilterOptions() + } + ) + + // Adjust SearchView width to avoid pushing other menu items out of the screen + searchView.maxWidth = getScreenWidth(activity) * 3 / 5 + } + + override fun onOptionsItemSelected(item: MenuItem?): Boolean { + return when (item?.itemId) { + R.id.menu_filter -> { + val filterOptionsDialog = FilterOptionsDialog.newInstance( + filterTransactionsDirection, filterDateRangeAll, filterStartDate * 1000, + filterEndDate * 1000, filterCryptocurrencyAll, filterCryptocurrency, + filterFiatAmountAll, filterFromFiatAmount, filterToFiatAmount + ) + filterOptionsDialog.show(childFragmentManager, "filter-options-tag") + true + } + R.id.menu_export -> { + // TODO add export options + true + } + else -> super.onOptionsItemSelected(item) + } + } + + /** + * Returns the screen width in pixels for the given [FragmentActivity] + */ + private fun getScreenWidth(activity: FragmentActivity?): Int { + if (activity == null) + return 200 + + val size = Point() + activity.windowManager.defaultDisplay.getSize(size) + return size.x + } + + /** + * Filters the TransferDetail list given the user selected filter options. + * TODO move this to a background thread + */ + private fun applyFilterOptions(scrollToTop: Boolean = true) { + // Clean the filtered list + filteredTransfersDetails.clear() + + for (transferDetail in transfersDetails) { + // Filter by transfer direction + if (transferDetail.direction) { // Transfer sent + if (filterTransactionsDirection == 1) + // Looking for received transfers only + continue + } else { // Transfer received + if (filterTransactionsDirection == 2) + // Looking for sent transactions only + continue + } + + // Filter by date range + if (!filterDateRangeAll && (transferDetail.date < filterStartDate || transferDetail.date > filterEndDate)) + continue + + // Filter by cryptocurrency + if (!filterCryptocurrencyAll && transferDetail.cryptoSymbol != filterCryptocurrency) + continue + +// // Filter by fiat amount +// if (!filterFiatAmountAll && (transferDetail.fiatAmount < filterFromFiatAmount || transferDetail.fiatAmount > filterToFiatAmount)) +// continue + + // Filter by search query + val text = (transferDetail.from ?: "").toLowerCase() + (transferDetail.to ?: "").toLowerCase() + if (text.contains(filterQuery, ignoreCase = true)) { + filteredTransfersDetails.add(transferDetail) + } + } + + // Replaces the list of TransferDetail items with the new filtered list + transfersDetailsAdapter.replaceAll(filteredTransfersDetails) + + if (scrollToTop) + rvTransactions.scrollToPosition(0) + } + + /** + * + */ + override fun onFilterOptionsSelected( + filterTransactionsDirection: Int, + filterDateRangeAll: Boolean, + filterStartDate: Long, + filterEndDate: Long, + filterCryptocurrencyAll: Boolean, + filterCryptocurrency: String, + filterFiatAmountAll: Boolean, + filterFromFiatAmount: Long, + filterToFiatAmount: Long + ) { + this.filterTransactionsDirection = filterTransactionsDirection + this.filterDateRangeAll = filterDateRangeAll + this.filterStartDate = filterStartDate / 1000 + this.filterEndDate = filterEndDate / 1000 + this.filterCryptocurrencyAll = filterCryptocurrencyAll + this.filterCryptocurrency = filterCryptocurrency + this.filterFiatAmountAll = filterFiatAmountAll + this.filterFromFiatAmount = filterFromFiatAmount + this.filterToFiatAmount = filterToFiatAmount + applyFilterOptions(true) + } + + override fun onDestroy() { + super.onDestroy() + + if (!mDisposables.isDisposed) mDisposables.dispose() + } } diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/models/Ambassador.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/models/Ambassador.kt new file mode 100644 index 0000000..f02947d --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/models/Ambassador.kt @@ -0,0 +1,5 @@ +package cy.agorise.bitsybitshareswallet.models + +class Ambassador(var id: String?, var account: String?, var country: String?, var city: String?) { + var cityId: String? = null +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/models/AmbassadorLocation.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/models/AmbassadorLocation.kt new file mode 100644 index 0000000..8e44024 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/models/AmbassadorLocation.kt @@ -0,0 +1,29 @@ +package cy.agorise.bitsybitshareswallet.models + +import androidx.annotation.NonNull + +class AmbassadorLocation( + var id: String?, + var name: String?, + var country: String? +) : Comparable { + + override fun toString(): String { + return this.name!! + } + + override fun equals(other: Any?): Boolean { + return other is AmbassadorLocation && id == other.id + } + + override fun compareTo(@NonNull other: AmbassadorLocation): Int { + return name!!.compareTo(other.name!!) + } + + override fun hashCode(): Int { + var result = id?.hashCode() ?: 0 + result = 31 * result + (name?.hashCode() ?: 0) + result = 31 * result + (country?.hashCode() ?: 0) + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/models/HistoricalOperationEntry.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/models/HistoricalOperationEntry.kt new file mode 100644 index 0000000..b601521 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/models/HistoricalOperationEntry.kt @@ -0,0 +1,68 @@ +package cy.agorise.bitsybitshareswallet.models + +import cy.agorise.graphenej.AssetAmount +import cy.agorise.graphenej.models.OperationHistory +import cy.agorise.graphenej.operations.TransferOperation + +/** + * This class is very similar to the OperationHistory, but while the later is used to deserialize + * the transfer information exactly as it comes from the 'get_relative_account_history' API call, + * this class is used to represent a single entry in the local database. + * + * + * Every entry in the transfers table needs a bit more information than what is provided by the + * HistoricalTransfer. We need to know the specific timestamp of a transaction for instance, instead + * of just a block number. + * + * + * There's also the data used for the equivalent fiat value. + * + * + * Created by nelson on 12/18/16. + */ +class HistoricalOperationEntry { + var historicalTransfer: OperationHistory? = null + var timestamp: Long = 0 + var equivalentValue: AssetAmount? = null + + override fun toString(): String { + if (historicalTransfer != null) { + // Since for now we know that all stored historical operations are 'transfers' + val op = historicalTransfer!!.operation as TransferOperation + var memo = "?" + if (op.memo != null && op.memo.plaintextMessage != null) { + memo = op.memo.plaintextMessage + } + return String.format( + "<%d, %s -> %s, %d of %s, memo: %s>", + timestamp, + op.from.objectId, + op.to.objectId, + op.assetAmount.amount.toLong(), + op.assetAmount.asset.objectId, + memo + ) + } else { + return "<>" + } + } + + override fun hashCode(): Int { + return historicalTransfer!!.objectId.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other == null) { + return false + } + return if (javaClass != other.javaClass) { + false + } else { + val otherEntry = other as HistoricalOperationEntry? + otherEntry!!.historicalTransfer!!.objectId == this.historicalTransfer!!.objectId + } + } +} diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/models/Merchant.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/models/Merchant.kt new file mode 100644 index 0000000..1059a7b --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/models/Merchant.kt @@ -0,0 +1,37 @@ +package cy.agorise.bitsybitshareswallet.models + +import java.util.* + +class Merchant { + + var id: String? = null + var address: String? = null + var phone: String? = null + var name: String? = null + var lat: Float = 0F + var lon: Float = 0F + var city: String? = null + var country: String? = null + var createdAt: Date? = null + var updatedAt: Date? = null + var __v: Int = 0 + + constructor() {} + constructor(id: String) { + this.id = id + } + + constructor(_id: String, address: String, phone: String, name: String, lat: Float, lon: Float, city: String, country: String, createdAt: Date, updatedAt: Date, __v: Int) { + this.id = _id + this.address = address + this.phone = phone + this.name = name + this.lat = lat + this.lon = lon + this.city = city + this.country = country + this.createdAt = createdAt + this.updatedAt = updatedAt + this.__v = __v + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/network/AmbassadorService.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/network/AmbassadorService.kt new file mode 100644 index 0000000..e714da2 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/network/AmbassadorService.kt @@ -0,0 +1,21 @@ +package cy.agorise.bitsybitshareswallet.network + +import cy.agorise.bitsybitshareswallet.models.Ambassador +import cy.agorise.bitsybitshareswallet.models.AmbassadorLocation +import cy.agorise.bitsybitshareswallet.models.Merchant +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.Query + +interface AmbassadorService { + + //https://ambpay.palmpay.io/api/v1/merchants?%24sort%5Baccount%5D=1&%24limit=50&%24skip=0 + @get:GET("/api/v1/merchants?%24sort%5Baccount%5D=1&%24limit=50&%24skip=0") + val allMerchants: Call> + + @GET("/api/v1/ambassadors") + fun getAmbassadors(@Query("cityId") cityId: String): Call> + + @GET("/api/v1/cities") + fun getAllCitiesSync(@Query("\$skip") skip: Long): Call> +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/network/FeathersResponse.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/network/FeathersResponse.kt new file mode 100644 index 0000000..70ddbc4 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/network/FeathersResponse.kt @@ -0,0 +1,19 @@ +package cy.agorise.bitsybitshareswallet.network + +class FeathersResponse(private val error: Throwable?) { + var total: Long = 0 + var limit: Long = 0 + var skip: Long = 0 + var data: List? = null + + val isSuccessful: Boolean + get() = error == null && data != null + + fun message(): String { + return if (error != null) { + error.message!! + } else { + "" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/processors/TransfersLoader.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/processors/TransfersLoader.kt new file mode 100644 index 0000000..b099c54 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/processors/TransfersLoader.kt @@ -0,0 +1,330 @@ +package cy.agorise.bitsybitshareswallet.processors + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.preference.PreferenceManager +import android.util.Log +import cy.agorise.bitsybitshareswallet.database.entities.Transfer +import cy.agorise.bitsybitshareswallet.models.HistoricalOperationEntry +import cy.agorise.bitsybitshareswallet.repositories.AuthorityRepository +import cy.agorise.bitsybitshareswallet.repositories.TransferRepository +import cy.agorise.bitsybitshareswallet.utils.Constants +import cy.agorise.bitsybitshareswallet.utils.CryptoUtils +import cy.agorise.graphenej.* +import cy.agorise.graphenej.api.ConnectionStatusUpdate +import cy.agorise.graphenej.api.android.NetworkService +import cy.agorise.graphenej.api.android.RxBus +import cy.agorise.graphenej.api.calls.GetRelativeAccountHistory +import cy.agorise.graphenej.errors.ChecksumException +import cy.agorise.graphenej.models.JsonRpcResponse +import cy.agorise.graphenej.models.OperationHistory +import cy.agorise.graphenej.operations.TransferOperation +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.functions.Consumer +import io.reactivex.schedulers.Schedulers +import org.bitcoinj.core.DumpedPrivateKey +import org.bitcoinj.core.ECKey +import java.util.* +import javax.crypto.AEADBadTagException + +/** + * This class is responsible for loading the local database with all past transfer operations of the + * currently selected account. + * + * The procedure used to load the database in 3 steps: + * + * 1- Load all transfer operations + * 2- Load all missing times + * 3- Load all missing equivalent times + * + * Since the 'get_relative_account_history' will not provide either timestamps nor equivalent values + * for every transfer, we must first load all historical transfer operations, and then proceed to + * handle those missing columns. + */ +class TransfersLoader(private var mContext: Context?): ServiceConnection { + + private val TAG = this.javaClass.simpleName + + /** Constant that specifies if we are on debug mode */ + private val DEBUG = false + + /* Constant used to fix the number of historical transfers to fetch from the network in one batch */ + private val HISTORICAL_TRANSFER_BATCH_SIZE = 100 + + private val RESPONSE_GET_RELATIVE_ACCOUNT_HISTORY = 0 + + private var mDisposables = CompositeDisposable() + + /* Current user account */ + private var mCurrentAccount: UserAccount? = null + + /** Variable holding the current user's private key in the WIF format */ + private var wifKey: String? = null + + /** Repository to access and update Transfers */ + private var transferRepository: TransferRepository? = null + + /** Repository to access and update Authorities */ + private var authorityRepository: AuthorityRepository? = null + + /* Network service connection */ + private var mNetworkService: NetworkService? = null + + /* Counter used to keep track of the transfer history batch count */ + private var historicalTransferCount = 0 + + // Used to keep track of the current state TODO this may not be needed + private var mState = State.IDLE + + /** + * Flag used to keep track of the NetworkService binding state + */ + private var mShouldUnbindNetwork: Boolean = false + + private var lastId: Long = 0 + + // Map used to keep track of request and response id pairs + private val responseMap = HashMap() + + /** + * Enum class used to keep track of the current state of the loader + */ + private enum class State { + IDLE, + LOADING_MISSING_TIMES, + LOADING_EQ_VALUES, + CANCELLED, + FINISHED + } + + init { + transferRepository = TransferRepository(mContext!!) + authorityRepository = AuthorityRepository(mContext!!) + + val pref = PreferenceManager.getDefaultSharedPreferences(mContext) + val userId = pref.getString(Constants.KEY_CURRENT_ACCOUNT_ID, "") + if (userId != "") { + mCurrentAccount = UserAccount(userId) + mDisposables.add(authorityRepository!!.getWIF(userId!!, AuthorityType.MEMO.ordinal) + .subscribeOn(Schedulers.computation()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { encryptedWIF -> + try { + wifKey = CryptoUtils.decrypt(mContext!!, encryptedWIF) + } catch (e: AEADBadTagException) { + Log.e(TAG, "AEADBadTagException. Class: " + e.javaClass + ", Msg: " + e.message) + } + + } + ) + mDisposables.add(RxBus.getBusInstance() + .asFlowable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer { message -> + if (mState == State.FINISHED) return@Consumer + if (message is JsonRpcResponse<*>) { + if (message.result is List<*>) { + if (responseMap.containsKey(message.id)) { + val responseType = responseMap[message.id] + when (responseType) { + RESPONSE_GET_RELATIVE_ACCOUNT_HISTORY -> handleOperationList(message.result as List) + } + responseMap.remove(message.id) + } + } + } else if (message is ConnectionStatusUpdate) { + if (message.updateCode == ConnectionStatusUpdate.DISCONNECTED) { + // If we got a disconnection notification, we should clear our response map, since + // all its stored request ids will now be reset + responseMap.clear() + } + } + }) + ) + } else { + // If there is no current user, we should not do anything + mState = State.CANCELLED + } + + onStart() + } + + override fun onServiceDisconnected(name: ComponentName?) { + mShouldUnbindNetwork = false + } + + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + // We've bound to LocalService, cast the IBinder and get LocalService instance + val binder = service as NetworkService.LocalBinder + mNetworkService = binder.service + + // Start the transfers update + startTransfersUpdateProcedure() + } + + private fun onStart() { + if (mState != State.CANCELLED) { + val intent = Intent(mContext, NetworkService::class.java) + if (mContext!!.bindService(intent, this, Context.BIND_AUTO_CREATE)) { + mShouldUnbindNetwork = true + } else { + Log.e(TAG, "Binding to the network service failed.") + } + } + } + + /** + * Starts the procedure that will try to update the 'transfers' table + */ + private fun startTransfersUpdateProcedure() { + if (DEBUG) { + // If we are in debug mode, we first erase all entries in the 'transfer' table + transferRepository!!.deleteAll() + } + mDisposables.add( + transferRepository!!.getCount() + .subscribeOn(Schedulers.computation()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { transferCount -> + if (transferCount > 0) { + // If we already have some transfers in the database, we might want to skip the request + // straight to the last batch + historicalTransferCount = Math.floor((transferCount / + HISTORICAL_TRANSFER_BATCH_SIZE).toDouble()).toInt() + } + // Retrieving account transactions + loadNextOperationsBatch() + } + ) + } + + /** + * Handles a freshly obtained list of OperationHistory instances. This is how the full node + * answers our 'get_relative_account_history' API call. + * + * This response however, has to be processed before being stored in the local database. + * + * @param operationHistoryList List of OperationHistory instances + */ + private fun handleOperationList(operationHistoryList: List) { + historicalTransferCount++ + + val insertedCount = transferRepository!!.insertAll(processOperationList(operationHistoryList)) + // TODO return number of inserted rows +// Log.d(TAG, String.format("Inserted count: %d, list size: %d", insertedCount, operationHistoryList.size)) + if (/* insertedCount == 0 && */ operationHistoryList.isEmpty()) { + onDestroy() + } else { + + // If we inserted more than one operation, we cannot yet be sure we've reached the + // end of the operation list, so we issue another call to the 'get_relative_account_history' + // API call + loadNextOperationsBatch() + } + } + + /** + * Method used to issue a new 'get_relative_account_history' API call. This is expected to retrieve + * at most HISTORICAL_TRANSFER_BATCH_SIZE operations. + */ + private fun loadNextOperationsBatch() { + val stop = historicalTransferCount * HISTORICAL_TRANSFER_BATCH_SIZE + val start = stop + HISTORICAL_TRANSFER_BATCH_SIZE + lastId = mNetworkService!!.sendMessage( + GetRelativeAccountHistory( + mCurrentAccount, + stop, + HISTORICAL_TRANSFER_BATCH_SIZE, + start + ), GetRelativeAccountHistory.REQUIRED_API + ) + responseMap[lastId] = RESPONSE_GET_RELATIVE_ACCOUNT_HISTORY + } + + /** + * Method that will transform a list of OperationHistory instances to a list of + * HistoricalOperationEntry. + * + * The HistoricalOperationEntry class is basically a wrapper around the OperationHistory class + * provided by the Graphenej library. It is used to better reflect what we store in the internal + * database for every transfer and expands the OperationHistory class basically adding + * two things: + * + * 1- A timestamp + * 2- An AssetAmount instance to represent the equivalent value in a fiat value + * + * @param operations List of OperationHistory instances + * @return List of HistoricalOperationEntry instances + */ + private fun processOperationList(operations: List): List { + val transfers = ArrayList() + + if (wifKey == null) { + // In case of key storage corruption, we give up on processing this list of operations + return transfers + } + val memoKey = DumpedPrivateKey.fromBase58(null, wifKey!!).key + val publicKey = PublicKey(ECKey.fromPublicOnly(memoKey.pubKey)) + val myAddress = Address(publicKey.key) + + + for (historicalOp in operations) { + if (historicalOp.operation == null || historicalOp.operation !is TransferOperation) { + // Some historical operations might not be transfer operations. + // As of right now non-transfer operations get deserialized as null + continue + } + + val entry = HistoricalOperationEntry() + val op = historicalOp.operation as TransferOperation + + val memo = op.memo + if (memo.byteMessage != null) { + val destinationAddress = memo.destination + try { + if (destinationAddress.toString() == myAddress.toString()) { + val decryptedMessage = Memo.decryptMessage(memoKey, memo.source, memo.nonce, memo.byteMessage) + memo.plaintextMessage = decryptedMessage + } + } catch (e: ChecksumException) { + Log.e(TAG, "ChecksumException. Msg: " + e.message) + } catch (e: NullPointerException) { + // This is expected in case the decryption fails, so no need to log this event. + Log.e(TAG, "NullPointerException. Msg: " + e.message) + } catch (e: Exception) { + Log.e(TAG, "Exception while decoding memo. Msg: " + e.message) + } + } + + val transfer = Transfer( + historicalOp.objectId, + historicalOp.blockNum, + entry.timestamp, + op.fee.amount.toLong(), + op.fee.asset.objectId, + op.from.objectId, + op.to.objectId, + op.assetAmount.amount.toLong(), + op.assetAmount.asset.objectId, + memo.plaintextMessage + ) + + transfers.add(transfer) + } + return transfers + } + + private fun onDestroy() { + Log.d(TAG, "Destroying TransfersLoader") + if (!mDisposables.isDisposed) mDisposables.dispose() + if (mShouldUnbindNetwork) { + mContext!!.unbindService(this) + mShouldUnbindNetwork = false + } + mContext = null + } +} diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/AssetRepository.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/AssetRepository.kt new file mode 100644 index 0000000..60aa72d --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/AssetRepository.kt @@ -0,0 +1,35 @@ +package cy.agorise.bitsybitshareswallet.repositories + +import android.content.Context +import android.os.AsyncTask +import androidx.lifecycle.LiveData +import cy.agorise.bitsybitshareswallet.database.daos.AssetDao +import cy.agorise.bitsybitshareswallet.database.BitsyDatabase +import cy.agorise.bitsybitshareswallet.database.entities.Asset + +class AssetRepository internal constructor(context: Context) { + + private val mAssetDao: AssetDao + + init { + val db = BitsyDatabase.getDatabase(context) + mAssetDao = db!!.assetDao() + } + + fun getAll(): LiveData> { + return mAssetDao.getAll() + } + + fun insertAll(assets: List) { + insertAllAsyncTask(mAssetDao).execute(assets) + } + + private class insertAllAsyncTask internal constructor(private val mAsyncTaskDao: AssetDao) : + AsyncTask, Void, Void>() { + + override fun doInBackground(vararg assets: List): Void? { + mAsyncTaskDao.insertAll(assets[0]) + return null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/AuthorityRepository.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/AuthorityRepository.kt new file mode 100644 index 0000000..e241170 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/AuthorityRepository.kt @@ -0,0 +1,39 @@ +package cy.agorise.bitsybitshareswallet.repositories + +import android.content.Context +import android.os.AsyncTask +import cy.agorise.bitsybitshareswallet.database.daos.AuthorityDao +import cy.agorise.bitsybitshareswallet.database.BitsyDatabase +import cy.agorise.bitsybitshareswallet.database.entities.Authority +import io.reactivex.Single + +class AuthorityRepository internal constructor(context: Context) { + + private val mAuthorityDao: AuthorityDao + + init { + val db = BitsyDatabase.getDatabase(context) + mAuthorityDao = db!!.authorityDao() + } + + fun insert(authority: Authority) { + insertAsyncTask(mAuthorityDao).execute(authority) + } + + fun get(userId: String): Single { + return mAuthorityDao.get(userId) + } + + fun getWIF(userId: String, authorityType: Int): Single { + return mAuthorityDao.getWIF(userId, authorityType) + } + + private class insertAsyncTask internal constructor(private val mAsyncTaskDao: AuthorityDao) : + AsyncTask() { + + override fun doInBackground(vararg authorities: Authority): Void? { + mAsyncTaskDao.insert(authorities[0]) + return null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/BalanceDetailRepository.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/BalanceDetailRepository.kt new file mode 100644 index 0000000..edecb7d --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/BalanceDetailRepository.kt @@ -0,0 +1,21 @@ +package cy.agorise.bitsybitshareswallet.repositories + +import android.content.Context +import androidx.lifecycle.LiveData +import cy.agorise.bitsybitshareswallet.database.BitsyDatabase +import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetail +import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetailDao + +class BalanceDetailRepository internal constructor(context: Context) { + + private val mBalanceDetailDao: BalanceDetailDao + + init { + val db = BitsyDatabase.getDatabase(context) + mBalanceDetailDao = db!!.balanceDetailDao() + } + + fun getAll(): LiveData> { + return mBalanceDetailDao.getAll() + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/BalanceRepository.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/BalanceRepository.kt new file mode 100644 index 0000000..e0c0d7c --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/BalanceRepository.kt @@ -0,0 +1,39 @@ +package cy.agorise.bitsybitshareswallet.repositories + +import android.content.Context +import android.os.AsyncTask +import androidx.lifecycle.LiveData +import cy.agorise.bitsybitshareswallet.database.daos.BalanceDao +import cy.agorise.bitsybitshareswallet.database.BitsyDatabase +import cy.agorise.bitsybitshareswallet.database.entities.Balance + +class BalanceRepository internal constructor(context: Context) { + + private val mBalanceDao: BalanceDao + + init { + val db = BitsyDatabase.getDatabase(context) + mBalanceDao = db!!.balanceDao() + } + + fun insertAll(balances: List) { + insertAllAsyncTask(mBalanceDao).execute(balances) + } + + fun getAll(): LiveData> { + return mBalanceDao.getAll() + } + + fun getMissingAssetIds(): LiveData> { + return mBalanceDao.getMissingAssetIds() + } + + private class insertAllAsyncTask internal constructor(private val mAsyncTaskDao: BalanceDao) : + AsyncTask, Void, Void>() { + + override fun doInBackground(vararg transfers: List): Void? { + mAsyncTaskDao.insertAll(transfers[0]) + return null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/TransferDetailRepository.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/TransferDetailRepository.kt new file mode 100644 index 0000000..cd86011 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/TransferDetailRepository.kt @@ -0,0 +1,22 @@ +package cy.agorise.bitsybitshareswallet.repositories + +import android.content.Context +import androidx.lifecycle.LiveData +import cy.agorise.bitsybitshareswallet.database.BitsyDatabase +import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail +import cy.agorise.bitsybitshareswallet.database.joins.TransferDetailDao + +class TransferDetailRepository internal constructor(context: Context) { + + private val mTransferDetailDao: TransferDetailDao + + init { + val db = BitsyDatabase.getDatabase(context) + mTransferDetailDao = db!!.transferDetailDao() + } + + fun getAll(userId: String): LiveData> { + return mTransferDetailDao.getAll(userId) + } + +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/TransferRepository.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/TransferRepository.kt new file mode 100644 index 0000000..7f328d5 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/TransferRepository.kt @@ -0,0 +1,70 @@ +package cy.agorise.bitsybitshareswallet.repositories + +import android.content.Context +import android.os.AsyncTask +import androidx.lifecycle.LiveData +import cy.agorise.bitsybitshareswallet.database.BitsyDatabase +import cy.agorise.bitsybitshareswallet.database.daos.TransferDao +import cy.agorise.bitsybitshareswallet.database.entities.Transfer +import io.reactivex.Single + +class TransferRepository internal constructor(context: Context) { + + private val mTransferDao: TransferDao + + init { + val db = BitsyDatabase.getDatabase(context) + mTransferDao = db!!.transferDao() + } + + fun insertAll(transfers: List) { + insertAllAsyncTask(mTransferDao).execute(transfers) + } + + fun setBlockTime(blockNumber: Long, timestamp: Long) { + setBlockTimeAsyncTask(mTransferDao).execute(Pair(blockNumber, timestamp)) + } + + fun getAll(): LiveData> { + return mTransferDao.getAll() + } + + fun getCount(): Single { + return mTransferDao.getCount() + } + + fun getTransferBlockNumberWithMissingTime(): LiveData { + return mTransferDao.getTransferBlockNumberWithMissingTime() + } + + fun deleteAll() { + deleteAllAsyncTask(mTransferDao).execute() + } + + private class insertAllAsyncTask internal constructor(private val mAsyncTaskDao: TransferDao) : + AsyncTask, Void, Void>() { + + override fun doInBackground(vararg transfers: List): Void? { + mAsyncTaskDao.insertAll(transfers[0]) + return null + } + } + + private class setBlockTimeAsyncTask internal constructor(private val mAsyncTaskDao: TransferDao) : + AsyncTask, Void, Void>() { + + override fun doInBackground(vararg pair: Pair): Void? { + mAsyncTaskDao.setBlockTime(pair[0].first, pair[0].second) + return null + } + } + + private class deleteAllAsyncTask internal constructor(private val mAsyncTaskDao: TransferDao) : + AsyncTask() { + + override fun doInBackground(vararg params: Void?): Void? { + mAsyncTaskDao.deleteAll() + return null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/UserAccountRepository.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/UserAccountRepository.kt new file mode 100644 index 0000000..f6c1457 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/repositories/UserAccountRepository.kt @@ -0,0 +1,52 @@ +package cy.agorise.bitsybitshareswallet.repositories + +import android.content.Context +import android.os.AsyncTask +import androidx.lifecycle.LiveData +import cy.agorise.bitsybitshareswallet.database.BitsyDatabase +import cy.agorise.bitsybitshareswallet.database.daos.UserAccountDao +import cy.agorise.bitsybitshareswallet.database.entities.UserAccount + +class UserAccountRepository internal constructor(context: Context) { + + private val mUserAccountDao: UserAccountDao + + init { + val db = BitsyDatabase.getDatabase(context) + mUserAccountDao = db!!.userAccountDao() + } + + fun insert(userAccount: UserAccount) { + insertAsyncTask(mUserAccountDao).execute(userAccount) + } + + fun insertAll(userAccounts: List) { + insertAllAsyncTask(mUserAccountDao).execute(userAccounts) + } + + fun getUserAccount(id: String): LiveData { + return mUserAccountDao.getUserAccount(id) + } + + fun getMissingUserAccountIds(): LiveData> { + return mUserAccountDao.getMissingAccountIds() + } + + private class insertAsyncTask internal constructor(private val mAsyncTaskDao: UserAccountDao) : + AsyncTask() { + + override fun doInBackground(vararg userAccounts: UserAccount): Void? { + mAsyncTaskDao.insert(userAccounts[0]) + return null + } + } + + private class insertAllAsyncTask internal constructor(private val mAsyncTaskDao: UserAccountDao) : + AsyncTask, Void, Void>() { + + override fun doInBackground(vararg userAccounts: List): Void? { + mAsyncTaskDao.insertAll(userAccounts[0]) + return null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/BitsyApplication.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/BitsyApplication.kt new file mode 100644 index 0000000..81ab0c8 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/BitsyApplication.kt @@ -0,0 +1,52 @@ +package cy.agorise.bitsybitshareswallet.utils + +import android.app.Application +import cy.agorise.graphenej.api.ApiAccess +import cy.agorise.graphenej.api.android.NetworkServiceManager + +class BitsyApplication : Application() { + + private val BITSHARES_NODE_URLS = arrayOf( + // PP private nodes + "wss://nl.palmpay.io/ws", + + // Other public nodes + "wss://bitshares.nu/ws", // Stockholm, Sweden + "wss://bitshares.openledger.info/ws", // Openledger node + "wss://dallas.bitshares.apasia.tech/ws", // Dallas, USA + "wss://atlanta.bitshares.apasia.tech/ws", // Atlanta, USA + "wss://dex.rnglab.org", // Amsterdam, Netherlands + "wss://citadel.li/node" + ) + + override fun onCreate() { + super.onCreate() + + // Specifying some important information regarding the connection, such as the + // credentials and the requested API accesses + val requestedApis = ApiAccess.API_DATABASE or ApiAccess.API_HISTORY or ApiAccess.API_NETWORK_BROADCAST + val networkManager = NetworkServiceManager.Builder() + .setUserName("") + .setPassword("") + .setRequestedApis(requestedApis) + .setCustomNodeUrls(setupNodes()) + .setAutoConnect(true) + .setNodeLatencyVerification(true) + .build(this) + + /* + * Registering this class as a listener to all activity's callback cycle events, in order to + * better estimate when the user has left the app and it is safe to disconnect the websocket connection + */ + registerActivityLifecycleCallbacks(networkManager) + } + + private fun setupNodes(): String { + val stringBuilder = StringBuilder() + for (url in BITSHARES_NODE_URLS) { + stringBuilder.append(url).append(",") + } + stringBuilder.replace(stringBuilder.length - 1, stringBuilder.length, "") + return stringBuilder.toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/BounceTouchListener.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/BounceTouchListener.kt new file mode 100644 index 0000000..237e823 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/BounceTouchListener.kt @@ -0,0 +1,213 @@ +package cy.agorise.bitsybitshareswallet.utils + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator +import androidx.core.view.MotionEventCompat +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.StaggeredGridLayoutManager + +class BounceTouchListener(private val mRecyclerView: RecyclerView) : View.OnTouchListener { + + private var downCalled = false + private var mDownY: Float = 0.toFloat() + private var mSwipingDown: Boolean = false + private var mSwipingUp: Boolean = false + private val mInterpolator = DecelerateInterpolator(3f) + private var mActivePointerId = -99 + private var mLastTouchY = -99f + private var mMaxAbsTranslation = -99 + + init { + mRecyclerView.pivotY = 0F + } + + override fun onTouch(view: View, motionEvent: MotionEvent): Boolean { + val action = MotionEventCompat.getActionMasked(motionEvent) + + when (action) { + MotionEvent.ACTION_DOWN -> { + onDownMotionEvent(motionEvent) + view.onTouchEvent(motionEvent) + downCalled = true + if (this.mRecyclerView.translationY == 0f) { + return false + } + } + MotionEvent.ACTION_MOVE -> { + if (mActivePointerId == -99) { + onDownMotionEvent(motionEvent) + downCalled = true + } + val pointerIndex = MotionEventCompat.findPointerIndex(motionEvent, mActivePointerId) + val y = MotionEventCompat.getY(motionEvent, pointerIndex) + if (!hasHitTop() && !hasHitBottom() || !downCalled) { + if (!downCalled) { + downCalled = true + } + mDownY = y + view.onTouchEvent(motionEvent) + return false + } + val deltaY = y - mDownY + if (Math.abs(deltaY) > 0 && hasHitTop() && deltaY > 0) { + mSwipingDown = true + sendCancelEventToView(view, motionEvent) + } + if (Math.abs(deltaY) > 0 && hasHitBottom() && deltaY < 0) { + mSwipingUp = true + sendCancelEventToView(view, motionEvent) + } + if (mSwipingDown || mSwipingUp) { + if (deltaY <= 0 && mSwipingDown || deltaY >= 0 && mSwipingUp) { + mDownY = 0f + mSwipingDown = false + mSwipingUp = false + downCalled = false + val downEvent = MotionEvent.obtain(motionEvent) + downEvent.action = MotionEvent.ACTION_DOWN or + (MotionEventCompat.getActionIndex(motionEvent) shl MotionEventCompat.ACTION_POINTER_INDEX_SHIFT) + view.onTouchEvent(downEvent) + } + var translation = (deltaY / Math.abs(deltaY) * Math.pow(Math.abs(deltaY).toDouble(), .8)).toInt() + if (mMaxAbsTranslation > 0) { + if (translation < 0) { + translation = Math.max(-mMaxAbsTranslation, translation) + } else { + translation = Math.min(mMaxAbsTranslation, translation) + } + } + mRecyclerView.translationY = translation.toFloat() + translate(mRecyclerView.translationY) + return true + } + } + + MotionEvent.ACTION_UP -> { + mActivePointerId = -99 + // cancel + mRecyclerView.animate() + .setInterpolator(mInterpolator) + .translationY(0f) + .setDuration(600L) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + (animation as ValueAnimator).addUpdateListener { + translate(mRecyclerView.translationY) + } + super.onAnimationStart(animation) + } + }) + + mDownY = 0f + mSwipingDown = false + mSwipingUp = false + downCalled = false + } + + MotionEvent.ACTION_CANCEL -> { + mActivePointerId = -99 + } + + MotionEvent.ACTION_POINTER_UP -> { + val pointerIndex = MotionEventCompat.getActionIndex(motionEvent) + val pointerId = MotionEventCompat.getPointerId(motionEvent, pointerIndex) + + if (pointerId == mActivePointerId) { + val newPointerIndex = if (pointerIndex == 0) 1 else 0 + mLastTouchY = MotionEventCompat.getY(motionEvent, newPointerIndex) + mActivePointerId = MotionEventCompat.getPointerId(motionEvent, newPointerIndex) + + if (this.mRecyclerView.translationY > 0) { + mDownY = mLastTouchY - Math.pow(this.mRecyclerView.translationY.toDouble(), (10f / 8f).toDouble()).toInt() + this.mRecyclerView.animate().cancel() + } else if (this.mRecyclerView.translationY < 0) { + mDownY = mLastTouchY + + Math.pow((-this.mRecyclerView.translationY).toDouble(), (10f / 8f).toDouble()).toInt() + this.mRecyclerView.animate().cancel() + } + } + } + } + return false + } + + private fun translate(translation: Float) { + val scale = 2 * translation / mRecyclerView.measuredHeight + 1 + mRecyclerView.scaleY = Math.pow(scale.toDouble(), .6).toFloat() + } + + private fun sendCancelEventToView(view: View, motionEvent: MotionEvent) { + (view as ViewGroup).requestDisallowInterceptTouchEvent(true) + val cancelEvent = MotionEvent.obtain(motionEvent) + cancelEvent.action = MotionEvent.ACTION_CANCEL or + (MotionEventCompat.getActionIndex(motionEvent) shl MotionEventCompat.ACTION_POINTER_INDEX_SHIFT) + view.onTouchEvent(cancelEvent) + } + + private fun onDownMotionEvent(motionEvent: MotionEvent) { + val pointerIndex = MotionEventCompat.getActionIndex(motionEvent) + mLastTouchY = MotionEventCompat.getY(motionEvent, pointerIndex) + mActivePointerId = MotionEventCompat.getPointerId(motionEvent, 0) + + if (this.mRecyclerView.translationY > 0) { + mDownY = mLastTouchY - Math.pow(this.mRecyclerView.translationY.toDouble(), (10f / 8f).toDouble()).toInt() + this.mRecyclerView.animate().cancel() + } else if (this.mRecyclerView.translationY < 0) { + mDownY = mLastTouchY + Math.pow((-this.mRecyclerView.translationY).toDouble(), (10f / 8f).toDouble()).toInt() + this.mRecyclerView.animate().cancel() + } else { + mDownY = mLastTouchY + } + } + + private fun hasHitBottom(): Boolean { + val recyclerView = this.mRecyclerView + if (recyclerView.adapter != null && recyclerView.layoutManager != null) { + val adapter = recyclerView.adapter + if (adapter!!.itemCount > 0) { + val layoutManager = recyclerView.layoutManager + if (layoutManager is LinearLayoutManager) { + val linearLayoutManager = layoutManager as LinearLayoutManager? + return linearLayoutManager!!.findLastCompletelyVisibleItemPosition() == adapter.itemCount - 1 + } else if (layoutManager is StaggeredGridLayoutManager) { + val staggeredGridLayoutManager = layoutManager as StaggeredGridLayoutManager? + val checks = staggeredGridLayoutManager!!.findLastCompletelyVisibleItemPositions(null) + for (check in checks) { + if (check == adapter.itemCount - 1) + return true + } + } + } + } + return false + } + + private fun hasHitTop(): Boolean { + val recyclerView = this.mRecyclerView + if (recyclerView.adapter != null && recyclerView.layoutManager != null) { + val adapter = recyclerView.adapter + if (adapter!!.itemCount > 0) { + val layoutManager = recyclerView.layoutManager + if (layoutManager is LinearLayoutManager) { + val linearLayoutManager = layoutManager as LinearLayoutManager? + return linearLayoutManager!!.findFirstCompletelyVisibleItemPosition() == 0 + } else if (layoutManager is StaggeredGridLayoutManager) { + val staggeredGridLayoutManager = layoutManager as StaggeredGridLayoutManager? + val checks = staggeredGridLayoutManager!!.findFirstCompletelyVisibleItemPositions(null) + for (check in checks) { + if (check == 0) + return true + } + } + } + } + + return false + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Constants.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Constants.kt index 728cbfb..f404a82 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Constants.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Constants.kt @@ -1,10 +1,51 @@ package cy.agorise.bitsybitshareswallet.utils - object Constants { + /** Key used to store the number of the last agreed License version */ + const val KEY_LAST_AGREED_LICENSE_VERSION = "key_last_agreed_license_version" + + /** Version of the currently used license */ + const val CURRENT_LICENSE_VERSION = 1 + + /** Key used to store if the initial setup is already done or not */ + const val KEY_INITIAL_SETUP_DONE = "key_initial_setup_done" + + /** Key used to store the id value of the currently active account in the shared preferences */ + const val KEY_CURRENT_ACCOUNT_ID = "key_current_account_id" + + /** The minimum required length for a PIN number */ + const val MIN_PIN_LENGTH = 6 + + /** The user selected encrypted PIN */ + const val KEY_ENCRYPTED_PIN = "key_encrypted_pin" + /** - * Key used to store the night mode setting into the shared preferences + * LTM accounts come with an expiration date expressed as this string. + * This is used to recognize such accounts from regular ones. */ - val KEY_NIGHT_MODE_ACTIVATED = "key_night_mode_activated" + const val LIFETIME_EXPIRATION_DATE = "1969-12-31T23:59:59" + + /** + * Time period between two consecutive requests to the full node performed whenever we have + * open payment requests as a matter of redundancy. + */ + const val MISSING_PAYMENT_CHECK_PERIOD: Long = 5000 + + /** Time period to wait to send a request to the NetworkService, and retry in case it is still not connected */ + const val NETWORK_SERVICE_RETRY_PERIOD: Long = 5000 + + /** Bitshares block period */ + const val BLOCK_PERIOD: Long = 3000 + + /** Key used to store the number of operations that the currently selected account had last time we checked */ + const val KEY_ACCOUNT_OPERATION_COUNT = "key_account_operation_count" + + /** Key used to store the auto close app if no user activity setting into the shared preferences */ + const val KEY_AUTO_CLOSE_ACTIVATED = "key_auto_close_activated" + + /** Key used to store the night mode setting into the shared preferences */ + const val KEY_NIGHT_MODE_ACTIVATED = "key_night_mode_activated" + + const val MERCHANTS_WEBSERVICE_URL = "https://websvc.palmpay.io/" } diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/CryptoUtils.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/CryptoUtils.kt new file mode 100644 index 0000000..7fc8757 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/CryptoUtils.kt @@ -0,0 +1,72 @@ +package cy.agorise.bitsybitshareswallet.utils + +import android.content.Context +import android.preference.PreferenceManager + +import com.moldedbits.r2d2.R2d2 + +import javax.crypto.AEADBadTagException + +/** + * Class that provides encryption/decryption support by using the key management framework provided + * by the KeyStore system. + * + * The implemented scheme was taken from [this](https://medium.com/@ericfu/securely-storing-secrets-in-an-android-application-501f030ae5a3)> blog post. + * + * @see [Android Keystore System](https://developer.android.com/training/articles/keystore.html) + */ + +object CryptoUtils { + + /** + * Encrypts and stores a key-value pair in the shared preferences + * @param context The application context + * @param key The key to be used to reference the data + * @param value The actual value to be stored + */ + fun put(context: Context, key: String, value: String) { + val r2d2 = R2d2(context) + val encrypted = r2d2.encryptData(value) + PreferenceManager + .getDefaultSharedPreferences(context) + .edit() + .putString(key, encrypted) + .apply() + } + + /** + * Retrieves and decrypts an encrypted value from the shared preferences + * @param context The application context + * @param key The key used to reference the data + * @return The plaintext version of the encrypted data + */ + operator fun get(context: Context, key: String): String { + val r2d2 = R2d2(context) + val encrypted = PreferenceManager.getDefaultSharedPreferences(context).getString(key, null) + return r2d2.decryptData(encrypted) + } + + /** + * Encrypts some data + * @param context The application context + * @param plaintext The plaintext version of the data + * @return Encrypted data + */ + fun encrypt(context: Context, plaintext: String): String { + val r2d2 = R2d2(context) + return r2d2.encryptData(plaintext) + } + + /** + * Decrypts some data + * @param context The application context + * @param ciphertext The ciphertext version of the data + * @return Decrypted data + */ + @Throws(AEADBadTagException::class) + fun decrypt(context: Context, ciphertext: String): String { + val r2d2 = R2d2(context) + return r2d2.decryptData(ciphertext) + } +} + diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Helper.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Helper.kt new file mode 100644 index 0000000..0015e00 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Helper.kt @@ -0,0 +1,60 @@ +package cy.agorise.bitsybitshareswallet.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.net.Uri +import android.util.Log +import android.view.View +import androidx.core.content.FileProvider +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +/** + * Contains methods that are helpful in different parts of the app + */ +class Helper { + + companion object { + private val TAG = "Helper" + + /** + * Creates and returns a Bitmap from the contents of a View, does not matter + * if it is a simple view or a ViewGroup like a ConstraintLayout or a LinearLayout. + * + * @param view The view that is gonna be pictured. + * @return The generated image from the given view. + */ + fun loadBitmapFromView(view: View): Bitmap { + val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + view.draw(canvas) + + return bitmap + } + + fun saveTemporalBitmap(context: Context, bitmap: Bitmap): Uri { + // save bitmap to cache directory + try { + val cachePath = File(context.cacheDir, "images") + if (!cachePath.mkdirs()) + // don't forget to make the directory + Log.d(TAG, "shareBitmapImage creating cache images folder") + + val stream = FileOutputStream(cachePath.toString() + "/image.png") // overwrites this image every time + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + stream.close() + } catch (e: IOException) { + Log.d(TAG, "shareBitmapImage error: " + e.message) + } + + // Send intent to share image+text + val imagePath = File(context.cacheDir, "images") + val newFile = File(imagePath, "image.png") + + // Create and return image uri + return FileProvider.getUriForFile(context, "cy.agorise.bitsybitshareswallet.FileProvider", newFile) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/SquaredImageView.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/SquaredImageView.kt deleted file mode 100644 index 779744e..0000000 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/SquaredImageView.kt +++ /dev/null @@ -1,30 +0,0 @@ -package cy.agorise.bitsybitshareswallet.utils - -import android.content.Context -import android.util.AttributeSet -import androidx.annotation.Nullable -import androidx.appcompat.widget.AppCompatImageView - -/** - * Created by xd on 1/24/18. - * ImageView which adjusts its size to always create a square - */ - -class SquaredImageView : AppCompatImageView { - - constructor(context: Context) : super(context) - - constructor(context: Context, @Nullable attrs: AttributeSet) : super(context, attrs) - constructor(context: Context, @Nullable attrs: AttributeSet, defStyleAttr: Int) : super( - context, - attrs, - defStyleAttr - ) - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - - val size = Math.min(measuredWidth, measuredHeight) - setMeasuredDimension(size, size) - } -} diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/AssetViewModel.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/AssetViewModel.kt new file mode 100644 index 0000000..26fb10f --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/AssetViewModel.kt @@ -0,0 +1,15 @@ +package cy.agorise.bitsybitshareswallet.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import cy.agorise.bitsybitshareswallet.database.entities.Asset +import cy.agorise.bitsybitshareswallet.repositories.AssetRepository + +class AssetViewModel(application: Application) : AndroidViewModel(application) { + private var mRepository = AssetRepository(application) + + internal fun getAll(): LiveData> { + return mRepository.getAll() + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/BalanceDetailViewModel.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/BalanceDetailViewModel.kt new file mode 100644 index 0000000..93fcdfe --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/BalanceDetailViewModel.kt @@ -0,0 +1,16 @@ +package cy.agorise.bitsybitshareswallet.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import cy.agorise.bitsybitshareswallet.database.joins.BalanceDetail +import cy.agorise.bitsybitshareswallet.repositories.BalanceDetailRepository + + +class BalanceDetailViewModel(application: Application) : AndroidViewModel(application) { + private var mRepository = BalanceDetailRepository(application) + + internal fun getAll(): LiveData> { + return mRepository.getAll() + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/BalanceViewModel.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/BalanceViewModel.kt new file mode 100644 index 0000000..fc6ffe4 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/BalanceViewModel.kt @@ -0,0 +1,19 @@ +package cy.agorise.bitsybitshareswallet.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import cy.agorise.bitsybitshareswallet.database.entities.Balance +import cy.agorise.bitsybitshareswallet.repositories.BalanceRepository + +class BalanceViewModel(application: Application) : AndroidViewModel(application) { + private var mRepository = BalanceRepository(application) + + internal fun getMissingAssetIds(): LiveData> { + return mRepository.getMissingAssetIds() + } + + fun insertAll(balances: List) { + mRepository.insertAll(balances) + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/BalancesViewModel.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/BalancesViewModel.kt deleted file mode 100644 index 788d761..0000000 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/BalancesViewModel.kt +++ /dev/null @@ -1,7 +0,0 @@ -package cy.agorise.bitsybitshareswallet.viewmodels - -import androidx.lifecycle.ViewModel - -class BalancesViewModel : ViewModel() { - // TODO: Implement the ViewModel -} diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/MerchantsViewModel.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/MerchantsViewModel.kt deleted file mode 100644 index 662dbe7..0000000 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/MerchantsViewModel.kt +++ /dev/null @@ -1,7 +0,0 @@ -package cy.agorise.bitsybitshareswallet.viewmodels - -import androidx.lifecycle.ViewModel; - -class MerchantsViewModel : ViewModel() { - // TODO: Implement the ViewModel -} diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/TransactionsViewModel.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/TransactionsViewModel.kt deleted file mode 100644 index 12df4e4..0000000 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/TransactionsViewModel.kt +++ /dev/null @@ -1,7 +0,0 @@ -package cy.agorise.bitsybitshareswallet.viewmodels - -import androidx.lifecycle.ViewModel; - -class TransactionsViewModel : ViewModel() { - // TODO: Implement the ViewModel -} diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/TransferDetailViewModel.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/TransferDetailViewModel.kt new file mode 100644 index 0000000..39ff1b6 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/TransferDetailViewModel.kt @@ -0,0 +1,15 @@ +package cy.agorise.bitsybitshareswallet.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import cy.agorise.bitsybitshareswallet.database.joins.TransferDetail +import cy.agorise.bitsybitshareswallet.repositories.TransferDetailRepository + +class TransferDetailViewModel(application: Application) : AndroidViewModel(application) { + private var mRepository = TransferDetailRepository(application) + + internal fun getAll(userId: String): LiveData> { + return mRepository.getAll(userId) + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/TransferViewModel.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/TransferViewModel.kt new file mode 100644 index 0000000..484d72c --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/TransferViewModel.kt @@ -0,0 +1,18 @@ +package cy.agorise.bitsybitshareswallet.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import cy.agorise.bitsybitshareswallet.repositories.TransferRepository + +class TransferViewModel(application: Application) : AndroidViewModel(application) { + private var mRepository = TransferRepository(application) + + internal fun setBlockTime(blockNumber: Long, timestamp: Long) { + mRepository.setBlockTime(blockNumber, timestamp) + } + + internal fun getTransferBlockNumberWithMissingTime(): LiveData { + return mRepository.getTransferBlockNumberWithMissingTime() + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/UserAccountViewModel.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/UserAccountViewModel.kt new file mode 100644 index 0000000..b8d433c --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/viewmodels/UserAccountViewModel.kt @@ -0,0 +1,27 @@ +package cy.agorise.bitsybitshareswallet.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import cy.agorise.bitsybitshareswallet.database.entities.UserAccount +import cy.agorise.bitsybitshareswallet.repositories.UserAccountRepository + +class UserAccountViewModel(application: Application) : AndroidViewModel(application) { + private var mRepository = UserAccountRepository(application) + + internal fun getUserAccount(id: String): LiveData { + return mRepository.getUserAccount(id) + } + + internal fun getMissingUserAccountIds(): LiveData> { + return mRepository.getMissingUserAccountIds() + } + +// fun insert(userAccount: UserAccount) { +// mRepository.insert(userAccount) +// } + + fun insertAll(userAccounts: List) { + mRepository.insertAll(userAccounts) + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/views/MyTextInputAutoCompleteTextView.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/views/MyTextInputAutoCompleteTextView.kt new file mode 100644 index 0000000..d37b60d --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/views/MyTextInputAutoCompleteTextView.kt @@ -0,0 +1,45 @@ +package cy.agorise.bitsybitshareswallet.views + +import android.content.Context +import android.util.AttributeSet +import com.google.android.material.textfield.TextInputLayout +import android.view.inputmethod.InputConnection +import android.view.inputmethod.EditorInfo +import androidx.appcompat.widget.AppCompatAutoCompleteTextView + +/** + * Custom AutoCompleteTextView to be used inside a TextInputLayout, so that they can share their hint + * From https://stackoverflow.com/a/41864063/5428997 + */ +class MyTextInputAutoCompleteTextView : AppCompatAutoCompleteTextView { + + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { + val ic = super.onCreateInputConnection(outAttrs) + if (ic != null && outAttrs.hintText == null) { + // If we don't have a hint and our parent is a TextInputLayout, use it's hint for the + // EditorInfo. This allows us to display a hint in 'extract mode'. + val parent = parent + if (parent is TextInputLayout) { + outAttrs.hintText = parent.hint + } + } + // An EditText that lets you use actions ("Done", "Go", etc.) on multi-line edits. + val imeActions = outAttrs.imeOptions and EditorInfo.IME_MASK_ACTION + if (imeActions and EditorInfo.IME_ACTION_DONE != 0) { + // clear the existing action + outAttrs.imeOptions = outAttrs.imeOptions xor imeActions + // set the DONE action + outAttrs.imeOptions = outAttrs.imeOptions or EditorInfo.IME_ACTION_DONE + } + if (outAttrs.imeOptions and EditorInfo.IME_FLAG_NO_ENTER_ACTION != 0) { + outAttrs.imeOptions = outAttrs.imeOptions and EditorInfo.IME_FLAG_NO_ENTER_ACTION.inv() + } + return ic + } +} \ No newline at end of file diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/views/MyTextInputEditText.java b/app/src/main/java/cy/agorise/bitsybitshareswallet/views/MyTextInputEditText.java new file mode 100644 index 0000000..d5d17d6 --- /dev/null +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/views/MyTextInputEditText.java @@ -0,0 +1,43 @@ +package cy.agorise.bitsybitshareswallet.views; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +import com.google.android.material.textfield.TextInputEditText; + +// An EditText that lets you use actions ("Done", "Go", etc.) on multi-line edits. +public class MyTextInputEditText extends TextInputEditText +{ + public MyTextInputEditText(Context context) + { + super(context); + } + + public MyTextInputEditText(Context context, AttributeSet attrs) + { + super(context, attrs); + } + + public MyTextInputEditText(Context context, AttributeSet attrs, int defStyle) + { + super(context, attrs, defStyle); + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + InputConnection connection = super.onCreateInputConnection(outAttrs); + int imeActions = outAttrs.imeOptions&EditorInfo.IME_MASK_ACTION; + if ((imeActions&EditorInfo.IME_ACTION_DONE) != 0) { + // clear the existing action + outAttrs.imeOptions ^= imeActions; + // set the DONE action + outAttrs.imeOptions |= EditorInfo.IME_ACTION_DONE; + } + if ((outAttrs.imeOptions&EditorInfo.IME_FLAG_NO_ENTER_ACTION) != 0) { + outAttrs.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION; + } + return connection; + } +} \ No newline at end of file diff --git a/app/src/main/res/anim/item_animation_from_bottom.xml b/app/src/main/res/anim/item_animation_from_bottom.xml new file mode 100644 index 0000000..b7b874d --- /dev/null +++ b/app/src/main/res/anim/item_animation_from_bottom.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/app/src/main/res/anim/layout_animation_from_bottom.xml b/app/src/main/res/anim/layout_animation_from_bottom.xml new file mode 100644 index 0000000..6f90fed --- /dev/null +++ b/app/src/main/res/anim/layout_animation_from_bottom.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_in_left.xml b/app/src/main/res/anim/slide_in_left.xml new file mode 100644 index 0000000..e65e525 --- /dev/null +++ b/app/src/main/res/anim/slide_in_left.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_in_right.xml b/app/src/main/res/anim/slide_in_right.xml new file mode 100644 index 0000000..f387f84 --- /dev/null +++ b/app/src/main/res/anim/slide_in_right.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/anim/slide_out_left.xml b/app/src/main/res/anim/slide_out_left.xml new file mode 100644 index 0000000..3dfd3b2 --- /dev/null +++ b/app/src/main/res/anim/slide_out_left.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/anim/slide_out_right.xml b/app/src/main/res/anim/slide_out_right.xml new file mode 100644 index 0000000..63a2226 --- /dev/null +++ b/app/src/main/res/anim/slide_out_right.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/button_state_list_anim.xml b/app/src/main/res/animator/button_state_list_anim.xml new file mode 100644 index 0000000..efbc1fa --- /dev/null +++ b/app/src/main/res/animator/button_state_list_anim.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bts_logo.xml b/app/src/main/res/drawable/bts_logo.xml deleted file mode 100644 index ae391df..0000000 --- a/app/src/main/res/drawable/bts_logo.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/bts_logo_unselected.xml b/app/src/main/res/drawable/bts_logo_unselected.xml deleted file mode 100644 index 8173798..0000000 --- a/app/src/main/res/drawable/bts_logo_unselected.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/camera_view_background.xml b/app/src/main/res/drawable/camera_view_background.xml new file mode 100644 index 0000000..3557241 --- /dev/null +++ b/app/src/main/res/drawable/camera_view_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_forward.xml b/app/src/main/res/drawable/ic_arrow_forward.xml new file mode 100644 index 0000000..25fb386 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_forward.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_receive.xml b/app/src/main/res/drawable/ic_arrow_receive.xml new file mode 100644 index 0000000..4f68029 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_receive.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_send.xml b/app/src/main/res/drawable/ic_arrow_send.xml new file mode 100644 index 0000000..5628630 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_send.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_balances.xml b/app/src/main/res/drawable/ic_balances.xml new file mode 100644 index 0000000..326c11a --- /dev/null +++ b/app/src/main/res/drawable/ic_balances.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bitsy_logo.xml b/app/src/main/res/drawable/ic_bitsy_logo.xml new file mode 100644 index 0000000..8b5ea93 --- /dev/null +++ b/app/src/main/res/drawable/ic_bitsy_logo.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_bitsy_logo_2.xml b/app/src/main/res/drawable/ic_bitsy_logo_2.xml new file mode 100644 index 0000000..1761527 --- /dev/null +++ b/app/src/main/res/drawable/ic_bitsy_logo_2.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..0c8775c --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_connected.xml b/app/src/main/res/drawable/ic_connected.xml index 800434a..6b3ae1a 100644 --- a/app/src/main/res/drawable/ic_connected.xml +++ b/app/src/main/res/drawable/ic_connected.xml @@ -5,6 +5,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/ic_file_download.xml b/app/src/main/res/drawable/ic_file_download.xml new file mode 100644 index 0000000..b8e8361 --- /dev/null +++ b/app/src/main/res/drawable/ic_file_download.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_filter.xml b/app/src/main/res/drawable/ic_filter.xml new file mode 100644 index 0000000..5d4ec18 --- /dev/null +++ b/app/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_merchants.xml b/app/src/main/res/drawable/ic_merchants.xml new file mode 100644 index 0000000..094456f --- /dev/null +++ b/app/src/main/res/drawable/ic_merchants.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_pie_chart.xml b/app/src/main/res/drawable/ic_pie_chart.xml new file mode 100644 index 0000000..3782c7c --- /dev/null +++ b/app/src/main/res/drawable/ic_pie_chart.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_pin_merchants.xml b/app/src/main/res/drawable/ic_pin_merchants.xml new file mode 100644 index 0000000..7464702 --- /dev/null +++ b/app/src/main/res/drawable/ic_pin_merchants.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_receive.xml b/app/src/main/res/drawable/ic_receive.xml new file mode 100644 index 0000000..1967adf --- /dev/null +++ b/app/src/main/res/drawable/ic_receive.xml @@ -0,0 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..be5ad99 --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_send.xml b/app/src/main/res/drawable/ic_send.xml new file mode 100644 index 0000000..312dfa5 --- /dev/null +++ b/app/src/main/res/drawable/ic_send.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml index 369d00b..00d432f 100644 --- a/app/src/main/res/drawable/ic_settings.xml +++ b/app/src/main/res/drawable/ic_settings.xml @@ -4,11 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> - + android:fillColor="#FFF" + android:pathData="M19.43,12.98c0.04,-0.32 0.07,-0.64 0.07,-0.98 0,-0.34 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.09,-0.16 -0.26,-0.25 -0.44,-0.25 -0.06,0 -0.12,0.01 -0.17,0.03l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.06,-0.02 -0.12,-0.03 -0.18,-0.03 -0.17,0 -0.34,0.09 -0.43,0.25l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98 0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.09,0.16 0.26,0.25 0.44,0.25 0.06,0 0.12,-0.01 0.17,-0.03l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.06,0.02 0.12,0.03 0.18,0.03 0.17,0 0.34,-0.09 0.43,-0.25l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64l-2.11,-1.65zM17.45,11.27c0.04,0.31 0.05,0.52 0.05,0.73 0,0.21 -0.02,0.43 -0.05,0.73l-0.14,1.13 0.89,0.7 1.08,0.84 -0.7,1.21 -1.27,-0.51 -1.04,-0.42 -0.9,0.68c-0.43,0.32 -0.84,0.56 -1.25,0.73l-1.06,0.43 -0.16,1.13 -0.2,1.35h-1.4l-0.19,-1.35 -0.16,-1.13 -1.06,-0.43c-0.43,-0.18 -0.83,-0.41 -1.23,-0.71l-0.91,-0.7 -1.06,0.43 -1.27,0.51 -0.7,-1.21 1.08,-0.84 0.89,-0.7 -0.14,-1.13c-0.03,-0.31 -0.05,-0.54 -0.05,-0.74s0.02,-0.43 0.05,-0.73l0.14,-1.13 -0.89,-0.7 -1.08,-0.84 0.7,-1.21 1.27,0.51 1.04,0.42 0.9,-0.68c0.43,-0.32 0.84,-0.56 1.25,-0.73l1.06,-0.43 0.16,-1.13 0.2,-1.35h1.39l0.19,1.35 0.16,1.13 1.06,0.43c0.43,0.18 0.83,0.41 1.23,0.71l0.91,0.7 1.06,-0.43 1.27,-0.51 0.7,1.21 -1.07,0.85 -0.89,0.7 0.14,1.13zM12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z"/> diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..045bbc0 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_transactions.xml b/app/src/main/res/drawable/ic_transactions.xml new file mode 100644 index 0000000..060c0e5 --- /dev/null +++ b/app/src/main/res/drawable/ic_transactions.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/icon_camera.png b/app/src/main/res/drawable/icon_camera.png deleted file mode 100644 index 744d1dd..0000000 Binary files a/app/src/main/res/drawable/icon_camera.png and /dev/null differ diff --git a/app/src/main/res/drawable/icon_receive.png b/app/src/main/res/drawable/icon_receive.png deleted file mode 100644 index 9e6e211..0000000 Binary files a/app/src/main/res/drawable/icon_receive.png and /dev/null differ diff --git a/app/src/main/res/drawable/icon_send.png b/app/src/main/res/drawable/icon_send.png deleted file mode 100644 index 3896ad1..0000000 Binary files a/app/src/main/res/drawable/icon_send.png and /dev/null differ diff --git a/app/src/main/res/drawable/loader_homescreen.xml b/app/src/main/res/drawable/loader_homescreen.xml deleted file mode 100644 index 5abd1b9..0000000 --- a/app/src/main/res/drawable/loader_homescreen.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/send_fab_background.xml b/app/src/main/res/drawable/send_fab_background.xml new file mode 100644 index 0000000..a8c2324 --- /dev/null +++ b/app/src/main/res/drawable/send_fab_background.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/splash_screen.xml b/app/src/main/res/drawable/splash_screen.xml new file mode 100644 index 0000000..e10be20 --- /dev/null +++ b/app/src/main/res/drawable/splash_screen.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tab_home_selector.xml b/app/src/main/res/drawable/tab_home_selector.xml deleted file mode 100644 index 0af9508..0000000 --- a/app/src/main/res/drawable/tab_home_selector.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/font/p_f_beau_sans_pro.xml b/app/src/main/res/font/p_f_beau_sans_pro.xml new file mode 100644 index 0000000..6bf6506 --- /dev/null +++ b/app/src/main/res/font/p_f_beau_sans_pro.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/p_f_beau_sans_pro_italic.ttf b/app/src/main/res/font/p_f_beau_sans_pro_italic.ttf new file mode 100644 index 0000000..d94bb10 Binary files /dev/null and b/app/src/main/res/font/p_f_beau_sans_pro_italic.ttf differ diff --git a/app/src/main/res/font/p_f_beau_sans_pro_reg.ttf b/app/src/main/res/font/p_f_beau_sans_pro_reg.ttf new file mode 100644 index 0000000..8980a88 Binary files /dev/null and b/app/src/main/res/font/p_f_beau_sans_pro_reg.ttf differ diff --git a/app/src/main/res/layout/activity_import_brainkey.xml b/app/src/main/res/layout/activity_import_brainkey.xml new file mode 100644 index 0000000..87d1449 --- /dev/null +++ b/app/src/main/res/layout/activity_import_brainkey.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_license.xml b/app/src/main/res/layout/activity_license.xml new file mode 100644 index 0000000..3f1dc54 --- /dev/null +++ b/app/src/main/res/layout/activity_license.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index efd102d..cb09a9c 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,100 +1,28 @@ - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="match_parent" + android:orientation="vertical" + tools:context=".activities.MainActivity"> - + - + - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_receive_transaction.xml b/app/src/main/res/layout/activity_receive_transaction.xml deleted file mode 100644 index d1b31e3..0000000 --- a/app/src/main/res/layout/activity_receive_transaction.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_send_transaction.xml b/app/src/main/res/layout/activity_send_transaction.xml deleted file mode 100644 index 5500336..0000000 --- a/app/src/main/res/layout/activity_send_transaction.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml deleted file mode 100644 index 8b6045a..0000000 --- a/app/src/main/res/layout/activity_settings.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_copy_brainkey.xml b/app/src/main/res/layout/dialog_copy_brainkey.xml new file mode 100644 index 0000000..f4d31a9 --- /dev/null +++ b/app/src/main/res/layout/dialog_copy_brainkey.xml @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_filter_options.xml b/app/src/main/res/layout/dialog_filter_options.xml new file mode 100644 index 0000000..15cc40a --- /dev/null +++ b/app/src/main/res/layout/dialog_filter_options.xml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_balances.xml b/app/src/main/res/layout/fragment_balances.xml index d6b044c..e828895 100644 --- a/app/src/main/res/layout/fragment_balances.xml +++ b/app/src/main/res/layout/fragment_balances.xml @@ -1,146 +1,20 @@ - + + android:layout_height="match_parent"> - + android:layout_height="match_parent" + android:layout_marginTop="@dimen/activity_vertical_margin" + android:layout_marginBottom="@dimen/activity_vertical_margin" + android:layout_marginStart="@dimen/activity_horizontal_margin" + android:layout_marginEnd="@dimen/activity_horizontal_margin" + tools:listitem="@layout/item_balance" + tools:itemCount="3"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml new file mode 100644 index 0000000..d26ae91 --- /dev/null +++ b/app/src/main/res/layout/fragment_home.xml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_merchants.xml b/app/src/main/res/layout/fragment_merchants.xml index bdd66aa..8adfe9e 100644 --- a/app/src/main/res/layout/fragment_merchants.xml +++ b/app/src/main/res/layout/fragment_merchants.xml @@ -1,14 +1,8 @@ - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_net_worth.xml b/app/src/main/res/layout/fragment_net_worth.xml new file mode 100644 index 0000000..11f7fc2 --- /dev/null +++ b/app/src/main/res/layout/fragment_net_worth.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_receive_transaction.xml b/app/src/main/res/layout/fragment_receive_transaction.xml new file mode 100644 index 0000000..fefdfc9 --- /dev/null +++ b/app/src/main/res/layout/fragment_receive_transaction.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_send_transaction.xml b/app/src/main/res/layout/fragment_send_transaction.xml new file mode 100644 index 0000000..73f1b78 --- /dev/null +++ b/app/src/main/res/layout/fragment_send_transaction.xml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml new file mode 100644 index 0000000..905d393 --- /dev/null +++ b/app/src/main/res/layout/fragment_settings.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_transactions.xml b/app/src/main/res/layout/fragment_transactions.xml index 743ce9d..430ce36 100644 --- a/app/src/main/res/layout/fragment_transactions.xml +++ b/app/src/main/res/layout/fragment_transactions.xml @@ -1,14 +1,11 @@ - - - - - \ No newline at end of file + android:padding="@dimen/card_margin" + android:clipToPadding="false" + tools:listitem="@layout/item_transaction" + tools:itemCount="6"/> \ No newline at end of file diff --git a/app/src/main/res/layout/item_balance.xml b/app/src/main/res/layout/item_balance.xml new file mode 100644 index 0000000..d8c7013 --- /dev/null +++ b/app/src/main/res/layout/item_balance.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_node.xml b/app/src/main/res/layout/item_node.xml new file mode 100644 index 0000000..beb35be --- /dev/null +++ b/app/src/main/res/layout/item_node.xml @@ -0,0 +1,27 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_transaction.xml b/app/src/main/res/layout/item_transaction.xml new file mode 100644 index 0000000..967998c --- /dev/null +++ b/app/src/main/res/layout/item_transaction.xml @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_home.xml b/app/src/main/res/menu/menu_home.xml new file mode 100644 index 0000000..f9d5c72 --- /dev/null +++ b/app/src/main/res/menu/menu_home.xml @@ -0,0 +1,23 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml new file mode 100644 index 0000000..bf6f71b --- /dev/null +++ b/app/src/main/res/menu/menu_main.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_receive_transaction.xml b/app/src/main/res/menu/menu_receive_transaction.xml new file mode 100644 index 0000000..5888d65 --- /dev/null +++ b/app/src/main/res/menu/menu_receive_transaction.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_transactions.xml b/app/src/main/res/menu/menu_transactions.xml new file mode 100644 index 0000000..75b0095 --- /dev/null +++ b/app/src/main/res/menu/menu_transactions.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml new file mode 100644 index 0000000..1e2c596 --- /dev/null +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/attr.xml b/app/src/main/res/values/attr.xml new file mode 100644 index 0000000..e85dca7 --- /dev/null +++ b/app/src/main/res/values/attr.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 2a7fee2..41ef6c5 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,39 +1,18 @@ #0099d6 - #0099d6 - #669900 - #d3d3d3 - #686767 - #ffffff - #f0006b + #006ba4 + #0099d6 - #147b00 + + #424242 - #000000 - #BFD1FF - #f2f6ff - #70882E - #ff0000 - #FF4081 - #DD4739 - - #FFFFFF - #FFFFFF - #000000 - - #dddddd - - #000000 - #ffffff - #669900 - #c5e1a5 - #DC473A - #ef9a9a - #BFE5F5 - #CCCCCC - - #000000 - #000000 - #ff56a9c7 + #000 + #888 + #139657 + #e0e0e0 + #616161 + #669900 + #DC473A + #2888 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 9d518a5..6f512c5 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -7,4 +7,16 @@ 35sp + + 16dp + 24dp + 40dp + + + 180dp + + + 4dp + 4dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9d4109e..84af3e9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,39 +1,86 @@ + BiTSy - + +

                  

BiTSy Terms and Conditions of Use

 

BiTSy is a software Application (also known as an “App”) developed by the International Business Company “AGORISE, LTD.”, which has been established under Cypriot law. Any person wishing to use this App is obliged to accept the following Terms and Conditions of Use, before any such use:

 

Part I - Terminology

 

The following Terminology applies to these Terms and Conditions of Use (hereinafter referred to as “Terms”), the Privacy and Transparency Statement, and all other agreements between You and Us:

  • The terms “Client”, “Customer”, “Merchant”, “User”, “You” and “Your” refer to you, the person accessing the BiTSy software Application (hereinafter referred to as “App”) and hereby accepting our Terms.
  • The terms “Agorise”, “The Company”, “Our”, “Ourselves”, “We” and “Us” collectively refer to the App and to its owners, freelancers, developers, designers, contractors, directors, officers, employees, agents, insurers, suppliers, and attorneys.
  • The term “Party” refers to either You or Us.
  • In all the above-mentioned Terms, unless otherwise specified, words using the singular include the plural and vice versa and words using gender include all genders.
  • The terms “digital asset”, “asset”, “coin”, “cryptocurrency”, “ledger entry”, “altcoin” and “token” refer to blockchain-based software ledger data entries.
  • The term “Blockchain” refers to a chain of blocks, processed by hundreds of computers around the world. A blockchain (like “Bitshares”) is a decentralized network of computers, an immutable distributed ledger. As each block of transactions is confirmed every 3 (three) seconds, it is hashed together with the latest block in the chain, forming the next block in the chain. Hence the term blockchain.
  • The term “Ledger” refers to a digital version of the “big book” of blocks of transactions taking place on a blockchain. The Distributed ledger keeps track of all transactions on a blockchain network.
  • The name “Morphit” refers to the name chosen by our Company for the Bridge.
  • The term “Bridge” refers to a company or autonomous software (like the “Morphit” feature of our App) which converts/morphs one cryptocurrency into another by way of seeking the best possible price for the Customer by using various third-party exchanges.
  • The terms “Teller” or “Gateway” refers to a person or company (or autonomous software) that converts “fiat” (USD, EUR, CNY, etc) into cryptocurrencies, and vice-versa, whether by cash, card, Swift or SEPA, etc.
  • The term “Multisig” refers to Multiple Signatories. Multiple people or accounts that are assigned to validate a transaction before it can be allowed to be processed, and entered into a block on the chain. Typically, two or more people sign the transaction with their private key (Public Key Infrastructure (PKI)) which will approve the transaction to be sent.
  • For the term “Bitshares” more information can usually be found at the website: http://www.bitshares.org
  • The term “Coin” refers to a digital token. It is underlined that this term is still loosely defined in legislation. It’s a unit of value (if it has any value at all, as determined by the free market) which is running on a blockchain.
  • The term “BTS” refers to the core token that the Bitshares platform uses to determine fair-market values for tokens.

 

Part II - Terms and Conditions of Use

 

By using the App, you represent and warrant that you are:

  1. at least 18 (eighteen) years old and have full capacity to contract under the applicable law;
  2. only transacting with the App with legally-obtained funds that belong to you;
  3. not furthering, performing, undertaking, engaging in, aiding, or abetting any unlawful activity through your relationship with Us or through your use of the App (for example: money laundering, etc); and,
  4. comporting with and obeying all applicable laws.

 

We reserve the right to terminate your access to the App for any breach or our Terms, in our sole and absolute discretion, if the App ceases to exist and/or following a decision of the Company. Use of the App is void where prohibited by applicable law.

 

1. Terms

 

1.1 By accessing the App, you agree to be bound by our Terms, all applicable laws and regulations in Cyprus, and you agree that you are responsible for compliance with, and that you are compliant with applicable law.

 

1.2 If you do not agree with any of our Terms, you are prohibited from using or accessing the App. Your only recourse is to stop using the App. Any use of the App after accepting these Terms is considered to be a deemed acceptance of our Terms, as they may be modified and amended from time to time.

 

1.3 The materials contained in the App are protected by applicable copyright and trademark laws in Cyprus and EU and international treaties. The updates of our Terms, as they appear on our website, take into account the legislative modifications.

 

1.4 By accepting our Terms, you expressly accept that data of transactions made when accessing the App might be exported outside the jurisdiction in which you reside or are located when you access the App. This export is an inherent part of our App and is necessary for its functioning and does not include personal data of Users in any way.

 

1.5 By using any of the third-parties linked to within the App (such as Tellers or Gateways), it is not an endorsement of those third-parties, nor is it a guarantee of any kind that using those third-parties will not result in loss of funds or other damages. Use those third-parties at your own risk.

 

2. Limitations

 

The use of this App may carry financial risk for any User, and is to be used as an experimental software utility only. In no event shall We be liable or responsible for any damages, claims, applications, losses, injuries, delays, accidents, costs, business interruption costs, or other expenses (including, without limitation, attorneys’ fees or the costs of any claim or suit), nor for any incidental, direct, general, indirect, special, punitive, exemplary, or consequential damages, loss of goodwill or business profits, loss of cryptocurrency or digital assets, work stoppage, computer or device failure or malfunction, or any other commercial or other losses directly or indirectly arising out of or related to:

  1. our Terms;
  2. the Privacy and Transparency Statement;
  3. any service We provide;
  4. the use of the App;
  5. any use of your digital assets or cryptocurrency with the App by any other party not authorized by you (collectively, all of the foregoing items shall be referred to herein as “Losses”).

 

We are hereby released by You from liability for any kind of the above-mentioned Losses. We disclaim any and all warranties or guarantees, including any warranty of merchantability and warranty of fitness for any particular purpose.

 

The foregoing limitations of liability shall apply whether the alleged liability or Losses are based on contract, negligence, tort, strict liability, or any other basis, even if We have been advised of or should have known of the possibility of such losses and damages, and without regard to the success or effectiveness of other remedies. Notwithstanding anything else in our Terms, in no event shall the combined aggregate liability for any Loss hereunder exceed € 50.00 (fifty euros).

 

3. Prices, Exchange Rates, Surety and Confirmations

 

3.1 Users are hereby informed that Cryptocurrencies and digital assets in general are highly experimental and risky. Our App attempts to provide accurate price and exchange rate information, but this information is as well highly volatile and can change quickly without Users or Us necessarily being aware of these changes.

 

3.2 The exchange rate that the Customer pays (if applicable) is calculated at the moment the App presents the amount due to the customer (via the QR Code or NFC signal). Our App will always seek out the best price for the Customer.

3.3 The confirmation time of some cryptocurrencies can be very lengthy. For example, if a customer pays with a slow coin such as the Petro (Venezuela) but pays a minimal miner fee when sending their funds to the App, then that customer’s payment may not be processed by that network for many hours, if at all.

 

Due to this risk, the App offers a Surety feature, allowing You to voluntarily pre-fund a Multisig blockchain account (for example: joes-grocery-morphit) with some Bitshares (BTS) tokens, and then voluntarily share that account with the Morphit bridge algorithm in the App temporarily.

 

This way, the bridge can send the necessary BTS or smartcoins to the Merchant instantly, be protected from customer minimal miner fee or double-spend scams, and confirm the customers "completed" payment so that the customer doesn't have to stand there waiting for their (slow coin) payment to confirm. The customer can then theoretically pay with any supported coin and leave in as little as 3 seconds. Payments in a Bitshares token do not require use of a bridge or surety at all since that chain is so fast. Steem payments don't need surety either, but the bridge is used real quick to convert it to the merchants desired BTS or smartcoin. The merchant can empty their surety account any time they like, or add more funds to it for high volume (such as grocery chains) or larger transactions (such as homes or cars).

 

If however, the customer’s payment is never confirmed by their network, then that amount of Surety that was shared with the bridge will not be released back to You. The bridge will not assume any risk of Your customers payments not clearing.

 

Due to individual blockchain specifications, the Customer payment is typically considered “accepted” by the bridge at two block confirmations. If a bridge had to be used, the bridge will then automatically release the Surety back to you that was shared while the bridge was awaiting those two block confirmations. It is important to note that a payment being broadcast to a blockchain network does not constitute an acceptance by the App of that payment.

 

4. Returns and Refund Policy

 

4.1 Cryptocurrencies, tokens, and digital assets are, by their nature, generally irreversible, and their exchange rates are highly volatile and transitory. Once the User’s asset has been transmitted to the User’s address, no refund is possible, even if the wrong address was provided to the App. All sales after transmission are final and the Company is not in position to reverse or correct the process already undertaken by the App.

 

4.2 Surety can be 100% refunded at any time from within the App itself. With no pre-funded bridge Surety however, only EOS or Graphene-based cryptocurrencies should be accepted (such as Steem and/or Bitshares tokens).

 

5. Governing Law

 

5.1 These Terms are governed by the laws of Cyprus, and any and all laws applicable therein.

 

5.2 Our Terms are to be treated in all respects as a Cypriot contract. We and You irrevocably and unconditionally attorn to the non-exclusive jurisdiction, venue and forum of the courts of Nicosia, Cyprus, and all courts competent to hear appeals therefrom.

 

6. Permissible Use 

 

The App and all its services may be used only as a mechanism of software ledger entry translation between the User and the Bitshares blockchain. You are prohibited from using the App for the purpose of translating ledger entries with other parties, with the exception of explicit payment for goods and services.

 

7. Terms of Use Modifications 

 

7.1 We may revise our Terms at any time and without notice to you or third parties. By using the App, you agree to be bound by the then-current version of our Terms. All modifications generally apply ex nunc, unless otherwise specified in special cases, if this is justified by the circumstances.

 

7.2 The Terms as they appear on the website of our App are always up-to-date. Therefore, Users are advised to visit the relevant part of the website regularly in order to be informed of the latest changes.

 

8. Costs 

 

8.1 From time to time, We may need to spend time dealing with issues brought to Us by customers, for example requests of information.

 

8.2 Where any customer issue is not caused by our negligence or oversight, We reserve the right to recover reasonable administrative costs spent addressing the customer issue. These costs will be calculated by the Company taking into account the relevant administrative burden caused to it and will be communicated to the Customer.

 

8.3 No response will be sent to the Customer if this entails a payment from his side, before the cost is communicated to him by the email provided by him for this specific purpose.

 

9. Transparency Statement 

 

9.1 No personal data whatsoever of Users is registered, stored or processed in any way; Users are solely liable for keeping the “key” which is necessary to access the interface and in case of loss renders this access impossible. Therefore, the Regulation (EU) No 2016/679 (GDPR) is not applicable within the framework of this App.

 

9.2 We also do not in any way obscure the information that it does request or obtain. Due to the inherent transparency of blockchains, transactions to and from the App are public and easily correlated. Utilizing the App to obscure transactions or assets in any way is futile. Law enforcement has full access to blockchain information that goes in or out of the Bitshares network.

 

9.3 You accept that We will comply willingly with all legal requests for information from it. We reserve the right to provide information to law enforcement personnel and other third parties to answer inquiries; to respond to legal process; to respond to the order of a court of competent jurisdiction and those exercising the court’s authority; and, to protect Ourselves and our users.

 

Copyright 2018 AGORISE, LTD.

An International Business Co.

Cyprus Reg# HE375959

 

IF YOU AGREE TO ALL OF THESE TERMS AND CONDITIONS, PLEASE TAP ON THE GREEN BUTTON BELOW

]]>
+ + + Agree + Disagree + + + Loading Assets… + Performing a series of requests in order to get the complete list of all + existing assets + + Loaded %1$d assets into the database + Assets Loaded + Next, please setup your account… + Next + + + 6+ digits PIN + PIN too short + Confirm PIN + PIN mismatch + BrainKey + Please enter correct brainkey, it should have between 12 and 16 + words. + + Import + Create + + Invalid account, please check your brainkey for typing errors + Please try again after 5 minutes + Please select an account + The keys derived from this brainkey seem to be used to control + more than one account, please select which account you wish to import + + + Transactions Merchants - Block: %1$s + Receive + Balances + Send + Net Worth + Coming soon + + + Search + Filter + Export + + + To + Amount + Memo + Scan QR + Asset + Please Pay: %1$s %2$s + To: %1$s + BiTSy invoice from %1$s + Share + Share with Settings - Night mode - - CANCEL - - to - from - - accountId - - language - - NEXT - - From - To - - Amount - - Loyalty Points - Backup Asset - Memo - Donate 2 BTS to the Support Developers at - Send - v 1.0 beta - block# ------- - No amount requested - Amount + General + Automatically close BiTSy after 3 minutes of inactivity + Night mode + Backup + BrainKey. Account recovery words that can be captured or copied, but not + edited. + + Print this out, or write it down. Anyone with access to your recovery key will + have access to funds within this wallet. + + + Bugs or Ideas? + Telegram chat: http://t.me/Agorise\nEmail: Agorise@protonmail.ch\nOpen Source: + https://github.com/Agorise + + Block: %1$s
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index f95b2ee..a6268bc 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,33 +1,57 @@ - - - - + + + + + + + - -