Skip to content

Commit f574859

Browse files
committed
feat: implement in-app review prompt and add "Rate TapSense" option in settings
- Introduced a new "Rate TapSense" option in the settings that opens the Play Store review page directly. - Integrated the Play In-App Review API to prompt users for a review after their second successful tap test. - Added necessary data persistence for tracking tap test success count and review request status. - Updated localization for the new settings option across multiple languages.
1 parent 47648a3 commit f574859

24 files changed

Lines changed: 315 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ with the `0.1.0` release.
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- Sample app: a "Rate TapSense" row in Settings, opening the Play Store listing directly
12+
(`market:` URI, falling back to the web listing). Separately, the Play In-App Review API
13+
(`com.google.android.play:review:2.0.2`) is now requested at most once per install, after the
14+
user's second successful tap test - a real signal they got value from the app's core promise,
15+
without asking on the very first (possibly just curious) success. The request/launch itself is
16+
fire-and-forget per Google's guidance (the API never reports whether the dialog was shown or
17+
reviewed), and Google's own quota is still the final word on whether it actually appears.
18+
919
### Fixed
1020

1121
- Sample app: changing the app's language via **Settings → Apps → TapSense → Language** (the

app/build.gradle.kts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ android {
2727
applicationId = "com.tapsense.app"
2828
minSdk = 26
2929
targetSdk = 36
30-
versionCode = 6
30+
versionCode = 7
3131
versionName = "1.0.0"
3232

3333
testInstrumentationRunner = "com.tapsense.app.HiltTestRunner"
@@ -145,6 +145,9 @@ dependencies {
145145

146146
implementation(libs.kotlinx.coroutines.android)
147147
implementation(libs.kotlinx.serialization.json)
148+
// FakeReviewManager (used for BuildConfig.DEBUG in InAppReviewLauncher.kt) ships inside this
149+
// same artifact as of 2.0.x - no separate review-testing artifact exists for this version.
150+
implementation(libs.play.review)
148151

149152
testImplementation(libs.junit)
150153
testImplementation(libs.mockk)

app/src/main/kotlin/com/tapsense/app/data/settings/TapSenseSettings.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ data class TapSenseSettings(
1919
val hapticsEnabled: Boolean = true,
2020
val reduceMotion: Boolean = false,
2121
val appearanceMode: AppearanceMode = AppearanceMode.SYSTEM,
22+
val tapTestSuccessCount: Int = 0,
23+
val reviewFlowRequested: Boolean = false,
2224
) {
2325
val hasManualPhoneOverride: Boolean
2426
get() = selectedPhoneManufacturer != null && selectedPhoneModel != null

app/src/main/kotlin/com/tapsense/app/data/settings/TapSenseSettingsRepository.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
44
import androidx.datastore.preferences.core.Preferences
55
import androidx.datastore.preferences.core.booleanPreferencesKey
66
import androidx.datastore.preferences.core.edit
7+
import androidx.datastore.preferences.core.intPreferencesKey
78
import androidx.datastore.preferences.core.stringPreferencesKey
89
import com.nfclocator.core.domain.model.FormFactor
910
import kotlinx.coroutines.flow.Flow
@@ -33,6 +34,8 @@ class TapSenseSettingsRepository @Inject constructor(
3334
appearanceMode = prefs[Keys.APPEARANCE_MODE]
3435
?.let { runCatching { AppearanceMode.valueOf(it) }.getOrNull() }
3536
?: AppearanceMode.SYSTEM,
37+
tapTestSuccessCount = prefs[Keys.TAP_TEST_SUCCESS_COUNT] ?: 0,
38+
reviewFlowRequested = prefs[Keys.REVIEW_FLOW_REQUESTED] ?: false,
3639
)
3740
}
3841

@@ -69,6 +72,34 @@ class TapSenseSettingsRepository @Inject constructor(
6972
dataStore.edit { it[Keys.APPEARANCE_MODE] = mode.name }
7073
}
7174

75+
/**
76+
* Records one more successful tap test and reports whether *this* success is the moment to
77+
* request an in-app review: the running count just reached [reviewTriggerCount] for the first
78+
* time, and the flow has never been requested before on this install. The count keeps
79+
* incrementing past the trigger (so it stays a true lifetime count, not capped at the
80+
* threshold), but [Keys.REVIEW_FLOW_REQUESTED] latches to true the moment eligibility is
81+
* reported, permanently ruling out every later call - the review flow is only ever requested
82+
* once per install, matching Google's own guidance not to over-ask (see
83+
* [com.tapsense.app.util.requestInAppReviewSafely]).
84+
*
85+
* Both the increment and the latch happen inside one [DataStore.edit] transaction so a caller
86+
* can't observe a count bump without the accompanying requested-flag update, or vice versa.
87+
*/
88+
suspend fun recordTapTestSuccessAndCheckReviewEligibility(reviewTriggerCount: Int): Boolean {
89+
var eligible = false
90+
dataStore.edit { prefs ->
91+
val updatedCount = (prefs[Keys.TAP_TEST_SUCCESS_COUNT] ?: 0) + 1
92+
prefs[Keys.TAP_TEST_SUCCESS_COUNT] = updatedCount
93+
94+
val alreadyRequested = prefs[Keys.REVIEW_FLOW_REQUESTED] ?: false
95+
eligible = !alreadyRequested && updatedCount >= reviewTriggerCount
96+
if (eligible) {
97+
prefs[Keys.REVIEW_FLOW_REQUESTED] = true
98+
}
99+
}
100+
return eligible
101+
}
102+
72103
private object Keys {
73104
val ONBOARDING_COMPLETED = booleanPreferencesKey("onboarding_completed")
74105
val PHONE_MANUFACTURER = stringPreferencesKey("selected_phone_manufacturer")
@@ -77,5 +108,7 @@ class TapSenseSettingsRepository @Inject constructor(
77108
val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled")
78109
val REDUCE_MOTION = booleanPreferencesKey("reduce_motion")
79110
val APPEARANCE_MODE = stringPreferencesKey("appearance_mode")
111+
val TAP_TEST_SUCCESS_COUNT = intPreferencesKey("tap_test_success_count")
112+
val REVIEW_FLOW_REQUESTED = booleanPreferencesKey("review_flow_requested")
80113
}
81114
}

app/src/main/kotlin/com/tapsense/app/ui/navigation/TapSenseNavHost.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import com.tapsense.app.ui.taptest.TapTestRoute
2727
import com.tapsense.app.ui.troubleshoot.TroubleshootRoute
2828
import com.tapsense.app.util.PRIVACY_POLICY_URL
2929
import com.tapsense.app.util.openNfcSettingsSafely
30+
import com.tapsense.app.util.openPlayStoreListingSafely
3031
import com.tapsense.app.util.sendFeedbackEmailSafely
3132
import com.tapsense.app.util.openUrlSafely
3233

@@ -126,6 +127,7 @@ fun TapSenseNavHost(
126127
navController.navigate(TapSenseDestinations.TROUBLESHOOT)
127128
},
128129
onContactSupportClick = { context.sendFeedbackEmailSafely() },
130+
onRateAppClick = { context.openPlayStoreListingSafely() },
129131
onPrivacyClick = { context.openUrlSafely(PRIVACY_POLICY_URL) },
130132
)
131133
}

