From 847e8a8d7f9ef283c6794516a8cdd232b911ab58 Mon Sep 17 00:00:00 2001 From: Severiano Jaramillo Date: Thu, 22 Nov 2018 20:13:10 -0600 Subject: [PATCH] - Make an UX improvement to let the user import the account directly from the keyboard when typing the brainkey in the TextField. - Add the MaterialDialogs library and use it to let the user choose the desired account to import when the brainkey controls more than one account. --- app/build.gradle | 13 +- app/src/main/AndroidManifest.xml | 35 ++-- .../activities/ConnectedActivity.kt | 73 ++++++- .../activities/ImportBrainkeyActivity.kt | 191 +++++++++++++++++- .../bitsybitshareswallet/utils/Constants.kt | 3 + .../views/MyTextInputEditText.java | 43 ++++ .../res/layout/activity_import_brainkey.xml | 2 +- app/src/main/res/values/strings.xml | 5 + 8 files changed, 331 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/cy/agorise/bitsybitshareswallet/views/MyTextInputEditText.java diff --git a/app/build.gradle b/app/build.gradle index 6e4d35e..e60da3a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -22,6 +22,11 @@ android { 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 { @@ -34,9 +39,6 @@ dependencies { 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' - implementation 'com.google.zxing:core:3.3.1' - implementation 'me.dm7.barcodescanner:zxing:1.9.8' implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version" @@ -44,6 +46,11 @@ dependencies { implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version" + implementation 'org.bitcoinj:bitcoinj-core:0.14.3' + implementation 'com.google.zxing:core:3.3.1' + implementation 'me.dm7.barcodescanner:zxing:1.9.8' + implementation 'com.afollestad.material-dialogs:core:2.0.0-rc1' + // Android Debug Database debugImplementation 'com.amitshekhar.android:debug-db:1.0.4' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6de4794..29fb061 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,20 +1,20 @@ + xmlns:tools="http://schemas.android.com/tools" + package="cy.agorise.bitsybitshareswallet"> + android:name=".utils.BitsyApplication" + android:allowBackup="true" + android:icon="@drawable/bts_logo" + android:label="@string/app_name" + android:roundIcon="@drawable/bts_logo" + android:supportsRtl="true" + android:theme="@style/Theme.Bitsy" + tools:ignore="GoogleAppIndexingWarning"> + android:name=".activities.SplashActivity" + android:theme="@style/SplashTheme"> @@ -22,13 +22,10 @@ - - - - - + android:name=".activities.SettingsActivity" + android:label="@string/title_settings"/> + + diff --git a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt index 94ca6de..6f1d3a9 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ConnectedActivity.kt @@ -4,15 +4,21 @@ 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.os.PersistableBundle import android.util.Log +import android.widget.Toast import androidx.appcompat.app.AppCompatActivity -import cy.agorise.bitsybitshareswallet.utils.Constants +import cy.agorise.graphenej.api.ConnectionStatusUpdate import cy.agorise.graphenej.api.android.NetworkService -import cy.agorise.graphenej.api.calls.GetFullAccounts -import java.util.ArrayList +import cy.agorise.graphenej.api.android.RxBus +import cy.agorise.graphenej.models.FullAccountDetails +import cy.agorise.graphenej.models.HistoryOperationDetail +import cy.agorise.graphenej.models.JsonRpcResponse +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable /** * Class in charge of managing the connection to graphenej's NetworkService @@ -23,6 +29,11 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { private val mHandler = Handler() + // Disposable returned at the bus subscription + private var mDisposable: Disposable? = null + + private var storedOpCount: Long = -1 + /* Network service connection */ protected var mNetworkService: NetworkService? = null @@ -31,6 +42,48 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { */ private var mShouldUnbindNetwork: Boolean = false + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + mDisposable = RxBus.getBusInstance() + .asFlowable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { message -> + if (message is JsonRpcResponse<*>) { + // Generic processing taken care by subclasses + handleJsonRpcResponse(message) + // Payment detection focused responses + if (message.error == null) { +// if (message.result is List<*> && (message.result as List<*>).size > 0) { +// if ((message.result as List<*>)[0] is FullAccountDetails) { +// if (message.id == recurrentAccountUpdateId) { +// handleAccountDetails((message.result as List<*>)[0] as FullAccountDetails) +// } else if (message.id == postProcessingAccountUpdateId) { +// handleAccountUpdate((message.result as List<*>)[0] as FullAccountDetails) +// } +// } +// } else if (message.result is HistoryOperationDetail && message.id == accountOpRequestId) { +// handleNewOperations(message.result as HistoryOperationDetail) +// } + } 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) { +// recurrentAccountUpdateId = -1 +// accountOpRequestId = -1 +// isProcessingTx = false +// } + } + } + } + override fun onResume() { super.onResume() @@ -81,4 +134,16 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection { override fun onServiceDisconnected(name: ComponentName?) { } + + /** + * 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 index f69ece5..bf524cc 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ImportBrainkeyActivity.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/activities/ImportBrainkeyActivity.kt @@ -1,13 +1,30 @@ package cy.agorise.bitsybitshareswallet.activities +import android.content.DialogInterface import android.os.Bundle +import android.preference.PreferenceManager +import android.util.Log +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.Toast +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.list.listItems import cy.agorise.bitsybitshareswallet.R import cy.agorise.bitsybitshareswallet.utils.Constants +import cy.agorise.graphenej.Address import cy.agorise.graphenej.BrainKey import cy.agorise.graphenej.UserAccount +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 kotlinx.android.synthetic.main.activity_import_brainkey.* +import org.bitcoinj.core.ECKey +import java.util.ArrayList class ImportBrainkeyActivity : ConnectedActivity() { + private val TAG = "ImportBrainkeyActivity" /** * Private variable that will hold an instance of the [BrainKey] class @@ -34,13 +51,19 @@ class ImportBrainkeyActivity : ConnectedActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_import_brainkey) + // Custom event to activate import account from the keyboard + tietBrainKey.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + importAccount() + true + } else + false + } + btnImport.setOnClickListener { importAccount() } } private fun importAccount() { - val trimmedBrainKey = tietBrainKey.text!!.toString().trim { it <= ' ' } - tietBrainKey.setText(trimmedBrainKey) - tilPin.isErrorEnabled = false tilPinConfirmation.isErrorEnabled = false tilBrainKey.isErrorEnabled = false @@ -52,12 +75,166 @@ class ImportBrainkeyActivity : ConnectedActivity() { else if (tietBrainKey.text!!.isEmpty() || !tietBrainKey.text.toString().contains(" ")) tilBrainKey.error = getString(R.string.error__enter_correct_brainkey) else { - val brainKey = tietBrainKey.text.toString().split(" ") - if (brainKey.size in 12..16) { - // TODO verify brainkey - } else + val brainKey = tietBrainKey.text.toString() + if (brainKey.split(" ").size in 12..16) + verifyBrainKey(false) + else tilBrainKey.error = getString(R.string.error__enter_correct_brainkey) } } + + /** + * 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) + .listItems(items = candidates) { dialog, index, _ -> + if (index >= 0) { + // If one account was selected, we keep a reference to it and + // store the account properties + // TODO make sure this is reached + mUserAccount = mUserAccountCandidates!![index] + onAccountSelected(accountPropertiesList[index]) + dialog.dismiss() + } + } + .negativeButton(android.R.string.cancel) { mKeyReferencesAttempts = 0 } + .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 + + Toast.makeText(this, "Account: "+accountProperties.name, Toast.LENGTH_SHORT).show() + + val password = tietPin.text!!.toString() + + // Stores the accounts this key refers to +// database.putOwnedUserAccounts(applicationContext, mUserAccount, password) + + // Stores the id of the currently active user account +// PreferenceManager.getDefaultSharedPreferences(applicationContext) +// .edit() +// .putString(Constants.KEY_CURRENT_ACCOUNT_ID, mUserAccount!!.objectId) +// .apply() + + // Trying to store all possible authorities (owner, active and memo) +// for (i in 0..2) { +// mBrainKey.setSequenceNumber(i) +// saveAccountAuthorities(mBrainKey, accountProperties) +// } + + // TODO move to MainActivity + } } \ 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 c162b35..d2128c5 100644 --- a/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Constants.kt +++ b/app/src/main/java/cy/agorise/bitsybitshareswallet/utils/Constants.kt @@ -9,6 +9,9 @@ object Constants { /** Version of the currently used license */ const val CURRENT_LICENSE_VERSION = 1 + /** 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 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/layout/activity_import_brainkey.xml b/app/src/main/res/layout/activity_import_brainkey.xml index 589a313..1a6d3ae 100644 --- a/app/src/main/res/layout/activity_import_brainkey.xml +++ b/app/src/main/res/layout/activity_import_brainkey.xml @@ -59,7 +59,7 @@ android:layout_marginEnd="@dimen/activity_horizontal_margin" android:hint="@string/text__brain_key"> - 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 MainActivity