- 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.
This commit is contained in:
Severiano Jaramillo 2018-11-22 20:13:10 -06:00
parent 22a0735379
commit 847e8a8d7f
8 changed files with 331 additions and 34 deletions

View file

@ -22,6 +22,11 @@ android {
resValue("string", "PORT_NUMBER", "8082") 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 { dependencies {
@ -34,9 +39,6 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.0.2' implementation 'androidx.appcompat:appcompat:1.0.2'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-alpha2' implementation 'androidx.constraintlayout:constraintlayout:2.0.0-alpha2'
implementation 'com.google.android.material:material:1.0.0' 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" implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version" kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"
@ -44,6 +46,11 @@ dependencies {
implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$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 // Android Debug Database
debugImplementation 'com.amitshekhar.android:debug-db:1.0.4' debugImplementation 'com.amitshekhar.android:debug-db:1.0.4'

View file

@ -1,20 +1,20 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="cy.agorise.bitsybitshareswallet"> package="cy.agorise.bitsybitshareswallet">
<application <application
android:name=".utils.BitsyApplication" android:name=".utils.BitsyApplication"
android:allowBackup="true" android:allowBackup="true"
android:icon="@drawable/bts_logo" android:icon="@drawable/bts_logo"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@drawable/bts_logo" android:roundIcon="@drawable/bts_logo"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Bitsy" android:theme="@style/Theme.Bitsy"
tools:ignore="GoogleAppIndexingWarning"> tools:ignore="GoogleAppIndexingWarning">
<activity <activity
android:name=".activities.SplashActivity" android:name=".activities.SplashActivity"
android:theme="@style/SplashTheme"> android:theme="@style/SplashTheme">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
@ -22,13 +22,10 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
android:name=".activities.SettingsActivity" android:name=".activities.SettingsActivity"
android:label="@string/title_settings"> android:label="@string/title_settings"/>
</activity> <activity android:name=".activities.SendTransactionActivity"/>
<activity android:name=".activities.SendTransactionActivity"> <activity android:name=".activities.ReceiveTransactionActivity"/>
</activity>
<activity android:name=".activities.ReceiveTransactionActivity">
</activity>
<activity android:name=".activities.MainActivity"/> <activity android:name=".activities.MainActivity"/>
<activity android:name=".activities.LicenseActivity"/> <activity android:name=".activities.LicenseActivity"/>
<activity android:name=".activities.ImportBrainkeyActivity"/> <activity android:name=".activities.ImportBrainkeyActivity"/>

View file

@ -4,15 +4,21 @@ import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.preference.PreferenceManager import android.os.PersistableBundle
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity 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.android.NetworkService
import cy.agorise.graphenej.api.calls.GetFullAccounts import cy.agorise.graphenej.api.android.RxBus
import java.util.ArrayList 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 * Class in charge of managing the connection to graphenej's NetworkService
@ -23,6 +29,11 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection {
private val mHandler = Handler() private val mHandler = Handler()
// Disposable returned at the bus subscription
private var mDisposable: Disposable? = null
private var storedOpCount: Long = -1
/* Network service connection */ /* Network service connection */
protected var mNetworkService: NetworkService? = null protected var mNetworkService: NetworkService? = null
@ -31,6 +42,48 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection {
*/ */
private var mShouldUnbindNetwork: Boolean = false 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() { override fun onResume() {
super.onResume() super.onResume()
@ -81,4 +134,16 @@ abstract class ConnectedActivity : AppCompatActivity(), ServiceConnection {
override fun onServiceDisconnected(name: ComponentName?) { 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)
} }

View file

@ -1,13 +1,30 @@
package cy.agorise.bitsybitshareswallet.activities package cy.agorise.bitsybitshareswallet.activities
import android.content.DialogInterface
import android.os.Bundle 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.R
import cy.agorise.bitsybitshareswallet.utils.Constants import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.graphenej.Address
import cy.agorise.graphenej.BrainKey import cy.agorise.graphenej.BrainKey
import cy.agorise.graphenej.UserAccount 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 kotlinx.android.synthetic.main.activity_import_brainkey.*
import org.bitcoinj.core.ECKey
import java.util.ArrayList
class ImportBrainkeyActivity : ConnectedActivity() { class ImportBrainkeyActivity : ConnectedActivity() {
private val TAG = "ImportBrainkeyActivity"
/** /**
* Private variable that will hold an instance of the [BrainKey] class * Private variable that will hold an instance of the [BrainKey] class
@ -34,13 +51,19 @@ class ImportBrainkeyActivity : ConnectedActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_import_brainkey) 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() } btnImport.setOnClickListener { importAccount() }
} }
private fun importAccount() { private fun importAccount() {
val trimmedBrainKey = tietBrainKey.text!!.toString().trim { it <= ' ' }
tietBrainKey.setText(trimmedBrainKey)
tilPin.isErrorEnabled = false tilPin.isErrorEnabled = false
tilPinConfirmation.isErrorEnabled = false tilPinConfirmation.isErrorEnabled = false
tilBrainKey.isErrorEnabled = false tilBrainKey.isErrorEnabled = false
@ -52,12 +75,166 @@ class ImportBrainkeyActivity : ConnectedActivity() {
else if (tietBrainKey.text!!.isEmpty() || !tietBrainKey.text.toString().contains(" ")) else if (tietBrainKey.text!!.isEmpty() || !tietBrainKey.text.toString().contains(" "))
tilBrainKey.error = getString(R.string.error__enter_correct_brainkey) tilBrainKey.error = getString(R.string.error__enter_correct_brainkey)
else { else {
val brainKey = tietBrainKey.text.toString().split(" ") val brainKey = tietBrainKey.text.toString()
if (brainKey.size in 12..16) { if (brainKey.split(" ").size in 12..16)
// TODO verify brainkey verifyBrainKey(false)
} else else
tilBrainKey.error = getString(R.string.error__enter_correct_brainkey) 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<List<UserAccount>>
val accountList: List<UserAccount> = 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<AccountProperties>
if (accountPropertiesList.size > 1) {
val candidates = ArrayList<String>()
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
}
} }

View file

@ -9,6 +9,9 @@ object Constants {
/** Version of the currently used license */ /** Version of the currently used license */
const val CURRENT_LICENSE_VERSION = 1 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 */ /** The minimum required length for a PIN number */
const val MIN_PIN_LENGTH = 6 const val MIN_PIN_LENGTH = 6

View file

@ -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;
}
}

View file

@ -59,7 +59,7 @@
android:layout_marginEnd="@dimen/activity_horizontal_margin" android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:hint="@string/text__brain_key"> android:hint="@string/text__brain_key">
<com.google.android.material.textfield.TextInputEditText <cy.agorise.bitsybitshareswallet.views.MyTextInputEditText
android:id="@+id/tietBrainKey" android:id="@+id/tietBrainKey"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View file

@ -16,6 +16,11 @@
<string name="error__enter_correct_brainkey">Please enter correct brainkey, it should have between 12 and 16 words.</string> <string name="error__enter_correct_brainkey">Please enter correct brainkey, it should have between 12 and 16 words.</string>
<string name="button__import">Import</string> <string name="button__import">Import</string>
<string name="button__create">Create</string> <string name="button__create">Create</string>
<!-- TODO improve below error explanation -->
<string name="error__invalid_brainkey">Invalid account, please check your brainkey for typing errors</string>
<string name="error__try_again">Please try again after 5 minutes</string>
<string name="dialog__account_candidates_title">Please select an account</string>
<string name="dialog__account_candidates_content">The keys derived from this brainkey seem to be used to control more than one account, please select which account you wish to import</string>
<!-- Main Activity --> <!-- Main Activity -->
<string name="title_activity_main">MainActivity</string> <string name="title_activity_main">MainActivity</string>