Merge pull request #173 from MAGICGrants/review-prompt
What changed, and why it matters
This commit adds an in-app store review prompt. After a successful cryptocurrency send, it marks the user as eligible, and the next time they open the wallet home screen it may ask for a Google Play or App Store rating. The code deliberately keeps Google Play's review library out of F-Droid/GitHub builds by using a build-time flag and separate source folders, so those builds remain free of Play-specific code.
No security patch needed. If reviewing further, verify that the F-Droid recipe indeed removes `play-store.gradle` and `src/play/`, and confirm the APK build does not pass `-PplayStore=true`. Also consider whether prompting after a financial transaction is appropriate UX and whether it could be perceived as coercive.
Security signals we found
Third-party SDK inclusion gated by build flavor (Google Play only)
Install-source check before invoking Play review API
F-Droid reproducible-build compatibility via source-set exclusion and recipe deletion
No handling of cryptographic secrets or transaction data in review code
Potential minor privacy signal: Play/App Store review API call reveals app usage timing to platform operator
Evidence from the diff
The change introduces a StoreReview method channel (org.magicgrants.wallet/store_review) implemented natively on Android and iOS. On Android it uses ReviewManagerFactory only for Play Store builds (-PplayStore=true), guarded by PackageManager.getInstallSourceInfo/getInstallerPackageName checking for com.android.vending. F-Droid/GitHub builds compile a no-op StoreReview from src/foss/. On iOS it uses StoreKit.requestReview(in:). Dart side calls StoreReview.markEligible() after a successful send and StoreReview.requestIfDue() on wallet home launch, with a stillAppropriate guard to avoid prompting after navigation away. The release workflow passes -PplayStore=true only for the App Bundle build, not for APKs.
Changed components
Android build configuration (build.gradle.kts, play-store.gradle)Android MainActivity.kt method channel handlerAndroid StoreReview.kt (play and foss variants)iOS AppDelegate.swift StoreKit review handlerlib/screens/send.dartlib/screens/wallet_home.dart.github/workflows/release.ymlInspect captured patch +165 / −1
### .github/workflows/release.yml
@@ -150,7 +150,7 @@ jobs:
-w /tmp/skylight \
-e SOURCE_DATE_EPOCH \
ghcr.io/magicgrants/skylight-wallet-builder:latest \
- bash -c "cp -a /opt/cargo /tmp/skylight-cargo && export CARGO_HOME=/tmp/skylight-cargo && export PUB_CACHE=/tmp/skylight/.pub-cache && flutter pub get --enforce-lockfile && bash scripts/pin-rust-toolchain.sh && flutter build apk --dart-define=DEMO_MODE=true --release --split-per-abi && flutter build appbundle --dart-define=DEMO_MODE=true --release"
+ bash -c "cp -a /opt/cargo /tmp/skylight-cargo && export CARGO_HOME=/tmp/skylight-cargo && export PUB_CACHE=/tmp/skylight/.pub-cache && flutter pub get --enforce-lockfile && bash scripts/pin-rust-toolchain.sh && flutter build apk --dart-define=DEMO_MODE=true --release --split-per-abi && flutter build appbundle --dart-define=DEMO_MODE=true --release -PplayStore=true"
mkdir -p dist
cp -v build/app/outputs/flutter-apk/app-arm64-v8a-release.apk "dist/skylight-wallet-${VERSION}-arm64-v8a.apk"
### android/app/build.gradle.kts
@@ -9,6 +9,19 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
+// The Google Play build, and only it, links Play's in-app review library. The
+// F-Droid recipe and the GitHub APKs run `flutter build apk` without this
+// property, so they carry no Play code at all: F-Droid's reproducible build has
+// to match the published APK byte for byte, so a runtime check alone could not
+// keep the library out. The release workflow passes -PplayStore=true to
+// `flutter build appbundle` alone.
+//
+// Everything Play-specific sits in play-store.gradle and src/play/, which
+// the F-Droid recipe deletes before building (rm:), so its scanner never meets
+// a Play library -- and the APK it rebuilds is unchanged, since neither is used
+// without the property.
+val playStoreBuild = (findProperty("playStore") as String?) == "true"
+
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
@@ -35,6 +48,13 @@ android {
jvmTarget = JavaVersion.VERSION_17.toString()
}
+ // One StoreReview or the other: Play's review flow, or a no-op.
+ sourceSets {
+ getByName("main") {
+ java.srcDir(if (playStoreBuild) "src/play/kotlin" else "src/foss/kotlin")
+ }
+ }
+
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "org.magicgrants.skylight"
@@ -90,6 +110,10 @@ dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
+if (playStoreBuild) {
+ apply(from = "play-store.gradle")
+}
+
flutter {
source = "../.."
}
### android/app/play-store.gradle
@@ -0,0 +1,8 @@
+// Google Play build only: applied from build.gradle.kts under -PplayStore=true,
+// alongside the StoreReview in src/play/. The F-Droid recipe removes both.
+//
+// Groovy rather than Kotlin script: Android Lint fails on a second .gradle.kts
+// in the module ("Cannot find a KaModule for the VirtualFile"), applied or not.
+dependencies {
+ implementation 'com.google.android.play:review:2.0.2'
+}
### android/app/src/foss/kotlin/org/magicgrants/skylight/StoreReview.kt
@@ -0,0 +1,14 @@
+package org.magicgrants.skylight
+
+import android.app.Activity
+import android.content.Context
+
+// F-Droid and GitHub build: no Google Play library is compiled in, so there is
+// no store to ask and no code that could reach Play. See build.gradle.kts.
+object StoreReview {
+ @Suppress("UNUSED_PARAMETER")
+ fun isAvailable(context: Context): Boolean = false
+
+ @Suppress("UNUSED_PARAMETER")
+ fun request(activity: Activity, reply: (Boolean) -> Unit) = reply(false)
+}
### android/app/src/main/kotlin/com/example/monero_light_wallet/MainActivity.kt
@@ -13,6 +13,11 @@ class MainActivity : FlutterFragmentActivity() {
// App-neutral name shared with wallet-core's SecureClipboard (D10).
private val secureClipboardChannel = "org.magicgrants.wallet/secure_clipboard"
+ // App-neutral name shared with wallet-core's StoreReview. Which StoreReview
+ // this resolves to -- Play's review flow or a no-op -- is chosen at build
+ // time; see build.gradle.kts.
+ private val storeReviewChannel = "org.magicgrants.wallet/store_review"
+
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, secureClipboardChannel)
@@ -31,6 +36,14 @@ class MainActivity : FlutterFragmentActivity() {
else -> result.notImplemented()
}
}
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, storeReviewChannel)
+ .setMethodCallHandler { call, result ->
+ when (call.method) {
+ "isAvailable" -> result.success(StoreReview.isAvailable(this))
+ "requestReview" -> StoreReview.request(this) { result.success(it) }
+ else -> result.notImplemented()
+ }
+ }
}
// Copies [text] flagged as sensitive so keyboards/clipboard UIs don't show a
### android/app/src/play/kotlin/org/magicgrants/skylight/StoreReview.kt
@@ -0,0 +1,46 @@
+package org.magicgrants.skylight
+
+import android.app.Activity
+import android.content.Context
+import android.os.Build
+import com.google.android.play.core.review.ReviewManagerFactory
+
+// Google Play build: the in-app review flow, for installs Google Play made.
+// Compiled in only when the build passes -PplayStore=true; see build.gradle.kts.
+object StoreReview {
+ private const val PLAY_STORE = "com.android.vending"
+
+ // Whether Google Play installed this app. A local package-manager lookup:
+ // nothing here talks to Play, and everything that would is gated on it. The
+ // same bundle is attached to GitHub releases, so a copy installed by hand
+ // lands here too -- and must not reach Play.
+ fun isAvailable(context: Context): Boolean = installerOf(context) == PLAY_STORE
+
+ // Replies true once Play has been asked, whatever it then showed: Play
+ // reports neither whether the card appeared nor whether the user rated.
+ fun request(activity: Activity, reply: (Boolean) -> Unit) {
+ if (!isAvailable(activity)) {
+ reply(false)
+ return
+ }
+ val manager = ReviewManagerFactory.create(activity)
+ manager.requestReviewFlow().addOnCompleteListener { request ->
+ if (!request.isSuccessful) {
+ reply(true)
+ return@addOnCompleteListener
+ }
+ manager.launchReviewFlow(activity, request.result).addOnCompleteListener { reply(true) }
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun installerOf(context: Context): String? = try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ context.packageManager.getInstallSourceInfo(context.packageName).installingPackageName
+ } else {
+ context.packageManager.getInstallerPackageName(context.packageName)
+ }
+ } catch (_: Exception) {
+ null
+ }
+}
### ios/Runner/AppDelegate.swift
@@ -1,10 +1,12 @@
import Flutter
+import StoreKit
import UIKit
import workmanager_apple
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var secureClipboardChannel: FlutterMethodChannel?
+ private var storeReviewChannel: FlutterMethodChannel?
override func application(
_ application: UIApplication,
@@ -68,5 +70,41 @@ import workmanager_apple
}
secureClipboardChannel = channel
}
+
+ let reviewRegistrar = engineBridge.pluginRegistry.registrar(forPlugin: "StoreReview")
+ if let messenger = reviewRegistrar?.messenger() {
+ // App-neutral name shared with wallet-core's StoreReview. iOS builds only
+ // ship through App Store Connect, and in TestFlight the request is a
+ // no-op by Apple's design, so there is no install source to check.
+ let channel = FlutterMethodChannel(
+ name: "org.magicgrants.wallet/store_review",
+ binaryMessenger: messenger
+ )
+ channel.setMethodCallHandler { call, reply in
+ switch call.method {
+ case "isAvailable":
+ reply(true)
+ case "requestReview":
+ reply(Self.requestReview())
+ default:
+ reply(FlutterMethodNotImplemented)
+ }
+ }
+ storeReviewChannel = channel
+ }
+ }
+
+ /// Hands the request to StoreKit's own prompt, which decides whether to show
+ /// it (at most three times a year) and never says whether it did.
+ private static func requestReview() -> Bool {
+ let scene = UIApplication.shared.connectedScenes
+ .first { $0.activationState == .foregroundActive } as? UIWindowScene
+ guard let scene else { return false }
+ if #available(iOS 16.0, *) {
+ AppStore.requestReview(in: scene)
+ } else {
+ SKStoreReviewController.requestReview(in: scene)
+ }
+ return true
}
}
### lib/screens/send.dart
@@ -1,8 +1,11 @@
+import 'dart:async';
+
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart';
import 'package:wallet_domain/wallet_domain.dart' show baseUnitsToDecimalString, decimalToBaseUnits;
+import 'package:wallet_infra/wallet_infra.dart' show StoreReview;
import 'package:skylight_wallet/consts.dart' as consts;
import 'package:skylight_wallet/l10n/app_localizations.dart';
@@ -537,6 +540,10 @@ class _SendScreenState extends State<SendScreen> {
onConfirm: () => _commitTx(tx, destinationAddress),
);
+ if (committed == true) {
+ // The send went through: ask for a store review on a later launch.
+ unawaited(StoreReview.markEligible());
+ }
if (committed == true && mounted) {
Navigator.pushNamed(context, '/wallet_home', arguments: {'showTxSuccessToast': true});
}
### lib/screens/wallet_home.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:intl/intl.dart';
@@ -15,6 +17,7 @@ import 'package:skylight_wallet/wallet_core_glue.dart';
import 'package:skylight_wallet/widgets/ui/ui.dart';
import 'package:skylight_wallet/widgets/wallet_navigation_bar.dart';
import 'package:wallet_domain/wallet_domain.dart' show TxDetails;
+import 'package:wallet_infra/wallet_infra.dart' show StoreReview;
/// The balance hero's big number style, mirroring Spice's `coin_home.dart`.
TextStyle get _balanceStyle => TextStyle(
@@ -95,9 +98,20 @@ class _WalletHomeScreenState extends State<WalletHomeScreen> {
if (args != null && args['showTxSuccessToast'] == true) {
_showTxSuccessToast();
}
+
+ // Opening the app after a send is when a store review is asked for;
+ // StoreReview decides whether one is due, once per launch.
+ unawaited(StoreReview.requestIfDue(stillAppropriate: _stillOnHome));
});
}
+ // Still the screen in front, with the app in the foreground: the review
+ // dialog must not land on a send or receive the user has already opened.
+ bool _stillOnHome() =>
+ mounted &&
+ (ModalRoute.of(context)?.isCurrent ?? false) &&
+ WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed;
+
void _showTxDetails(TxDetails txDetails) {
showTxDetailsDialog(context, txDetails);
}Why this scored 23/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.