This commit is contained in:
SatoshiQaziMuhammed
2026-08-03 04:20:13 -07:00
committed by GitHub
16 changed files with 331 additions and 8 deletions
+3
View File
@@ -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')
@@ -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) }
}
}
+4 -1
View File
@@ -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"
}
@@ -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()
+5 -1
View File
@@ -1 +1,5 @@
Pezkuwi Wallet — the official app for the Digital Kurdistan State. Manage your HEZ tokens, stake, and participate in on-chain governance.
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.
+5 -1
View File
@@ -1 +1,5 @@
Pezkuwi Wallet — ئەپی فەرمی دەوڵەتی دیجیتاڵی کوردستان. تۆکنەکانی HEZ بەڕێوەببە، ستەیک بکە و بەشداری بەڕێوەبردنی زنجیرە بکە.
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.
+5 -1
View File
@@ -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.
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.
@@ -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,
@@ -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
)
}
@@ -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
@@ -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
)