Hush lite wallet for Android
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.

236 lines
9.8 KiB

package cash.z.ecc.android.ui.send
import android.content.Context
import android.os.Bundle
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentAutoShieldBinding
import cash.z.ecc.android.ext.goneIf
import cash.z.ecc.android.ext.invisibleIf
import cash.z.ecc.android.ext.requireApplicationContext
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.preference.Preferences
import cash.z.ecc.android.preference.model.get
import cash.z.ecc.android.preference.model.put
import cash.z.ecc.android.sdk.db.entity.*
import cash.z.ecc.android.sdk.ext.collectWith
import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.ecc.android.util.twig
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import java.time.Clock
class AutoShieldFragment : BaseFragment<FragmentAutoShieldBinding>() {
override val screen = Report.Screen.AUTO_SHIELD_FINAL
private val viewModel: AutoShieldViewModel by viewModels()
private val uiModels = MutableStateFlow(UiModel())
override fun inflate(inflater: LayoutInflater): FragmentAutoShieldBinding =
FragmentAutoShieldBinding.inflate(inflater)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (null == savedInstanceState) {
setAutoshield(requireApplicationContext())
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.backButtonHitArea.setOnClickListener {
onExit().also { tapped(Report.Tap.AUTO_SHIELD_FINAL_CLOSE) }
}
mainActivity?.preventBackPress(this)
uiModels.collectWith(lifecycleScope, ::updateUi)
}
override fun onAttach(context: Context) {
super.onAttach(context)
mainActivity?.apply {
viewModel.shieldFunds().onEach { p: PendingTransaction ->
try {
uiModels.value = p.toUiModel()
} catch (t: Throwable) {
val message = "ERROR: error while handling pending transaction update! $t"
twig(message)
mainActivity?.feedback?.report(Report.Error.NonFatal.TxUpdateFailed(t))
mainActivity?.feedback?.report(t)
}
}.launchIn(lifecycleScope)
}
}
private fun updateUi(uiModel: UiModel) = uiModel.apply {
if (isResumed) {
// if this is the first success
if (!binding.lottieSuccess.isVisible && showSuccess) {
mainActivity?.vibrateSuccess()
}
binding.backButton.goneIf(!showCloseIcon)
binding.textTitle.text = title
binding.lottieShielding.invisibleIf(!showShielding)
if (pauseShielding) binding.lottieShielding.pauseAnimation()
binding.lottieSuccess.invisibleIf(!showSuccess)
binding.imageFailed.invisibleIf(!isFailure)
binding.textStatus.text = statusMessage
binding.textStatus.text = when {
showStatusDetails && showStatusMessage -> statusDetails
showStatusDetails -> statusDetails
showStatusMessage -> statusMessage
else -> ""
}
binding.buttonPrimary.text = primaryButtonText
binding.buttonPrimary.setOnClickListener { primaryAction() }
binding.buttonMoreInfo.text = moreInfoButtonText
binding.buttonMoreInfo.goneIf(!showMoreInfoButton)
binding.buttonMoreInfo.setOnClickListener { moreInfoAction() }
if (showSuccess) {
if (viewModel.updateAutoshieldAchievement()) {
mainActivity?.showSnackbar("Achievement unlocked! Golden Zebra.", "View") {
mainActivity?.safeNavigate(R.id.action_nav_shield_final_to_profile)
Toast.makeText(mainActivity, "Your Zebra is now yellow because you are great", Toast.LENGTH_LONG).show()
}
}
}
}
}
private fun onExit() {
mainActivity?.safeNavigate(R.id.action_nav_shield_final_to_nav_home)
}
private fun onCancel(tx: PendingTransaction) {
viewModel.cancel(tx.id)
}
private fun onSeeDetails() {
mainActivity?.safeNavigate(R.id.action_nav_shield_final_to_nav_history)
}
private fun PendingTransaction.toUiModel() = UiModel().also { model ->
when {
isCancelled() -> {
model.title = getString(R.string.send_final_result_cancelled)
model.pauseShielding = true
model.primaryButtonText = getString(R.string.send_final_button_primary_back)
model.primaryAction = { mainActivity?.navController?.popBackStack() }
}
isSubmitSuccess() -> {
model.showCloseIcon = true
model.title = getString(R.string.send_final_button_primary_sent)
model.showShielding = false
model.showSuccess = true
model.primaryButtonText = getString(R.string.done)
model.primaryAction = ::onExit
model.showMoreInfoButton = true
model.moreInfoButtonText = getString(R.string.send_final_button_primary_details)
model.moreInfoAction = ::onSeeDetails
}
isFailure() -> {
model.showCloseIcon = true
model.title =
if (isFailedEncoding()) getString(R.string.send_final_error_encoding) else getString(
R.string.send_final_error_submitting
)
model.showShielding = false
model.showSuccess = false
model.isFailure = true
model.showStatusDetails = false
model.primaryButtonText = getString(R.string.translated_button_back)
model.primaryAction = { mainActivity?.navController?.popBackStack() }
model.showMoreInfoButton = errorMessage != null
model.moreInfoButtonText = getString(R.string.send_more_info)
model.moreInfoAction = {
showMoreInfo(errorMessage ?: "No details available")
}
}
isCreating() -> {
model.showCloseIcon = false
model.primaryButtonText = getString(R.string.send_final_button_primary_cancel)
model.showStatusMessage = true
model.statusMessage = "Creating transaction..."
model.primaryAction = { onCancel(this) }
}
isCreated() -> {
model.showStatusMessage = true
model.statusMessage = "Submitting transaction..."
model.primaryButtonText = getString(R.string.translated_button_back)
model.primaryAction = { mainActivity?.navController?.popBackStack() }
}
else -> {
model.primaryButtonText = getString(R.string.translated_button_back)
model.primaryAction = { mainActivity?.navController?.popBackStack() }
}
}
}
private fun showMoreInfo(info: String) {
val current = uiModels.value
uiModels.value = current.copy(
showMoreInfoButton = true,
moreInfoButtonText = getString(R.string.done),
moreInfoAction = ::onExit,
showStatusMessage = false,
showStatusDetails = true,
statusDetails = info
)
}
// fields are ordered, as they appear, top-to-bottom in the UI because that makes it easier to reason about each screen state
data class UiModel(
var showCloseIcon: Boolean = false,
var title: String = "Shielding Now!",
var showShielding: Boolean = true,
var pauseShielding: Boolean = false,
var showSuccess: Boolean = false,
var isFailure: Boolean = false,
var statusMessage: String = "",
var statusDetails: String = "",
var showStatusDetails: Boolean = false,
var showStatusMessage: Boolean = true,
var primaryButtonText: String = "Cancel",
var primaryAction: () -> Unit = {},
var moreInfoButtonText: String = "",
var showMoreInfoButton: Boolean = false,
var moreInfoAction: () -> Unit = {},
)
companion object {
private const val maxAutoshieldFrequency: Long = 30 * DateUtils.MINUTE_IN_MILLIS
/**
* @param clock Optionally allows injecting a clock, in order to make this testable.
*/
fun canAutoshield(context: Context, clock: Clock = Clock.systemUTC()): Boolean {
val currentEpochMillis = clock.millis()
val lastAutoshieldEpochMillis = Preferences.lastAutoshieldingEpochMillis.get(context)
val isLastAutoshieldOld = (currentEpochMillis - lastAutoshieldEpochMillis) > maxAutoshieldFrequency
// Prevent a corner case where a user with a clock in the future during one autoshielding prompt
// could prevent all subsequent autoshielding prompts.
val isTimeTraveling = lastAutoshieldEpochMillis > currentEpochMillis
return isLastAutoshieldOld || isTimeTraveling
}
/**
* @param clock Optionally allows injecting a clock, in order to make this testable.
*/
private fun setAutoshield(context: Context, clock: Clock = Clock.systemUTC()) =
Preferences.lastAutoshieldingEpochMillis.put(context, clock.millis())
}
}