bitsy-wallet/app/src/main/java/cy/agorise/bitsybitshareswallet/fragments/CreateAccountFragment.kt

401 lines
15 KiB
Kotlin

package cy.agorise.bitsybitshareswallet.fragments
import android.os.Bundle
import android.os.Handler
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.collection.LongSparseArray
import androidx.navigation.fragment.findNavController
import com.afollestad.materialdialogs.MaterialDialog
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.jakewharton.rxbinding3.widget.textChanges
import cy.agorise.bitsybitshareswallet.R
import cy.agorise.bitsybitshareswallet.databinding.FragmentCreateAccountBinding
import cy.agorise.bitsybitshareswallet.models.FaucetRequest
import cy.agorise.bitsybitshareswallet.models.FaucetResponse
import cy.agorise.bitsybitshareswallet.network.FaucetService
import cy.agorise.bitsybitshareswallet.network.ServiceGenerator
import cy.agorise.bitsybitshareswallet.utils.Constants
import cy.agorise.bitsybitshareswallet.utils.containsDigits
import cy.agorise.bitsybitshareswallet.utils.containsVowels
import cy.agorise.bitsybitshareswallet.utils.toast
import cy.agorise.graphenej.Address
import cy.agorise.graphenej.BrainKey
import cy.agorise.graphenej.api.ConnectionStatusUpdate
import cy.agorise.graphenej.api.calls.GetAccountByName
import cy.agorise.graphenej.models.AccountProperties
import cy.agorise.graphenej.models.JsonRpcResponse
import io.reactivex.android.schedulers.AndroidSchedulers
import org.bitcoinj.core.ECKey
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.util.concurrent.TimeUnit
class CreateAccountFragment : BaseAccountFragment() {
companion object {
private const val TAG = "CreateAccountFragment"
private const val BRAINKEY_FILE = "brainkeydict.txt"
private const val MIN_ACCOUNT_NAME_LENGTH = 3
private const val MAX_ACCOUNT_NAME_LENGTH = 16
// Used when trying to validate that the account name is available
private const val RESPONSE_GET_ACCOUNT_BY_NAME_VALIDATION = 1
// Used when trying to obtain the info of the newly created account
private const val RESPONSE_GET_ACCOUNT_BY_NAME_CREATED = 2
}
private var _binding: FragmentCreateAccountBinding? = null
private val binding get() = _binding!!
private lateinit var mAddress: String
/** Variables used to store the validation status of the form fields */
private var isPINValid = false
private var isPINConfirmationValid = false
private var isAccountValidAndAvailable = false
// Map used to keep track of request and response id pairs
private val responseMap = LongSparseArray<Int>()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
setHasOptionsMenu(true)
_binding = FragmentCreateAccountBinding.inflate(inflater, container, false)
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val crashlytics = FirebaseCrashlytics.getInstance()
crashlytics.setCustomKey(Constants.CRASHLYTICS_KEY_LAST_SCREEN, TAG)
// Use RxJava Debounce to check the validity and availability of the user's proposed account name
mDisposables.add(
binding.tietAccountName.textChanges()
.skipInitialValue()
.debounce(800, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ validateAccountName(it.toString()) },
{ crashlytics.log("D/$TAG: ${it.message}") }
)
)
// Use RxJava Debounce to update the PIN error only after the user stops writing for > 500 ms
mDisposables.add(
binding.tietPin.textChanges()
.skipInitialValue()
.debounce(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ validatePIN() },
{ crashlytics.log("D/$TAG: ${it.message}") }
)
)
// Use RxJava Debounce to update the PIN Confirmation error only after the user stops writing for > 500 ms
mDisposables.add(
binding.tietPinConfirmation.textChanges()
.skipInitialValue()
.debounce(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ validatePINConfirmation() },
{ crashlytics.log("D/$TAG: ${it.message}") }
)
)
binding.btnCancel.setOnClickListener { findNavController().navigateUp() }
binding.btnCreate.isEnabled = false
binding.btnCreate.setOnClickListener { createAccount() }
// Generating BrainKey
generateKeys()
}
private fun validateAccountName(accountName: String) {
isAccountValidAndAvailable = false
if (!isAccountLengthValid(accountName)) {
binding.tilAccountName.helperText = ""
binding.tilAccountName.error = getString(R.string.error__invalid_account_length)
} else if (!isAccountStartValid(accountName)) {
binding.tilAccountName.helperText = ""
binding.tilAccountName.error = getString(R.string.error__invalid_account_start)
} else if (!isAccountNameValid(accountName)) {
binding.tilAccountName.helperText = ""
binding.tilAccountName.error = getString(R.string.error__invalid_account_name)
} else {
binding.tilAccountName.isErrorEnabled = false
binding.tilAccountName.helperText =
getString(R.string.text__verifying_account_availability)
val id = mNetworkService?.sendMessage(
GetAccountByName(accountName),
GetAccountByName.REQUIRED_API
)
if (id != null)
responseMap.append(id, RESPONSE_GET_ACCOUNT_BY_NAME_VALIDATION)
}
enableDisableCreateButton()
}
/**
* Verifies if the account length is valid, so that the faucet does not pay for a premium account.
*/
private fun isAccountLengthValid(accountName: String): Boolean {
return accountName.length in MIN_ACCOUNT_NAME_LENGTH..MAX_ACCOUNT_NAME_LENGTH
}
/**
* Verifies if the account start is valid, the account name should start with a letter.
*/
private fun isAccountStartValid(accountName: String): Boolean {
return accountName[0].isLetter()
}
/**
* Method used to determine if the account name entered by the user is valid
* @param accountName The proposed account name
* @return True if the name is valid, false otherwise
*/
private fun isAccountNameValid(accountName: String): Boolean {
return accountName.contains("-") ||
accountName.containsDigits() ||
!accountName.containsVowels()
}
private fun validatePIN() {
val pin = binding.tietPin.text.toString()
if (pin.length < Constants.MIN_PIN_LENGTH) {
binding.tilPin.error = getString(R.string.error__pin_too_short)
isPINValid = false
} else {
binding.tilPin.isErrorEnabled = false
isPINValid = true
}
validatePINConfirmation()
}
private fun validatePINConfirmation() {
val pinConfirmation = binding.tietPinConfirmation.text.toString()
if (pinConfirmation != binding.tietPin.text.toString()) {
binding.tilPinConfirmation.error = getString(R.string.error__pin_mismatch)
isPINConfirmationValid = false
} else {
binding.tilPinConfirmation.isErrorEnabled = false
isPINConfirmationValid = true
}
enableDisableCreateButton()
}
private fun enableDisableCreateButton() {
binding.btnCreate.isEnabled =
(isPINValid && isPINConfirmationValid && isAccountValidAndAvailable)
}
override fun handleJsonRpcResponse(response: JsonRpcResponse<*>) {
if (responseMap.containsKey(response.id)) {
when (responseMap[response.id]) {
RESPONSE_GET_ACCOUNT_BY_NAME_VALIDATION -> handleAccountNameValidation(response.result)
RESPONSE_GET_ACCOUNT_BY_NAME_CREATED -> handleAccountNameCreated(response.result)
}
responseMap.remove(response.id)
}
}
override fun handleConnectionStatusUpdate(connectionStatusUpdate: ConnectionStatusUpdate) {}
/**
* Handles the response from the NetworkService's GetAccountByName call to decide if the user's suggested
* account is available or not.
*/
private fun handleAccountNameValidation(result: Any?) {
if (result is AccountProperties) {
binding.tilAccountName.helperText = ""
binding.tilAccountName.error = getString(R.string.error__account_not_available)
isAccountValidAndAvailable = false
} else {
binding.tilAccountName.isErrorEnabled = false
binding.tilAccountName.helperText = getString(R.string.text__account_is_available)
isAccountValidAndAvailable = true
}
enableDisableCreateButton()
}
/**
* Handles the response from the NetworkService's GetAccountByName call and stores the information of the newly
* created account if the result is successful, shows a toast error otherwise
*/
private fun handleAccountNameCreated(result: Any?) {
if (result is AccountProperties) {
onAccountSelected(result, binding.tietPin.text.toString())
} else {
context?.toast(getString(R.string.error__created_account_not_found))
setStateError()
}
}
/**
* Sets the state to Loading, when the app is trying to create an account and waiting for the response.
*/
private fun setStateLoading() {
binding.btnCancel.isEnabled = false
binding.btnCreate.isEnabled = false
binding.progressBar.visibility = View.VISIBLE
}
/**
* Sets the state to Error, when the app is unable to create the account or unable to retrieve
* the information from the newly created account.
*/
private fun setStateError() {
binding.btnCancel.isEnabled = true
binding.btnCreate.isEnabled = false
binding.progressBar.visibility = View.GONE
}
/**
* Sends the account-creation request to the faucet server.
* Only account name and public address is sent here.
*/
private fun createAccount() {
setStateLoading()
val accountName = binding.tietAccountName.text.toString()
val faucetRequest = FaucetRequest(accountName, mAddress, Constants.FAUCET_REFERRER)
val sg = ServiceGenerator(Constants.FAUCET_URL)
val faucetService = sg.getService(FaucetService::class.java)
val call = faucetService?.registerPrivateAccount(faucetRequest)
// Execute the call asynchronously. Get a positive or negative callback.
call?.enqueue(object : Callback<FaucetResponse> {
override fun onResponse(
call: Call<FaucetResponse>,
response: Response<FaucetResponse>
) {
// The network call was a success and we got a response, obtain the info of the newly created account
// with a delay to let the nodes update their information
val handler = Handler()
handler.postDelayed({
getCreatedAccountInfo(response.body())
}, 4000)
}
override fun onFailure(call: Call<FaucetResponse>, t: Throwable) {
// the network call was a failure
context?.let { context ->
MaterialDialog(context).show {
title(R.string.title_error)
message(R.string.error__faucet)
negativeButton(android.R.string.ok)
}
}
setStateError()
}
})
}
private fun getCreatedAccountInfo(faucetResponse: FaucetResponse?) {
if (faucetResponse?.account != null) {
val id = mNetworkService?.sendMessage(
GetAccountByName(faucetResponse.account?.name),
GetAccountByName.REQUIRED_API
)
if (id != null)
responseMap.append(id, RESPONSE_GET_ACCOUNT_BY_NAME_CREATED)
} else {
Log.d(TAG, "Private account creation failed ")
val content = if (faucetResponse?.error?.base?.size ?: 0 > 0) {
getString(R.string.error__faucet_template, faucetResponse?.error?.base?.get(0))
} else {
getString(R.string.error__faucet_template, "None")
}
context?.let { context ->
MaterialDialog(context)
.title(R.string.title_error)
.message(text = content)
.show()
}
setStateError()
}
}
/**
* Method that generates a fresh key that will be controlling the newly created account.
*/
private fun generateKeys() {
var reader: BufferedReader? = null
val dictionary: String
try {
reader =
BufferedReader(InputStreamReader(context!!.assets.open(BRAINKEY_FILE), "UTF-8"))
dictionary = reader.readLine()
val brainKeySuggestion = BrainKey.suggest(dictionary)
mBrainKey = BrainKey(brainKeySuggestion, 0)
val address = Address(ECKey.fromPublicOnly(mBrainKey?.privateKey?.pubKey))
Log.d(TAG, "brain key: $brainKeySuggestion")
Log.d(TAG, "address would be: $address")
mAddress = address.toString()
binding.tvBrainKey.text = mBrainKey?.brainKey
} catch (e: IOException) {
Log.e(TAG, "IOException while trying to generate key. Msg: " + e.message)
context?.toast(getString(R.string.error__read_dict_file))
findNavController().navigateUp()
} catch (e: IllegalArgumentException) {
val crashlytics = FirebaseCrashlytics.getInstance()
crashlytics.recordException(e)
// TODO if this does happen to real devices, use a proper error message
context?.toast(getString(R.string.error__try_again))
findNavController().navigateUp()
} finally {
if (reader != null) {
try {
reader.close()
} catch (e: IOException) {
Log.e(
TAG,
"IOException while trying to close BufferedReader. Msg: " + e.message
)
}
}
}
}
}