mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-08-07 01:25:41 +00:00
feat: tell users a new version exists, and let them rate the wallet from inside it (#17)
Two gaps, both invisible until you look for them. There was no update mechanism at all. Someone on an old build stayed on it until they happened to open the Play Store on their own. For a wallet that is worse than an inconvenience: today's multisig signing fix would have reached nobody who was not already looking. InAppUpdates asks Play on every foreground. A user a few days behind gets the flexible flow — the download runs in the background and the wallet stays usable, because interrupting someone mid-transfer to force an update is its own kind of harm. Past fourteen days of staleness, or on a release marked priority 4+ in Play Console, it switches to immediate. onResume finishes an interrupted immediate update and installs a flexible one that completed while the app was backgrounded; without that the first leaves a user stuck and the second never installs. Ratings had the same shape of gap: people who would happily rate the wallet never do, because nothing ever asks. Play's in-app card asks without sending them to the store. Play answers neither "has this user rated" nor "what did they choose" — by design. It also throttles to a handful of showings a year and silently drops the rest. So the gates in AppReviewTracker are not there to avoid nagging, which Play already handles; they exist to spend those few real chances well: three successful operations, three days since first use, ninety since the last ask, and not within two days of an error. Recording sits in RealExtrinsicService, the single point every on-chain action passes through, so transfers and staking are covered without a hook per screen. The tracker swallows everything it touches — a rating counter must never be able to fail a transfer. Both features no-op outside a Play install, so neither can be verified from a Firebase build; that needs an internal testing track. Version 1.2.0.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
package io.novafoundation.nova.common.appstore
|
||||
|
||||
import io.novafoundation.nova.common.data.storage.Preferences
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Decides when to ask for a Play Store review.
|
||||
*
|
||||
* Play gives no way to ask whether someone has already rated, and it throttles the
|
||||
* prompt to a handful of showings per user per year — a call made too often is
|
||||
* silently dropped. So the gates below are not there to avoid nagging, which Play
|
||||
* already handles; they are there to spend the few real chances on a good moment.
|
||||
*
|
||||
* Play policy forbids pre-qualifying ("do you like the app?"), rewarding a review, or
|
||||
* asking for a *positive* one. The prompt has to be unconditional, so the only thing
|
||||
* left to choose is when.
|
||||
*
|
||||
* Nothing here may throw. It is called from the extrinsic submission path, and a
|
||||
* rating counter must never be able to fail a transfer.
|
||||
*/
|
||||
interface AppReviewTracker {
|
||||
|
||||
/** Called after an on-chain operation or a wallet creation succeeds. */
|
||||
fun onMeaningfulSuccess()
|
||||
|
||||
/** Called when the user hits an error, which parks the prompt for a while. */
|
||||
fun onFailure()
|
||||
|
||||
/** True when every gate is satisfied. Ask Play only then. */
|
||||
fun shouldRequestReview(): Boolean
|
||||
|
||||
/** Called once the Play flow has been launched, whatever its outcome. */
|
||||
fun onReviewRequested()
|
||||
}
|
||||
|
||||
private const val KEY_SUCCESSES = "app_review_success_count"
|
||||
private const val KEY_FIRST_SEEN = "app_review_first_seen_at"
|
||||
private const val KEY_LAST_ASKED = "app_review_last_asked_at"
|
||||
private const val KEY_LAST_FAILURE = "app_review_last_failure_at"
|
||||
|
||||
/** Someone with one transfer behind them has no opinion yet. */
|
||||
private const val MIN_SUCCESSES = 3
|
||||
|
||||
/** A first-day user is judging the download, not the wallet. */
|
||||
private val MIN_AGE_MS = TimeUnit.DAYS.toMillis(3)
|
||||
|
||||
/** Play's own quota is roughly this scale; asking more often just wastes attempts. */
|
||||
private val MIN_INTERVAL_MS = TimeUnit.DAYS.toMillis(90)
|
||||
|
||||
/** Long enough that a fresh failure is no longer what the user has in mind. */
|
||||
private val FAILURE_COOLDOWN_MS = TimeUnit.DAYS.toMillis(2)
|
||||
|
||||
class RealAppReviewTracker(
|
||||
private val preferences: Preferences,
|
||||
private val currentTimeMillis: () -> Long = System::currentTimeMillis,
|
||||
) : AppReviewTracker {
|
||||
|
||||
override fun onMeaningfulSuccess() = safely {
|
||||
val now = currentTimeMillis()
|
||||
if (preferences.getLong(KEY_FIRST_SEEN, 0L) == 0L) {
|
||||
preferences.putLong(KEY_FIRST_SEEN, now)
|
||||
}
|
||||
preferences.putInt(KEY_SUCCESSES, preferences.getInt(KEY_SUCCESSES, 0) + 1)
|
||||
}
|
||||
|
||||
override fun onFailure() = safely {
|
||||
preferences.putLong(KEY_LAST_FAILURE, currentTimeMillis())
|
||||
}
|
||||
|
||||
override fun shouldRequestReview(): Boolean {
|
||||
return runCatching {
|
||||
val now = currentTimeMillis()
|
||||
|
||||
val successes = preferences.getInt(KEY_SUCCESSES, 0)
|
||||
if (successes < MIN_SUCCESSES) return@runCatching false
|
||||
|
||||
val firstSeen = preferences.getLong(KEY_FIRST_SEEN, 0L)
|
||||
if (firstSeen == 0L || now - firstSeen < MIN_AGE_MS) return@runCatching false
|
||||
|
||||
val lastAsked = preferences.getLong(KEY_LAST_ASKED, 0L)
|
||||
if (lastAsked != 0L && now - lastAsked < MIN_INTERVAL_MS) return@runCatching false
|
||||
|
||||
val lastFailure = preferences.getLong(KEY_LAST_FAILURE, 0L)
|
||||
if (lastFailure != 0L && now - lastFailure < FAILURE_COOLDOWN_MS) return@runCatching false
|
||||
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
override fun onReviewRequested() = safely {
|
||||
// Written whether or not the user acted on the card: Play does not report the
|
||||
// outcome, and an attempt spends quota either way.
|
||||
preferences.putLong(KEY_LAST_ASKED, currentTimeMillis())
|
||||
}
|
||||
|
||||
private inline fun safely(block: () -> Unit) {
|
||||
runCatching(block)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import io.novafoundation.nova.common.data.repository.BannerVisibilityRepository
|
||||
import io.novafoundation.nova.common.data.repository.ToggleFeatureRepository
|
||||
import io.novafoundation.nova.common.data.secrets.v1.SecretStoreV1
|
||||
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
|
||||
import io.novafoundation.nova.common.appstore.AppReviewTracker
|
||||
import io.novafoundation.nova.common.data.storage.Preferences
|
||||
import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences
|
||||
import io.novafoundation.nova.common.di.modules.Caching
|
||||
@@ -160,6 +161,8 @@ interface CommonApi {
|
||||
|
||||
fun providePreferences(): Preferences
|
||||
|
||||
fun appReviewTracker(): AppReviewTracker
|
||||
|
||||
fun backgroundAccessObserver(): BackgroundAccessObserver
|
||||
|
||||
fun provideEncryptedPreferences(): EncryptedPreferences
|
||||
|
||||
@@ -37,6 +37,8 @@ import io.novafoundation.nova.common.data.repository.ToggleFeatureRepository
|
||||
import io.novafoundation.nova.common.data.secrets.v1.SecretStoreV1
|
||||
import io.novafoundation.nova.common.data.secrets.v1.SecretStoreV1Impl
|
||||
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
|
||||
import io.novafoundation.nova.common.appstore.AppReviewTracker
|
||||
import io.novafoundation.nova.common.appstore.RealAppReviewTracker
|
||||
import io.novafoundation.nova.common.data.storage.Preferences
|
||||
import io.novafoundation.nova.common.data.storage.PreferencesImpl
|
||||
import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences
|
||||
@@ -165,6 +167,10 @@ class CommonModule {
|
||||
return PreferencesImpl(sharedPreferences)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@ApplicationScope
|
||||
fun provideAppReviewTracker(preferences: Preferences): AppReviewTracker = RealAppReviewTracker(preferences)
|
||||
|
||||
@Provides
|
||||
@ApplicationScope
|
||||
fun provideInteractionGate(): AutomaticInteractionGate = RealAutomaticInteractionGate()
|
||||
|
||||
Reference in New Issue
Block a user