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:
2026-08-03 04:52:07 -07:00
committed by GitHub
parent 56c37615a0
commit 27aff873a6
16 changed files with 331 additions and 8 deletions
@@ -42,6 +42,7 @@ import io.novafoundation.nova.feature_dapp_api.data.repository.BrowserTabExterna
import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository
import io.novafoundation.nova.feature_dapp_api.di.deeplinks.DAppDeepLinks
import io.novafoundation.nova.feature_deep_linking.presentation.handling.PendingDeepLinkProvider
import io.novafoundation.nova.common.appstore.AppReviewTracker
import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkingPreferences
import io.novafoundation.nova.feature_gift_api.di.GiftDeepLinks
import io.novafoundation.nova.feature_governance_api.data.MutableGovernanceState
@@ -186,4 +187,6 @@ interface RootDependencies {
fun chainMigrationRepository(): ChainMigrationRepository
fun migrationInfoRepository(): MigrationInfoRepository
fun appReviewTracker(): AppReviewTracker
}
@@ -22,6 +22,9 @@ import io.novafoundation.nova.common.view.dialog.dialog
import io.novafoundation.nova.feature_push_notifications.presentation.multisigsWarning.observeEnableMultisigPushesAlert
import io.novafoundation.nova.splash.presentation.SplashBackgroundHolder
import io.novafoundation.nova.app.root.presentation.update.AppReviewPrompt
import io.novafoundation.nova.app.root.presentation.update.InAppUpdates
import io.novafoundation.nova.common.appstore.AppReviewTracker
import javax.inject.Inject
class RootActivity : BaseActivity<RootViewModel, ActivityRootBinding>(), SplashBackgroundHolder {
@@ -35,6 +38,12 @@ class RootActivity : BaseActivity<RootViewModel, ActivityRootBinding>(), SplashB
@Inject
lateinit var contextManager: ContextManager
@Inject
lateinit var appReviewTracker: AppReviewTracker
private val inAppUpdates by lazy { InAppUpdates(this) }
private val appReviewPrompt by lazy { AppReviewPrompt(this, appReviewTracker) }
override fun createBinding(): ActivityRootBinding {
return ActivityRootBinding.inflate(LayoutInflater.from(this))
}
@@ -104,6 +113,19 @@ class RootActivity : BaseActivity<RootViewModel, ActivityRootBinding>(), SplashB
super.onStart()
viewModel.noticeInForeground()
// Both are no-ops outside a Play install, and both swallow their own failures:
// neither an update check nor a rating card may keep the wallet from opening.
inAppUpdates.checkForUpdate()
appReviewPrompt.requestIfEarned()
}
override fun onResume() {
super.onResume()
// Finishes an immediate update that was interrupted, and installs a flexible one
// that finished downloading while the app was in the background.
inAppUpdates.resumeIfNeeded()
}
override fun subscribe(viewModel: RootViewModel) {
@@ -0,0 +1,45 @@
package io.novafoundation.nova.app.root.presentation.update
import android.app.Activity
import android.util.Log
import com.google.android.play.core.review.ReviewManagerFactory
import io.novafoundation.nova.common.appstore.AppReviewTracker
/**
* Shows Play's native rating card, in-app, when the tracker says the moment is right.
*
* Play decides the rest: whether the user has already rated, and whether their quota
* allows another showing. Neither is visible to us, and neither is reported back — the
* flow reports only that it finished, not what the user did. So there is nothing to
* branch on afterwards, and nothing to record beyond "we spent an attempt".
*
* Failure is silent by design. A rating prompt that surfaces an error is worse than no
* prompt at all.
*/
private const val LOG_TAG = "AppReviewPrompt"
class AppReviewPrompt(
private val activity: Activity,
private val tracker: AppReviewTracker,
) {
fun requestIfEarned() {
if (!tracker.shouldRequestReview()) return
runCatching {
val manager = ReviewManagerFactory.create(activity)
manager.requestReviewFlow()
.addOnSuccessListener { info ->
runCatching {
manager.launchReviewFlow(activity, info)
.addOnCompleteListener {
// Completion says the flow ended, not that a review was
// left. Record either way: the attempt is what Play counts.
tracker.onReviewRequested()
}
}.onFailure { Log.w(LOG_TAG, "Could not launch review flow", it) }
}
.addOnFailureListener { Log.w(LOG_TAG, "Review flow unavailable", it) }
}.onFailure { Log.w(LOG_TAG, "Review manager unavailable", it) }
}
}
@@ -0,0 +1,109 @@
package io.novafoundation.nova.app.root.presentation.update
import android.app.Activity
import android.util.Log
import com.google.android.play.core.appupdate.AppUpdateManager
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.appupdate.AppUpdateOptions
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.InstallStatus
import com.google.android.play.core.install.model.UpdateAvailability
/**
* Tells the user a newer version exists, and installs it without leaving the app.
*
* There was no such mechanism before: someone on an old build stayed on it until they
* happened to open the Play Store. For a wallet that is worse than an inconvenience —
* a signing fix reaches nobody until they go looking for it.
*
* Two flows, chosen by how far behind the user is:
*
* - FLEXIBLE downloads in the background and keeps the wallet usable, then asks to
* restart. This is the default, because interrupting someone mid-transfer to force
* an update is its own kind of harm.
* - IMMEDIATE blocks until the update is installed. Reserved for releases marked
* high priority in Play Console, and for users who have ignored a flexible prompt
* long enough that staleness alone justifies it.
*
* Only works when Play installed the app. On a Firebase or sideloaded build every call
* here resolves to "no update available" — that is the API's design, not a failure, so
* this must never surface an error to the user.
*/
private const val LOG_TAG = "InAppUpdates"
/** Beyond this, a flexible prompt has clearly been ignored and the update is forced. */
private const val IMMEDIATE_AFTER_STALENESS_DAYS = 14
/** Play Console marks security-relevant releases at 4+; those are not optional. */
private const val IMMEDIATE_AT_PRIORITY = 4
const val REQUEST_CODE_APP_UPDATE = 4711
class InAppUpdates(private val activity: Activity) {
private val manager: AppUpdateManager by lazy { AppUpdateManagerFactory.create(activity) }
/**
* Ask Play whether a newer version exists and start the appropriate flow.
*
* Silent on every failure path: no store, no network, no Play install. A wallet
* that cannot check for updates must still open.
*/
fun checkForUpdate() {
runCatching {
manager.appUpdateInfo
.addOnSuccessListener { info ->
runCatching {
if (info.updateAvailability() != UpdateAvailability.UPDATE_AVAILABLE) return@runCatching
val staleness = info.clientVersionStalenessDays() ?: 0
val forced = staleness >= IMMEDIATE_AFTER_STALENESS_DAYS ||
info.updatePriority() >= IMMEDIATE_AT_PRIORITY
val type = when {
forced && info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) -> AppUpdateType.IMMEDIATE
info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) -> AppUpdateType.FLEXIBLE
info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) -> AppUpdateType.IMMEDIATE
else -> return@runCatching
}
manager.startUpdateFlowForResult(
info,
activity,
AppUpdateOptions.newBuilder(type).build(),
REQUEST_CODE_APP_UPDATE
)
}.onFailure { Log.w(LOG_TAG, "Could not start update flow", it) }
}
.addOnFailureListener { Log.w(LOG_TAG, "Update check failed", it) }
}.onFailure { Log.w(LOG_TAG, "Update manager unavailable", it) }
}
/**
* Finish an interrupted IMMEDIATE update, and install a FLEXIBLE one that finished
* downloading while the app was backgrounded. Call from onResume.
*
* Without this an immediate update that was interrupted leaves the user on a screen
* they cannot get past, and a completed flexible download never installs.
*/
fun resumeIfNeeded() {
runCatching {
manager.appUpdateInfo.addOnSuccessListener { info ->
runCatching {
when {
info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS -> {
manager.startUpdateFlowForResult(
info,
activity,
AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(),
REQUEST_CODE_APP_UPDATE
)
}
info.installStatus() == InstallStatus.DOWNLOADED -> manager.completeUpdate()
}
}.onFailure { Log.w(LOG_TAG, "Could not resume update", it) }
}
}.onFailure { Log.w(LOG_TAG, "Update resume unavailable", it) }
}
}