app/src/main/kotlin/com/tapsense/app/ui/settings/SettingsScreen.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ fun SettingsRoute(
3838
onOpenNfcSettings: () -> Unit,
3939
onHelpCenterClick: () -> Unit,
4040
onContactSupportClick: () -> Unit,
41+
onRateAppClick: () -> Unit,
4142
onPrivacyClick: () -> Unit,
4243
modifier: Modifier = Modifier,
4344
viewModel: SettingsViewModel = hiltViewModel(),
@@ -53,6 +54,7 @@ fun SettingsRoute(
5354
onOpenNfcSettings = onOpenNfcSettings,
5455
onHelpCenterClick = onHelpCenterClick,
5556
onContactSupportClick = onContactSupportClick,
57+
onRateAppClick = onRateAppClick,
5658
onPrivacyClick = onPrivacyClick,
5759
onHapticsChange = viewModel::setHapticsEnabled,
5860
onReduceMotionChange = viewModel::setReduceMotion,
@@ -70,6 +72,7 @@ private fun SettingsScreen(
7072
onOpenNfcSettings: () -> Unit,
7173
onHelpCenterClick: () -> Unit,
7274
onContactSupportClick: () -> Unit,
75+
onRateAppClick: () -> Unit,
7376
onPrivacyClick: () -> Unit,
7477
onHapticsChange: (Boolean) -> Unit,
7578
onReduceMotionChange: (Boolean) -> Unit,
@@ -148,6 +151,10 @@ private fun SettingsScreen(
148151
Text("", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
149152
}
150153
HorizontalDivider()
154+
SettingsRow(label = stringResource(R.string.settings_rate_app), onClick = onRateAppClick) {
155+
Text("", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
156+
}
157+
HorizontalDivider()
151158
SettingsRow(label = stringResource(R.string.settings_privacy), onClick = onPrivacyClick) {
152159
Text("", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
153160
}

app/src/main/kotlin/com/tapsense/app/ui/taptest/TapTestScreen.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import com.tapsense.app.ui.theme.tapSenseFilled
5050
import com.tapsense.app.ui.theme.tapSenseOutlined
5151
import com.tapsense.app.ui.theme.tapSenseOutlinedBorder
5252
import com.tapsense.app.util.openNfcSettingsSafely
53+
import com.tapsense.app.util.requestInAppReviewSafely
5354

5455
/**
5556
* The real "does this tap zone actually work" flow: registers as a live NFC reader
@@ -71,6 +72,7 @@ fun TapTestRoute(
7172
val activity = context as? Activity
7273
val hapticFeedback = LocalHapticFeedback.current
7374
val hapticsEnabled by viewModel.hapticsEnabled.collectAsState()
75+
val reviewFlowEligible by viewModel.reviewFlowEligible.collectAsState()
7476

7577
DisposableEffect(activity) {
7678
if (activity != null) {
@@ -89,6 +91,15 @@ fun TapTestRoute(
8991
}
9092
}
9193

94+
// Fires at most once per install (see TapTestViewModel.onTagDetected) - the dismissible
95+
// system review sheet itself, not this screen, is what the user actually sees or ignores.
96+
LaunchedEffect(reviewFlowEligible) {
97+
if (reviewFlowEligible) {
98+
activity?.requestInAppReviewSafely()
99+
viewModel.onReviewFlowRequested()
100+
}
101+
}
102+
92103
TapTestScreen(
93104
uiState = uiState,
94105
antennaState = antennaState,

app/src/main/kotlin/com/tapsense/app/ui/taptest/TapTestViewModel.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ import javax.inject.Inject
2828
private const val DEFAULT_TIMEOUT_MILLIS = 25_000L
2929
private const val TAG = "TapTestViewModel"
3030

31+
/**
32+
* Ask for a review after the *second* successful tap test, not the first - the first success may
33+
* just be onboarding curiosity, while a second success is a real signal the user got value from
34+
* the app's core promise (a tap zone that actually works).
35+
*/
36+
private const val REVIEW_TRIGGER_TAP_TEST_SUCCESS_COUNT = 2
37+
3138
/**
3239
* Drives the Tap Test screen's state machine. [startListening]/[stopListening] are the only
3340
* methods that touch the real `NfcAdapter.enableReaderMode` boundary (via
@@ -67,6 +74,15 @@ class TapTestViewModel @Inject constructor(
6774
/** The resolved device's marker, shown behind the Ready/Detecting content - matches Home/My Phone/Tap Guide. */
6875
val antennaState: StateFlow<AntennaLocatorUiState?> = _antennaState.asStateFlow()
6976

77+
private val _reviewFlowEligible = MutableStateFlow(false)
78+
/**
79+
* One-shot signal that this is the moment to fire the Play In-App Review flow (see
80+
* [onTagDetected]). The screen observes this, launches the OS flow with its `Activity`, and
81+
* calls [onReviewFlowRequested] to consume it - the boolean going back to `false` is what
82+
* stops the same signal from re-firing on the next recomposition (e.g. after a rotation).
83+
*/
84+
val reviewFlowEligible: StateFlow<Boolean> = _reviewFlowEligible.asStateFlow()
85+
7086
val hapticsEnabled: StateFlow<Boolean> = settingsRepository.settings
7187
.map { it.hapticsEnabled }
7288
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
@@ -121,6 +137,19 @@ class TapTestViewModel @Inject constructor(
121137
fun onTagDetected() {
122138
timeoutJob?.cancel()
123139
_uiState.value = TapTestUiState.Detected
140+
viewModelScope.launch {
141+
val eligible = settingsRepository.recordTapTestSuccessAndCheckReviewEligibility(
142+
REVIEW_TRIGGER_TAP_TEST_SUCCESS_COUNT,
143+
)
144+
if (eligible) {
145+
_reviewFlowEligible.value = true
146+
}
147+
}
148+
}
149+
150+
/** Call once the screen has launched (or attempted to launch) the in-app review flow. */
151+
fun onReviewFlowRequested() {
152+
_reviewFlowEligible.value = false
124153
}
125154

126155
/**
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.tapsense.app.util
2+
3+
import android.app.Activity
4+
import android.util.Log
5+
import com.google.android.play.core.review.ReviewManager
6+
import com.google.android.play.core.review.ReviewManagerFactory
7+
import com.google.android.play.core.review.testing.FakeReviewManager
8+
import com.tapsense.app.BuildConfig
9+
10+
private const val TAG = "InAppReview"
11+
12+
/**
13+
* Fires the Play In-App Review flow from [this] activity. Fire-and-forget by design, per Google's
14+
* own guidance: the API never reveals whether the dialog was actually shown or whether the user
15+
* reviewed (it applies its own undisclosed quota - most calls are silent no-ops), and a failed
16+
* request must never change the app's normal flow. So there is nothing meaningful to return to
17+
* the caller either way; this only decides *when to ask*, not whether the OS agrees to show it.
18+
* Both outcomes are still logged (never surfaced to the user) so a real failure is diagnosable
19+
* instead of a silent black box - see the "Common gotchas" table at
20+
* https://developer.android.com/guide/playcore/in-app-review/test.
21+
*
22+
* Call sites are responsible for their own "don't ask too often" gating (see
23+
* [com.tapsense.app.data.settings.TapSenseSettingsRepository.recordTapTestSuccessAndCheckReviewEligibility])
24+
* - unlike [openPlayStoreListingSafely], which a user can trigger from Settings as often as they like.
25+
*
26+
* The real [ReviewManagerFactory]-created manager only ever succeeds for a build installed
27+
* *through* Google Play (an internal test track, closed/open testing, or production, with that
28+
* Google account as the Play Store's primary account and no existing review) - never a debug
29+
* build run from Android Studio, `adb install`, or even a sideloaded release APK; on any of those
30+
* `requestReviewFlow()` fails every time, regardless of how many times this is called or how the
31+
* app decided to call it. [FakeReviewManager] can't render the real dialog either (it only fakes
32+
* a successful `Task` result, per Google's docs), but swapping to it for [BuildConfig.DEBUG]
33+
* lets the logs below confirm the trigger/eligibility logic itself reached `launchReviewFlow`
34+
* correctly, isolating "nothing shows" during local testing to that Play-install requirement
35+
* rather than a bug in this app's own gating.
36+
*/
37+
fun Activity.requestInAppReviewSafely() {
38+
val manager: ReviewManager = if (BuildConfig.DEBUG) FakeReviewManager(this) else ReviewManagerFactory.create(this)
39+
val request = manager.requestReviewFlow()
40+
request.addOnCompleteListener { task ->
41+
if (task.isSuccessful) {
42+
Log.i(TAG, "requestReviewFlow succeeded, launching review flow")
43+
manager.launchReviewFlow(this, task.result)
44+
} else {
45+
// Expected on any build not installed through Play (see the doc link above) - never
46+
// surfaced to the user or allowed to change the app's flow, only logged.
47+
Log.w(TAG, "requestReviewFlow failed - not installed through Play, or quota declined", task.exception)
48+
}
49+
}
50+
}

app/src/main/kotlin/com/tapsense/app/util/UrlLauncher.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ const val PRIVACY_POLICY_URL = "https://nagarjunavs.github.io/tapsense/android/p
1212
/** Support inbox for tester/user feedback - the only feedback channel the app offers. */
1313
const val SUPPORT_EMAIL = "nagarjunavs.dev@gmail.com"
1414

15+
/**
16+
* The published Play Store package id - deliberately not [BuildConfig.APPLICATION_ID]. Debug
17+
* builds suffix that with ".debug" (see `applicationIdSuffix` in build.gradle.kts), which has no
18+
* Play Store listing at all, so a debug build would otherwise send the "Rate" button to a listing
19+
* that doesn't exist.
20+
*/
21+
private const val PLAY_STORE_PACKAGE_ID = "com.tapsense.app"
22+
1523
/**
1624
* Opens [url] in the user's browser, or does nothing if there's no app installed that can handle it
1725
* (mirrors [openNfcSettingsSafely]'s no-op-on-`ActivityNotFoundException` behavior).
@@ -49,3 +57,18 @@ fun Context.sendFeedbackEmailSafely() {
4957
// No email app available to handle this - silently no-op rather than crash.
5058
}
5159
}
60+
61+
/**
62+
* Opens this app's Play Store listing - used by the Settings screen's "Rate" row, a direct,
63+
* always-available path to leave a review (unlike [requestInAppReviewSafely], which Google's own
64+
* quota may silently decline to show). Prefers a `market:` URI, which the Play Store app resolves
65+
* directly to the review tab without a browser hop; falls back to the web listing via
66+
* [openUrlSafely] if the Play Store app isn't installed (e.g. some emulator images).
67+
*/
68+
fun Context.openPlayStoreListingSafely() {
69+
try {
70+
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$PLAY_STORE_PACKAGE_ID")))
71+
} catch (e: ActivityNotFoundException) {
72+
openUrlSafely("https://play.google.com/store/apps/details?id=$PLAY_STORE_PACKAGE_ID")
73+
}
74+
}

0 commit comments

Comments
 (0)