From 9eaba6f6c90e636b7c3baef8398e9078eef666c2 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 3 Oct 2026 21:24:56 +0200 Subject: [PATCH 1/2] Ask for a review when a document from another app is put down The ask came only at the list or at app start, and a user who opens files from other apps reaches neither. Back on such a document now asks if the review is earned, and leaves the app when the sheet closes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AK12MgSt5U9MA7dQaCQyqU --- .../droid/ui/activity/MainActivity.kt | 37 +++++++++++++++---- .../opendocument/droid/nonfree/InAppReview.kt | 9 ++++- .../opendocument/droid/nonfree/InAppReview.kt | 14 ++++++- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt b/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt index b3d7afbd0b9e..29ad902f6ddb 100644 --- a/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt +++ b/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt @@ -102,14 +102,27 @@ class MainActivity : AppCompatActivity() { // an externally opened document leaves the app rather than the document, and // carries unsaved edits out just the same confirmLeavingEdits { - // fall through to the default behavior (close the activity) - isEnabled = false - onBackPressedDispatcher.onBackPressed() - isEnabled = true + // the only moment a document opened from another app is put down again + if (documentFragment == null) { + leaveApp() + } else { + askForReviewIfEarned { leaveApp() } + } } } } + /** Falls through to the default back behavior, which closes the activity. */ + private fun leaveApp() { + if (isFinishing) { + return + } + + backCallback.isEnabled = false + onBackPressedDispatcher.onBackPressed() + backCallback.isEnabled = true + } + // kept because onPause has to stop it private var ttsActionMode: TtsActionModeCallback? = null @@ -870,14 +883,24 @@ class MainActivity : AppCompatActivity() { .show() } - /** Deliberately not from [closeFailedDocument], the last moment on earth to ask for stars. */ - private fun askForReviewIfEarned() { + /** + * Deliberately not from [closeFailedDocument], the last moment on earth to ask for stars. + * [then] runs after the sheet, or at once when there is no ask. + */ + private fun askForReviewIfEarned(then: () -> Unit = {}) { if (reviewRequested || !ReviewInvitation.isEarned(this)) { + then() + return } reviewRequested = true - InAppReview.request(this, analyticsManager) { ReviewInvitation.recordAsk(this) } + InAppReview.request( + this, + analyticsManager, + onAsked = { ReviewInvitation.recordAsk(this) }, + onDone = then, + ) } private fun closeDocument(keepMessage: Boolean = false) { diff --git a/app/src/noReview/java/app/opendocument/droid/nonfree/InAppReview.kt b/app/src/noReview/java/app/opendocument/droid/nonfree/InAppReview.kt index 3b4426e2960e..0e7d0ac87168 100644 --- a/app/src/noReview/java/app/opendocument/droid/nonfree/InAppReview.kt +++ b/app/src/noReview/java/app/opendocument/droid/nonfree/InAppReview.kt @@ -5,5 +5,12 @@ import android.app.Activity /** The play in-app review sheet, in a build that links no play core. */ object InAppReview { - fun request(activity: Activity, analyticsManager: AnalyticsManager, onAsked: () -> Unit) {} + fun request( + activity: Activity, + analyticsManager: AnalyticsManager, + onAsked: () -> Unit, + onDone: () -> Unit, + ) { + onDone() + } } diff --git a/app/src/review/java/app/opendocument/droid/nonfree/InAppReview.kt b/app/src/review/java/app/opendocument/droid/nonfree/InAppReview.kt index 7a14ade6a3e7..2bde5808177c 100644 --- a/app/src/review/java/app/opendocument/droid/nonfree/InAppReview.kt +++ b/app/src/review/java/app/opendocument/droid/nonfree/InAppReview.kt @@ -12,8 +12,16 @@ import com.google.android.play.core.review.ReviewManagerFactory */ object InAppReview { - /** [onAsked] runs when the sheet is handed to play - see `ReviewInvitation.recordAsk`. */ - fun request(activity: Activity, analyticsManager: AnalyticsManager, onAsked: () -> Unit) { + /** + * [onAsked] runs when the sheet is handed to play - see `ReviewInvitation.recordAsk`. [onDone] + * runs once the sheet is gone, or at once when there is none to show. + */ + fun request( + activity: Activity, + analyticsManager: AnalyticsManager, + onAsked: () -> Unit, + onDone: () -> Unit, + ) { analyticsManager.report("in_app_review_eligible") val manager = ReviewManagerFactory.create(activity) @@ -21,6 +29,7 @@ object InAppReview { if (!reviewInfoTask.isSuccessful) { // usually an install that did not come from play, so there is no store to ask analyticsManager.report("in_app_review_error") + onDone() return@addOnCompleteListener } @@ -31,6 +40,7 @@ object InAppReview { manager.launchReviewFlow(activity, reviewInfoTask.result).addOnCompleteListener { analyticsManager.report("in_app_review_done") + onDone() } } } From 25efde7cb7a5d59c677e67299582f2026d178bdc Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 3 Oct 2026 21:41:14 +0200 Subject: [PATCH 2/2] Fetch the review sheet while the document is read Back no longer waits on the network: the sheet is fetched once an externally opened document has rendered, and back shows it only if it has arrived. Otherwise back leaves at once. leaveApp also returns when the activity is already destroyed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AK12MgSt5U9MA7dQaCQyqU --- .../droid/ui/activity/DocumentFragment.kt | 1 + .../droid/ui/activity/MainActivity.kt | 43 ++++++++------ .../opendocument/droid/nonfree/InAppReview.kt | 14 ++--- .../opendocument/droid/nonfree/InAppReview.kt | 56 +++++++++++++++---- 4 files changed, 79 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt b/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt index 6c8647c62df6..32b8a2c71471 100644 --- a/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt +++ b/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt @@ -942,6 +942,7 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { freshOpenPending = false ReviewInvitation.recordDocumentOpen(activity) + (activity as? MainActivity)?.onDocumentShown() } } diff --git a/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt b/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt index 29ad902f6ddb..65e6c266db7d 100644 --- a/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt +++ b/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt @@ -103,10 +103,16 @@ class MainActivity : AppCompatActivity() { // carries unsaved edits out just the same confirmLeavingEdits { // the only moment a document opened from another app is put down again - if (documentFragment == null) { + val asked = + documentFragment != null && + preparedReview?.showIfReady( + onAsked = { ReviewInvitation.recordAsk(this@MainActivity) }, + onDone = { leaveApp() }, + ) == true + preparedReview = null + + if (!asked) { leaveApp() - } else { - askForReviewIfEarned { leaveApp() } } } } @@ -114,7 +120,7 @@ class MainActivity : AppCompatActivity() { /** Falls through to the default back behavior, which closes the activity. */ private fun leaveApp() { - if (isFinishing) { + if (isFinishing || isDestroyed) { return } @@ -158,6 +164,9 @@ class MainActivity : AppCompatActivity() { // lands would pass isEarned again. never reset: one hand-off per activity is plenty private var reviewRequested = false + // fetched while an externally opened document is read: back must not wait on the network + private var preparedReview: InAppReview.Prepared? = null + /** * Loads and saves the open document. Scoped to the activity, so it survives a configuration * change and [DocumentFragment] finds it already there rather than waiting for a binding. @@ -883,24 +892,24 @@ class MainActivity : AppCompatActivity() { .show() } - /** - * Deliberately not from [closeFailedDocument], the last moment on earth to ask for stars. - * [then] runs after the sheet, or at once when there is no ask. - */ - private fun askForReviewIfEarned(then: () -> Unit = {}) { - if (reviewRequested || !ReviewInvitation.isEarned(this)) { - then() + /** Called once a fresh open has rendered. */ + fun onDocumentShown() { + if (!documentOpenedExternally || reviewRequested || !ReviewInvitation.isEarned(this)) { + return + } + reviewRequested = true + preparedReview = InAppReview.prepare(this, analyticsManager) + } + + /** Deliberately not from [closeFailedDocument], the last moment on earth to ask for stars. */ + private fun askForReviewIfEarned() { + if (reviewRequested || !ReviewInvitation.isEarned(this)) { return } reviewRequested = true - InAppReview.request( - this, - analyticsManager, - onAsked = { ReviewInvitation.recordAsk(this) }, - onDone = then, - ) + InAppReview.request(this, analyticsManager) { ReviewInvitation.recordAsk(this) } } private fun closeDocument(keepMessage: Boolean = false) { diff --git a/app/src/noReview/java/app/opendocument/droid/nonfree/InAppReview.kt b/app/src/noReview/java/app/opendocument/droid/nonfree/InAppReview.kt index 0e7d0ac87168..b21981c7be15 100644 --- a/app/src/noReview/java/app/opendocument/droid/nonfree/InAppReview.kt +++ b/app/src/noReview/java/app/opendocument/droid/nonfree/InAppReview.kt @@ -5,12 +5,12 @@ import android.app.Activity /** The play in-app review sheet, in a build that links no play core. */ object InAppReview { - fun request( - activity: Activity, - analyticsManager: AnalyticsManager, - onAsked: () -> Unit, - onDone: () -> Unit, - ) { - onDone() + fun request(activity: Activity, analyticsManager: AnalyticsManager, onAsked: () -> Unit) {} + + fun prepare(activity: Activity, analyticsManager: AnalyticsManager): Prepared = Prepared() + + class Prepared { + + fun showIfReady(onAsked: () -> Unit, onDone: () -> Unit): Boolean = false } } diff --git a/app/src/review/java/app/opendocument/droid/nonfree/InAppReview.kt b/app/src/review/java/app/opendocument/droid/nonfree/InAppReview.kt index 2bde5808177c..b354b6f90ec9 100644 --- a/app/src/review/java/app/opendocument/droid/nonfree/InAppReview.kt +++ b/app/src/review/java/app/opendocument/droid/nonfree/InAppReview.kt @@ -1,6 +1,7 @@ package app.opendocument.droid.nonfree import android.app.Activity +import com.google.android.play.core.review.ReviewInfo import com.google.android.play.core.review.ReviewManagerFactory /** @@ -12,16 +13,8 @@ import com.google.android.play.core.review.ReviewManagerFactory */ object InAppReview { - /** - * [onAsked] runs when the sheet is handed to play - see `ReviewInvitation.recordAsk`. [onDone] - * runs once the sheet is gone, or at once when there is none to show. - */ - fun request( - activity: Activity, - analyticsManager: AnalyticsManager, - onAsked: () -> Unit, - onDone: () -> Unit, - ) { + /** [onAsked] runs when the sheet is handed to play - see `ReviewInvitation.recordAsk`. */ + fun request(activity: Activity, analyticsManager: AnalyticsManager, onAsked: () -> Unit) { analyticsManager.report("in_app_review_eligible") val manager = ReviewManagerFactory.create(activity) @@ -29,7 +22,6 @@ object InAppReview { if (!reviewInfoTask.isSuccessful) { // usually an install that did not come from play, so there is no store to ask analyticsManager.report("in_app_review_error") - onDone() return@addOnCompleteListener } @@ -40,8 +32,50 @@ object InAppReview { manager.launchReviewFlow(activity, reviewInfoTask.result).addOnCompleteListener { analyticsManager.report("in_app_review_done") + } + } + } + + /** + * Fetches the sheet ahead of the moment to show it, so that moment does not wait on the + * network. + */ + fun prepare(activity: Activity, analyticsManager: AnalyticsManager): Prepared { + val prepared = Prepared(activity, analyticsManager) + analyticsManager.report("in_app_review_eligible") + + prepared.manager.requestReviewFlow().addOnCompleteListener { task -> + if (task.isSuccessful) { + prepared.reviewInfo = task.result + } else { + analyticsManager.report("in_app_review_error") + } + } + + return prepared + } + + class Prepared + internal constructor( + private val activity: Activity, + private val analyticsManager: AnalyticsManager, + ) { + internal val manager = ReviewManagerFactory.create(activity) + internal var reviewInfo: ReviewInfo? = null + + /** False when the sheet has not arrived yet - then [onDone] does not run. */ + fun showIfReady(onAsked: () -> Unit, onDone: () -> Unit): Boolean { + val reviewInfo = reviewInfo ?: return false + + analyticsManager.report("in_app_review_start") + onAsked() + + manager.launchReviewFlow(activity, reviewInfo).addOnCompleteListener { + analyticsManager.report("in_app_review_done") onDone() } + + return true } } }