You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
This repo is archived. You can view files and clone it, but cannot push or open issues/pull-requests.

669 lines
24 KiB

package cash.z.ecc.android.ui
import android.Manifest
import android.app.Dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.media.MediaPlayer
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Vibrator
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.annotation.IdRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager.Authenticators.*
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.*
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.NavDirections
import androidx.navigation.Navigator
import androidx.navigation.findNavController
import cash.z.ecc.android.R
import cash.z.ecc.android.ZcashWalletApp
import cash.z.ecc.android.databinding.DialogFirstUseMessageBinding
import cash.z.ecc.android.di.DependenciesHolder
import cash.z.ecc.android.ext.*
import cash.z.ecc.android.feedback.Feedback
import cash.z.ecc.android.feedback.FeedbackCoordinator
import cash.z.ecc.android.feedback.LaunchMetric
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.Error.NonFatal.Reorg
import cash.z.ecc.android.feedback.Report.NonUserAction.FEEDBACK_STOPPED
import cash.z.ecc.android.feedback.Report.NonUserAction.SYNC_START
import cash.z.ecc.android.feedback.Report.Tap.COPY_ADDRESS
import cash.z.ecc.android.feedback.Report.Tap.COPY_TRANSPARENT_ADDRESS
import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException
import cash.z.ecc.android.sdk.ext.BatchMetrics
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.ext.toAbbreviatedAddress
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.ui.history.HistoryViewModel
import cash.z.ecc.android.ui.util.MemoUtil
import cash.z.ecc.android.util.twig
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity(R.layout.main_activity) {
val mainViewModel: MainViewModel by viewModels()
val feedback: Feedback = DependenciesHolder.feedback
val feedbackCoordinator: FeedbackCoordinator = DependenciesHolder.feedbackCoordinator
val clipboard: ClipboardManager = DependenciesHolder.clipboardManager
val historyViewModel: HistoryViewModel by viewModels()
private var syncStarted = false
private val mediaPlayer: MediaPlayer = MediaPlayer()
private var snackbar: Snackbar? = null
private var dialog: Dialog? = null
private var ignoreScanFailure: Boolean = false
var navController: NavController? = null
private val navInitListeners: MutableList<() -> Unit> = mutableListOf()
private val hasCameraPermission
get() = ContextCompat.checkSelfPermission(
this,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
val latestHeight: BlockHeight?
get() = DependenciesHolder.synchronizer.latestHeight
override fun onCreate(savedInstanceState: Bundle?) {
lifecycleScope.launch {
feedback.start()
}
super.onCreate(savedInstanceState)
initNavigation()
initLoadScreen()
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
window.setFlags(
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
)
setWindowFlag(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS, false)
setWindowFlag(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION, false)
}
override fun onResume() {
super.onResume()
// keep track of app launch metrics
// (how long does it take the app to open when it is not already in the foreground)
ZcashWalletApp.instance.let { app ->
if (!app.creationMeasured) {
app.creationMeasured = true
feedback.report(LaunchMetric())
}
}
}
override fun onDestroy() {
lifecycleScope.launch {
feedback.report(FEEDBACK_STOPPED)
feedback.stop()
}
super.onDestroy()
}
private fun setWindowFlag(bits: Int, on: Boolean) {
val win = window
val winParams = win.attributes
if (on) {
winParams.flags = winParams.flags or bits
} else {
winParams.flags = winParams.flags and bits.inv()
}
win.attributes = winParams
}
private fun initNavigation() {
navController = findNavController(R.id.nav_host_fragment)
navController!!.addOnDestinationChangedListener { _, _, _ ->
// hide the keyboard anytime we change destinations
getSystemService<InputMethodManager>()?.hideSoftInputFromWindow(
this@MainActivity.window.decorView.rootView.windowToken,
InputMethodManager.HIDE_NOT_ALWAYS
)
}
for (listener in navInitListeners) {
listener()
}
navInitListeners.clear()
}
private fun initLoadScreen() {
lifecycleScope.launchWhenResumed {
mainViewModel.loadingMessage.collect { message ->
onLoadingMessage(message)
}
}
}
private fun onLoadingMessage(message: String?) {
twig("Applying loading message: $message")
// TODO: replace with view binding
findViewById<View>(R.id.container_loading).goneIf(message == null)
findViewById<TextView>(R.id.text_message).text = message
}
fun popBackTo(@IdRes destination: Int, inclusive: Boolean = false) {
navController?.popBackStack(destination, inclusive)
}
fun safeNavigate(navDirections: NavDirections) =
safeNavigate(navDirections.actionId, navDirections.arguments, null)
fun safeNavigate(
@IdRes destination: Int,
args: Bundle? = null,
extras: Navigator.Extras? = null
) {
if (navController == null) {
navInitListeners.add {
try {
navController?.navigate(destination, args, null, extras)
} catch (t: Throwable) {
twig(
"WARNING: during callback, did not navigate to destination: R.id.${
resources.getResourceEntryName(
destination
)
} due to: $t"
)
}
}
} else {
try {
navController?.navigate(destination, args, null, extras)
} catch (t: Throwable) {
twig(
"WARNING: did not immediately navigate to destination: R.id.${
resources.getResourceEntryName(
destination
)
} due to: $t"
)
}
}
}
fun startSync(isRestart: Boolean = false) {
twig("MainActivity.startSync")
if (!syncStarted || isRestart) {
syncStarted = true
mainViewModel.setLoading(true)
feedback.report(SYNC_START)
DependenciesHolder.synchronizer.let { synchronizer ->
synchronizer.onProcessorErrorHandler = ::onProcessorError
synchronizer.onChainErrorHandler = ::onChainError
synchronizer.onCriticalErrorHandler = ::onCriticalError
(synchronizer as SdkSynchronizer).processor.onScanMetricCompleteListener =
::onScanMetricComplete
synchronizer.start(lifecycleScope)
mainViewModel.setSyncReady(true)
}
} else {
twig("Ignoring request to start sync because sync has already been started!")
}
mainViewModel.setLoading(false)
twig("MainActivity.startSync COMPLETE")
}
private fun onScanMetricComplete(batchMetrics: BatchMetrics, isComplete: Boolean) {
val reportingThreshold = 100
if (isComplete) {
if (batchMetrics.cumulativeItems > reportingThreshold) {
val network = DependenciesHolder.synchronizer.network.networkName
reportAction(
Report.Performance.ScanRate(
network,
batchMetrics.cumulativeItems.toInt(),
batchMetrics.cumulativeTime,
batchMetrics.cumulativeIps
)
)
}
}
}
private fun onCriticalError(error: Throwable?): Boolean {
val errorMessage = error?.message
?: error?.cause?.message
?: error?.toString()
?: "A critical error has occurred but no details were provided. Please report and consider submitting logs to help track this one down."
showCriticalMessage(
title = "Unrecoverable Error",
message = errorMessage,
) {
throw error ?: RuntimeException("A critical error occurred but it was null")
}
return false
}
fun reportScreen(screen: Report.Screen?) = reportAction(screen)
fun reportTap(tap: Report.Tap?) = reportAction(tap)
fun reportFunnel(step: Feedback.Funnel?) = reportAction(step)
private fun reportAction(action: Feedback.Action?) {
action?.let { feedback.report(it) }
}
fun setLoading(isLoading: Boolean, message: String? = null) {
mainViewModel.setLoading(isLoading, message)
}
fun authenticate(
description: String,
title: String = getString(R.string.biometric_prompt_title),
block: () -> Unit
) {
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
twig("Authentication success with type: ${if (result.authenticationType == AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL) "DEVICE_CREDENTIAL" else if (result.authenticationType == AUTHENTICATION_RESULT_TYPE_BIOMETRIC) "BIOMETRIC" else "UNKNOWN"} object: ${result.cryptoObject}")
block()
twig("Done authentication block")
// we probably only need to do this if the type is DEVICE_CREDENTIAL
// but it doesn't hurt to hide the keyboard every time
hideKeyboard()
}
override fun onAuthenticationFailed() {
twig("Authentication failed!!!!")
showMessage("Authentication failed :(")
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
twig("Authentication Error")
fun doNothing(message: String, interruptUser: Boolean = true) {
if (interruptUser) {
showSnackbar(message)
} else {
showMessage(message, true)
}
}
when (errorCode) {
ERROR_HW_NOT_PRESENT, ERROR_HW_UNAVAILABLE,
ERROR_NO_BIOMETRICS, ERROR_NO_DEVICE_CREDENTIAL -> {
twig("Warning: bypassing authentication because $errString [$errorCode]")
showMessage(
"Please enable screen lock on this device to add security here!",
true
)
block()
}
ERROR_LOCKOUT -> doNothing("Too many attempts. Try again in 30s.")
ERROR_LOCKOUT_PERMANENT -> doNothing("Whoa. Waaaay too many attempts!")
ERROR_CANCELED -> doNothing("I just can't right now. Please try again.")
ERROR_NEGATIVE_BUTTON -> doNothing("Authentication cancelled", false)
ERROR_USER_CANCELED -> doNothing("Cancelled", false)
ERROR_NO_SPACE -> doNothing("Not enough storage space!")
ERROR_TIMEOUT -> doNothing("Oops. It timed out.")
ERROR_UNABLE_TO_PROCESS -> doNothing(".")
ERROR_VENDOR -> doNothing("We got some weird error and you should report this.")
else -> {
twig("Warning: unrecognized authentication error $errorCode")
doNothing("Authentication failed with error code $errorCode")
}
}
}
}
BiometricPrompt(this, ContextCompat.getMainExecutor(this), callback).apply {
authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setConfirmationRequired(false)
.setDescription(description)
.setAllowedAuthenticators(BIOMETRIC_STRONG or BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
.build()
)
}
}
fun playSound(fileName: String) {
mediaPlayer.apply {
if (isPlaying) stop()
try {
reset()
assets.openFd(fileName).let { afd ->
setDataSource(afd.fileDescriptor, afd.startOffset, afd.length)
}
prepare()
start()
} catch (t: Throwable) {
Log.e("SDK_ERROR", "ERROR: unable to play sound due to $t")
}
}
}
// TODO: spruce this up with API 26 stuff
fun vibrateSuccess() = vibrate(0, 200, 200, 100, 100, 800)
fun vibrate(initialDelay: Long, vararg durations: Long) {
val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
if (vibrator.hasVibrator()) {
vibrator.vibrate(longArrayOf(initialDelay, *durations), -1)
}
}
fun copyAddress(view: View? = null) {
reportTap(COPY_ADDRESS)
lifecycleScope.launch {
copyText(DependenciesHolder.synchronizer.getAddress(), "Address")
}
}
fun copyTransparentAddress(view: View? = null) {
reportTap(COPY_TRANSPARENT_ADDRESS)
lifecycleScope.launch {
copyText(DependenciesHolder.synchronizer.getTransparentAddress(), "T-Address")
}
}
fun copyText(textToCopy: String, label: String = "ECC Wallet Text") {
clipboard.setPrimaryClip(
ClipData.newPlainText(label, textToCopy)
)
showMessage("$label copied!")
vibrate(0, 50)
}
fun shareText(textToShare: String) {
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, textToShare)
}
val shareIntent = Intent.createChooser(sendIntent, null)
startActivity(shareIntent)
}
suspend fun isValidAddress(address: String): Boolean {
try {
return !DependenciesHolder.synchronizer.validateAddress(address).isNotValid
} catch (t: Throwable) {
}
return false
}
fun preventBackPress(fragment: Fragment) {
onFragmentBackPressed(fragment) {}
}
fun onFragmentBackPressed(fragment: Fragment, block: () -> Unit) {
onBackPressedDispatcher.addCallback(
fragment,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
block()
}
}
)
}
private fun showMessage(message: String, linger: Boolean = false) {
twig("toast: $message")
Toast.makeText(this, message, if (linger) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show()
}
fun showSnackbar(
message: String,
actionLabel: String = getString(android.R.string.ok),
action: () -> Unit = {}
): Snackbar {
return if (snackbar == null) {
val view = findViewById<View>(R.id.main_activity_container)
val snacks = Snackbar
.make(view, "$message", Snackbar.LENGTH_INDEFINITE)
.setAction(actionLabel) { action() }
val snackBarView = snacks.view as ViewGroup
val navigationBarHeight = resources.getDimensionPixelSize(
resources.getIdentifier(
"navigation_bar_height",
"dimen",
"android"
)
)
val params = snackBarView.getChildAt(0).layoutParams as ViewGroup.MarginLayoutParams
params.setMargins(
params.leftMargin,
params.topMargin,
params.rightMargin,
navigationBarHeight
)
snackBarView.getChildAt(0).setLayoutParams(params)
snacks
} else {
snackbar!!.setText(message).setAction(actionLabel) { action() }
}.also {
if (!it.isShownOrQueued) it.show()
}
}
fun showKeyboard(focusedView: View) {
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(focusedView, InputMethodManager.SHOW_FORCED)
}
fun hideKeyboard() {
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(findViewById<View>(android.R.id.content).windowToken, 0)
}
/**
* @param popUpToInclusive the destination to remove from the stack before opening the camera.
* This only takes effect in the common case where the permission is granted.
*/
fun maybeOpenScan(popUpToInclusive: Int? = null) {
if (hasCameraPermission) {
openCamera(popUpToInclusive)
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
requestPermissions(arrayOf(Manifest.permission.CAMERA), 101)
} else {
onNoCamera()
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 101) {
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
openCamera()
} else {
onNoCamera()
}
}
}
private fun openCamera(popUpToInclusive: Int? = null) {
navController?.navigate(popUpToInclusive ?: R.id.action_global_nav_scan)
}
private fun onNoCamera() {
showSnackbar(getString(R.string.camera_permission_denied))
}
// TODO: clean up this error handling
private var ignoredErrors = 0
private fun onProcessorError(error: Throwable?): Boolean {
var notified = false
when (error) {
is CompactBlockProcessorException.Uninitialized -> {
if (dialog == null) {
notified = true
runOnUiThread {
dialog = showUninitializedError(error) {
dialog = null
}
}
}
}
is CompactBlockProcessorException.FailedScan -> {
if (dialog == null && !ignoreScanFailure) throttle("scanFailure", 20_000L) {
notified = true
runOnUiThread {
dialog = showScanFailure(
error,
onCancel = { dialog = null },
onDismiss = { dialog = null }
)
}
}
}
}
if (!notified) {
ignoredErrors++
if (ignoredErrors >= ZcashSdk.RETRIES) {
if (dialog == null) {
notified = true
runOnUiThread {
dialog = showCriticalProcessorError(error) {
dialog = null
}
}
}
}
}
twig("MainActivity has received an error${if (notified) " and notified the user" else ""} and reported it to bugsnag and mixpanel.")
feedback.report(error)
return true
}
private fun onChainError(errorHeight: BlockHeight, rewindHeight: BlockHeight) {
feedback.report(Reorg(errorHeight, rewindHeight))
}
// TODO: maybe move this quick helper code somewhere general or throttle the dialogs differently (like with a flow and stream operators, instead)
private val throttles = mutableMapOf<String, () -> Any>()
private val noWork = {}
private fun throttle(key: String, delay: Long, block: () -> Any) {
// if the key exists, just add the block to run later and exit
if (throttles.containsKey(key)) {
throttles[key] = block
return
}
block()
// after doing the work, check back in later and if another request came in, throttle it, otherwise exit
throttles[key] = noWork
findViewById<View>(android.R.id.content).postDelayed(
{
throttles[key]?.let { pendingWork ->
throttles.remove(key)
if (pendingWork !== noWork) throttle(key, delay, pendingWork)
}
},
delay
)
}
/* Memo functions that might possibly get moved to MemoUtils */
suspend fun getSender(transaction: ConfirmedTransaction?): String {
if (transaction == null) return getString(R.string.unknown)
return MemoUtil.findAddressInMemo(transaction, ::isValidAddress)?.toAbbreviatedAddress()
?: getString(R.string.unknown)
}
suspend fun String?.validateAddress(): String? {
if (this == null) return null
return if (isValidAddress(this)) this else null
}
fun showFirstUseWarning(
prefKey: String,
@StringRes titleResId: Int = R.string.blank,
@StringRes msgResId: Int = R.string.blank,
@StringRes positiveResId: Int = android.R.string.ok,
@StringRes negativeResId: Int = android.R.string.cancel,
action: MainActivity.() -> Unit = {}
) {
historyViewModel.prefs.getBoolean(prefKey).let { doNotWarnAgain ->
if (doNotWarnAgain) {
action()
return@showFirstUseWarning
}
}
val dialogViewBinding = DialogFirstUseMessageBinding.inflate(layoutInflater)
fun savePref() {
dialogViewBinding.dialogFirstUseCheckbox.isChecked.let { wasChecked ->
historyViewModel.prefs.setBoolean(prefKey, wasChecked)
}
}
dialogViewBinding.dialogMessage.setText(msgResId)
if (dialog != null) dialog?.dismiss()
// TODO: This should be moved to a DialogFragment, otherwise unmanaged dialogs go away during Activity configuration changes
dialog = MaterialAlertDialogBuilder(this)
.setTitle(titleResId)
.setView(dialogViewBinding.root)
.setCancelable(false)
.setPositiveButton(positiveResId) { d, _ ->
d.dismiss()
dialog = null
savePref()
action()
}
.setNegativeButton(negativeResId) { d, _ ->
d.dismiss()
dialog = null
savePref()
}
.show()
}
fun onLaunchUrl(url: String) {
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
} catch (t: Throwable) {
showMessage(getString(R.string.error_launch_url))
twig("Warning: failed to open browser due to $t")
}
}
}