From b6642387ba5c7d3c066cb71e6eb4d711200b956c Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Mon, 3 Aug 2026 04:19:52 -0700 Subject: [PATCH] feat: tell users a new version exists, and let them rate the wallet from inside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/build.gradle | 3 + .../nova/app/root/di/RootDependencies.kt | 3 + .../app/root/presentation/RootActivity.kt | 22 ++++ .../presentation/update/AppReviewPrompt.kt | 45 ++++++++ .../root/presentation/update/InAppUpdates.kt | 109 ++++++++++++++++++ build.gradle | 5 +- .../nova/common/appstore/AppReviewTracker.kt | 99 ++++++++++++++++ .../nova/common/di/CommonApi.kt | 3 + .../nova/common/di/modules/CommonModule.kt | 6 + distribution/whatsnew/whatsnew-en-US | 6 +- distribution/whatsnew/whatsnew-ku | 6 +- distribution/whatsnew/whatsnew-tr-TR | 6 +- .../data/extrinsic/RealExtrinsicService.kt | 9 +- .../extrinsic/RealExtrinsicServiceFactory.kt | 5 +- .../di/AccountFeatureDependencies.kt | 3 + .../di/AccountFeatureModule.kt | 9 +- 16 files changed, 331 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/io/novafoundation/nova/app/root/presentation/update/AppReviewPrompt.kt create mode 100644 app/src/main/java/io/novafoundation/nova/app/root/presentation/update/InAppUpdates.kt create mode 100644 common/src/main/java/io/novafoundation/nova/common/appstore/AppReviewTracker.kt diff --git a/app/build.gradle b/app/build.gradle index fe5f4538..f4f7f53a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -181,6 +181,9 @@ dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation project(':core-db') implementation project(':common') + + implementation playReview + implementation playAppUpdate implementation project(':feature-splash') implementation project(':feature-onboarding-api') diff --git a/app/src/main/java/io/novafoundation/nova/app/root/di/RootDependencies.kt b/app/src/main/java/io/novafoundation/nova/app/root/di/RootDependencies.kt index 0861f027..6d03c419 100644 --- a/app/src/main/java/io/novafoundation/nova/app/root/di/RootDependencies.kt +++ b/app/src/main/java/io/novafoundation/nova/app/root/di/RootDependencies.kt @@ -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 } diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/RootActivity.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/RootActivity.kt index 4160cc7b..b0b6ef42 100644 --- a/app/src/main/java/io/novafoundation/nova/app/root/presentation/RootActivity.kt +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/RootActivity.kt @@ -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(), SplashBackgroundHolder { @@ -35,6 +38,12 @@ class RootActivity : BaseActivity(), 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(), 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) { diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/update/AppReviewPrompt.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/update/AppReviewPrompt.kt new file mode 100644 index 00000000..d7de20d3 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/update/AppReviewPrompt.kt @@ -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) } + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/update/InAppUpdates.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/update/InAppUpdates.kt new file mode 100644 index 00000000..1b5e1d2a --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/update/InAppUpdates.kt @@ -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) } + } +} diff --git a/build.gradle b/build.gradle index 9e68957a..9f38709a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { // App version - versionName = '1.1.2' + versionName = '1.2.0' versionCode = 1 applicationId = "io.pezkuwichain.wallet" @@ -226,6 +226,9 @@ buildscript { playIntegrity = "com.google.android.play:integrity:1.4.0" + playReview = "com.google.android.play:review-ktx:2.0.2" + playAppUpdate = "com.google.android.play:app-update-ktx:2.1.0" + lottie = "com.airbnb.android:lottie:6.6.6" } diff --git a/common/src/main/java/io/novafoundation/nova/common/appstore/AppReviewTracker.kt b/common/src/main/java/io/novafoundation/nova/common/appstore/AppReviewTracker.kt new file mode 100644 index 00000000..23d000a8 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/appstore/AppReviewTracker.kt @@ -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) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/CommonApi.kt b/common/src/main/java/io/novafoundation/nova/common/di/CommonApi.kt index 60d5d0f1..d7b54e4a 100644 --- a/common/src/main/java/io/novafoundation/nova/common/di/CommonApi.kt +++ b/common/src/main/java/io/novafoundation/nova/common/di/CommonApi.kt @@ -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 diff --git a/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonModule.kt b/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonModule.kt index 083b72bc..796543ea 100644 --- a/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonModule.kt +++ b/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonModule.kt @@ -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() diff --git a/distribution/whatsnew/whatsnew-en-US b/distribution/whatsnew/whatsnew-en-US index 3297c731..938508e1 100644 --- a/distribution/whatsnew/whatsnew-en-US +++ b/distribution/whatsnew/whatsnew-en-US @@ -1 +1,5 @@ -Pezkuwi Wallet — the official app for the Digital Kurdistan State. Manage your HEZ tokens, stake, and participate in on-chain governance. \ No newline at end of file +Multisig approvals work again on Pezkuwi chains. Signers could open an operation but not approve it — that is fixed. + +The wallet now tells you when a newer version is available and installs it without leaving the app. + +You can also rate the wallet from inside the app, after you have actually used it. diff --git a/distribution/whatsnew/whatsnew-ku b/distribution/whatsnew/whatsnew-ku index 8d302ec5..09444cff 100644 --- a/distribution/whatsnew/whatsnew-ku +++ b/distribution/whatsnew/whatsnew-ku @@ -1 +1,5 @@ -Pezkuwi Wallet — ئەپی فەرمی دەوڵەتی دیجیتاڵی کوردستان. تۆکنەکانی HEZ بەڕێوەببە، ستەیک بکە و بەشداری بەڕێوەبردنی زنجیرە بکە. \ No newline at end of file +Pesendkirina multisig li ser zincîrên Pezkuwî dîsa dixebite. Îmzeker dikaribûn karê vekin lê pesend nekin; ev hate çareserkirin. + +Berîk niha dema guhertoyeke nû derkeve agahdar dike û nûvekirinê bêyî derketina ji sepanê saz dike. + +Hûn dikarin berîkê ji hundirê sepanê, piştî ku bi rastî bi kar anî, nirxînin. diff --git a/distribution/whatsnew/whatsnew-tr-TR b/distribution/whatsnew/whatsnew-tr-TR index 9346dccd..d8154a05 100644 --- a/distribution/whatsnew/whatsnew-tr-TR +++ b/distribution/whatsnew/whatsnew-tr-TR @@ -1 +1,5 @@ -Pezkuwi Wallet — Dijital Kürdistan Devleti'nin resmi uygulaması. HEZ tokenlarınızı yönetin, stake edin ve zincir üstü yönetime katılın. \ No newline at end of file +Pezkuwi zincirlerinde multisig onayı yeniden çalışıyor. İmzacılar işlemi açabiliyor ama onaylayamıyordu; düzeltildi. + +Cüzdan artık yeni sürüm çıktığında haber veriyor ve güncellemeyi uygulamadan çıkmadan kuruyor. + +Cüzdanı, gerçekten kullandıktan sonra uygulama içinden puanlayabilirsiniz. diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/RealExtrinsicService.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/RealExtrinsicService.kt index 19b1092e..ba1db38d 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/RealExtrinsicService.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/RealExtrinsicService.kt @@ -1,6 +1,7 @@ package io.novafoundation.nova.feature_account_impl.data.extrinsic import android.util.Log +import io.novafoundation.nova.common.appstore.AppReviewTracker import io.novafoundation.nova.common.data.network.runtime.binding.DispatchError import io.novafoundation.nova.common.data.network.runtime.binding.bindDispatchError import io.novafoundation.nova.common.data.network.runtime.model.FeeResponse @@ -74,6 +75,7 @@ class RealExtrinsicService( private val feePaymentProviderRegistry: FeePaymentProviderRegistry, private val eventsRepository: EventsRepository, private val signingContextFactory: SigningContext.Factory, + private val appReviewTracker: AppReviewTracker, private val coroutineScope: CoroutineScope? // TODO: Make it non-nullable ) : ExtrinsicService { @@ -86,8 +88,13 @@ class RealExtrinsicService( val (extrinsic, submissionOrigin, _, callExecutionType, signingHierarchy) = buildSubmissionExtrinsic(chain, origin, formExtrinsic, submissionOptions) val hash = rpcCalls.submitExtrinsic(chain.id, extrinsic) + // The one place every on-chain action passes through, so counting here covers + // transfers, staking and the rest without a hook per screen. The tracker never + // throws — a rating counter must not be able to fail a submission. + appReviewTracker.onMeaningfulSuccess() + ExtrinsicSubmission(hash, submissionOrigin, callExecutionType, signingHierarchy) - } + }.onFailure { appReviewTracker.onFailure() } override suspend fun submitMultiExtrinsicAwaitingInclusion( chain: Chain, diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/RealExtrinsicServiceFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/RealExtrinsicServiceFactory.kt index 63a44a1e..cef5ff5c 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/RealExtrinsicServiceFactory.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/RealExtrinsicServiceFactory.kt @@ -1,5 +1,6 @@ package io.novafoundation.nova.feature_account_impl.data.extrinsic +import io.novafoundation.nova.common.appstore.AppReviewTracker import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSplitter import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry @@ -21,6 +22,7 @@ class RealExtrinsicServiceFactory( private val eventsRepository: EventsRepository, private val feePaymentProviderRegistry: FeePaymentProviderRegistry, private val signingContextFactory: SigningContext.Factory, + private val appReviewTracker: AppReviewTracker, ) : ExtrinsicService.Factory { override fun create(feeConfig: ExtrinsicService.FeePaymentConfig): ExtrinsicService { @@ -35,7 +37,8 @@ class RealExtrinsicServiceFactory( feePaymentProviderRegistry = registry, eventsRepository = eventsRepository, coroutineScope = feeConfig.coroutineScope, - signingContextFactory = signingContextFactory + signingContextFactory = signingContextFactory, + appReviewTracker = appReviewTracker ) } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureDependencies.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureDependencies.kt index f9d5f94a..06157975 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureDependencies.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureDependencies.kt @@ -12,6 +12,7 @@ import io.novafoundation.nova.common.data.network.NetworkApiCreator import io.novafoundation.nova.common.data.network.rpc.SocketSingleRequestExecutor 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 @@ -148,6 +149,8 @@ interface AccountFeatureDependencies { fun preferences(): Preferences + fun appReviewTracker(): AppReviewTracker + fun encryptedPreferences(): EncryptedPreferences fun resourceManager(): ResourceManager diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt index 353b27ac..3650c596 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt @@ -4,6 +4,7 @@ import com.google.gson.Gson import dagger.Binds import dagger.Module import dagger.Provides +import io.novafoundation.nova.common.appstore.AppReviewTracker import io.novafoundation.nova.common.address.AddressIconGenerator import io.novafoundation.nova.common.data.mappers.mapEncryptionToCryptoType import io.novafoundation.nova.common.data.network.AppLinksProvider @@ -254,7 +255,8 @@ class AccountFeatureModule { extrinsicSplitter: ExtrinsicSplitter, feePaymentProviderRegistry: FeePaymentProviderRegistry, eventsRepository: EventsRepository, - signingContextFactory: SigningContext.Factory + signingContextFactory: SigningContext.Factory, + appReviewTracker: AppReviewTracker, ): ExtrinsicService.Factory = RealExtrinsicServiceFactory( rpcCalls, chainRegistry, @@ -264,7 +266,8 @@ class AccountFeatureModule { extrinsicSplitter, eventsRepository, feePaymentProviderRegistry, - signingContextFactory + signingContextFactory, + appReviewTracker ) @Provides @@ -279,6 +282,7 @@ class AccountFeatureModule { feePaymentProviderRegistry: FeePaymentProviderRegistry, eventsRepository: EventsRepository, signingContextFactory: SigningContext.Factory, + appReviewTracker: AppReviewTracker, ): ExtrinsicService = RealExtrinsicService( rpcCalls, chainRegistry, @@ -289,6 +293,7 @@ class AccountFeatureModule { feePaymentProviderRegistry, eventsRepository, signingContextFactory, + appReviewTracker, coroutineScope = null )