From d074ab60cd5c4268a105d40e8d184d8e9a130be0 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 2 Aug 2026 21:03:13 +0200 Subject: [PATCH 1/2] ask for a review at two earned moments instead of on every load The review request sat in onLoadSuccess, which is reached by more than a user opening a document: reloadUri drives it when edit mode is entered and when it is left, and PageView hands a file back through loadUri while the user is reading. So toggling edit mode twice asked twice, mid-task. Gate it on a fresh open. loadUri takes a freshOpen flag, reloadUri clears it, and onLoadSuccess only asks when the load was one the user asked for. Saving still counts, because onSaveSuccess reloads through loadUri - that one is a success the user just caused, and worth asking after. Add the landing screen as the second moment, for people who open the app and browse rather than arriving with a document from another app. Both are counted in UsageCounters and neither asks until the third open. A fresh install has nothing to say about the app yet, and asking anyway costs stars - one of the one-star reviews in the play console is literally "not used it but asking for a review". A counter rather than the length of the recently opened list: that list is capped, pruned and deletable, so it undercounts returning users, and it counts distinct documents rather than visits. requestInAppRating moves out of DocumentFragment into nonfree/InAppReview so both call sites share it, which is also where the play dependency belongs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E1ANEeai87KtnX5pBzgDEJ --- .../droid/background/UsageCounters.kt | 38 ++++++++++++ .../opendocument/droid/nonfree/InAppReview.kt | 47 +++++++++++++++ .../droid/ui/activity/DocumentFragment.kt | 60 +++++++++---------- .../droid/ui/activity/MainActivity.kt | 16 +++++ .../opendocument/droid/ui/widget/PageView.kt | 7 ++- 5 files changed, 137 insertions(+), 31 deletions(-) create mode 100644 app/src/main/java/app/opendocument/droid/background/UsageCounters.kt create mode 100644 app/src/main/java/app/opendocument/droid/nonfree/InAppReview.kt diff --git a/app/src/main/java/app/opendocument/droid/background/UsageCounters.kt b/app/src/main/java/app/opendocument/droid/background/UsageCounters.kt new file mode 100644 index 000000000000..33996e709799 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/background/UsageCounters.kt @@ -0,0 +1,38 @@ +package app.opendocument.droid.background + +import android.content.Context +import android.content.SharedPreferences + +/** + * How often the user opened the app from the launcher, and how many documents they opened. + * + * A counter rather than the length of the recently opened list: that list is capped, pruned and + * deletable, so it undercounts exactly the returning users this is meant to find. + */ +object UsageCounters { + + private const val KEY_APP_OPENS = "usage_app_opens" + private const val KEY_DOCUMENT_OPENS = "usage_document_opens" + + fun recordAppOpen(context: Context): Int = increment(context, KEY_APP_OPENS) + + fun recordDocumentOpen(context: Context): Int = increment(context, KEY_DOCUMENT_OPENS) + + fun appOpens(context: Context): Int = preferences(context).getInt(KEY_APP_OPENS, 0) + + fun documentOpens(context: Context): Int = preferences(context).getInt(KEY_DOCUMENT_OPENS, 0) + + private fun increment(context: Context, key: String): Int { + val preferences = preferences(context) + val next = preferences.getInt(key, 0) + 1 + + // apply, not commit: nothing reads this back synchronously + preferences.edit().putInt(key, next).apply() + + return next + } + + /** The same default preference file [CatchAllSetting] uses. */ + private fun preferences(context: Context): SharedPreferences = + context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE) +} diff --git a/app/src/main/java/app/opendocument/droid/nonfree/InAppReview.kt b/app/src/main/java/app/opendocument/droid/nonfree/InAppReview.kt new file mode 100644 index 000000000000..63180e16811c --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/nonfree/InAppReview.kt @@ -0,0 +1,47 @@ +package app.opendocument.droid.nonfree + +import android.app.Activity +import com.google.android.play.core.review.ReviewManagerFactory + +/** + * The play in-app review sheet. + * + * Play decides from an undocumented per-user quota whether the sheet appears at all, and the + * completion listener fires the same way either way. The analytics events below are the only + * visibility there is. + */ +object InAppReview { + + /** + * Opens before the first ask - a fresh install has nothing to say yet, and asking costs stars. + */ + const val MINIMUM_OPENS: Int = 3 + + fun requestIfEarned(activity: Activity, analyticsManager: AnalyticsManager, opens: Int) { + if (opens < MINIMUM_OPENS) { + return + } + + request(activity, analyticsManager) + } + + fun request(activity: Activity, analyticsManager: AnalyticsManager) { + analyticsManager.report("in_app_review_eligible") + + val manager = ReviewManagerFactory.create(activity) + manager.requestReviewFlow().addOnCompleteListener { reviewInfoTask -> + 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") + + return@addOnCompleteListener + } + + analyticsManager.report("in_app_review_start") + + manager.launchReviewFlow(activity, reviewInfoTask.result).addOnCompleteListener { + analyticsManager.report("in_app_review_done") + } + } + } +} 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 134db8e06006..831130e8799c 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 @@ -27,15 +27,16 @@ import app.opendocument.droid.background.FileLoader import app.opendocument.droid.background.LoaderService import app.opendocument.droid.background.LoaderServiceQueue import app.opendocument.droid.background.StreamUtil +import app.opendocument.droid.background.UsageCounters import app.opendocument.droid.nonfree.AnalyticsConstants import app.opendocument.droid.nonfree.AnalyticsManager import app.opendocument.droid.nonfree.CrashManager +import app.opendocument.droid.nonfree.InAppReview import app.opendocument.droid.ui.OpenFileIdling import app.opendocument.droid.ui.SnackbarHelper import app.opendocument.droid.ui.widget.PageView import app.opendocument.droid.ui.widget.ProgressDialogFragment import com.google.android.material.tabs.TabLayout -import com.google.android.play.core.review.ReviewManagerFactory import java.io.File import java.io.FileNotFoundException import java.io.IOException @@ -61,6 +62,9 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider private var resultOnStart: FileLoader.Result? = null private var errorOnStart: Throwable? = null + /** Set by [loadUri], consumed by the load it belongs to. See [loadUri]. */ + private var freshOpenPending = false + private lateinit var tabLayout: TabLayout private lateinit var serviceQueue: LoaderServiceQueue @@ -303,9 +307,19 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider serviceQueue.addToQueue { service -> service.loadWithType(loaderType, options) } } - fun loadUri(uri: Uri, persistentUri: Boolean, editable: Boolean = false) { + /** + * [freshOpen] is false for a load the user did not ask for, which then never asks for a review. + */ + fun loadUri( + uri: Uri, + persistentUri: Boolean, + editable: Boolean = false, + freshOpen: Boolean = true, + ) { initializePageView() + freshOpenPending = freshOpen + state.lastRequestedUri = uri val options = FileLoader.Options() @@ -320,6 +334,9 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider val lastResult = checkNotNull(state.lastResult) { "nothing was loaded yet" } lastResult.options.translatable = translatable + // entering or leaving edit mode is not a new document, and the user is working + freshOpenPending = false + loadWithType(lastResult.loaderType, lastResult.options) } @@ -411,25 +428,6 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider pageView?.toggleDarkMode(fileType?.startsWith("application/pdf") != true) } - private fun requestInAppRating(activity: Activity) { - analyticsManager.report("in_app_review_eligible") - - val manager = ReviewManagerFactory.create(activity) - manager.requestReviewFlow().addOnCompleteListener { reviewInfoTask -> - if (!reviewInfoTask.isSuccessful) { - analyticsManager.report("in_app_review_error") - - return@addOnCompleteListener - } - - analyticsManager.report("in_app_review_start") - - manager.launchReviewFlow(activity, reviewInfoTask.result).addOnCompleteListener { - analyticsManager.report("in_app_review_done") - } - } - } - private fun isActivityReadyForResult(result: FileLoader.Result): Boolean { val lastRequestedUri = state.lastRequestedUri if (lastRequestedUri != null && lastRequestedUri != result.options.originalUri) { @@ -489,15 +487,17 @@ class DocumentFragment : Fragment(), LoaderService.LoaderListener, MenuProvider state.endLoadIdling() - // asked for in both flavors, and deliberately not behind a flag. lite used to - // consult a "show_in_app_rating" remote config key, which has returned false since - // firebase remote config was gutted in v4.2 - the call site was never touched, so - // nothing looked broken while the flavor carrying almost every user silently - // stopped asking. play decides whether the sheet actually appears (undocumented - // per-user quota) and reports nothing back either way, so there is nothing here - // worth gating: DISABLE_TRACKING means crash and analytics reporting, which this - // is not. - requestInAppRating(activity) + // only a fresh open earns the ask - reloadUri and the webview reach here mid-task. + // a save reloads through loadUri and so still counts, which is wanted + if (freshOpenPending) { + freshOpenPending = false + + InAppReview.requestIfEarned( + activity, + analyticsManager, + UsageCounters.recordDocumentOpen(activity), + ) + } } override fun onError(result: FileLoader.Result, error: Throwable) { 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 c453e9117a93..506bf2eb08cf 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 @@ -35,11 +35,13 @@ import app.opendocument.droid.background.LoaderService import app.opendocument.droid.background.LoaderServiceQueue import app.opendocument.droid.background.PersistedUriPermissions import app.opendocument.droid.background.PrintingManager +import app.opendocument.droid.background.UsageCounters import app.opendocument.droid.nonfree.AdManager import app.opendocument.droid.nonfree.AnalyticsConstants import app.opendocument.droid.nonfree.AnalyticsManager import app.opendocument.droid.nonfree.BillingManager import app.opendocument.droid.nonfree.CrashManager +import app.opendocument.droid.nonfree.InAppReview import app.opendocument.droid.ui.EditActionModeCallback import app.opendocument.droid.ui.FindActionModeCallback import app.opendocument.droid.ui.OpenFileIdling @@ -109,6 +111,9 @@ class MainActivity : AppCompatActivity(), MenuProvider { // landing screen instead of closing the app private var documentOpenedExternally = false + // launched from the launcher rather than with a document, consumed by the next onStart + private var openedDirectly = false + lateinit var loaderServiceQueue: LoaderServiceQueue private set @@ -247,11 +252,15 @@ class MainActivity : AppCompatActivity(), MenuProvider { ) } else { analyticsManager.setCurrentScreen(this, "screen_main") + + openedDirectly = true } } else { crashManager.log("onCreate empty") analyticsManager.setCurrentScreen(this, "screen_main") + + openedDirectly = true } addMenuProvider(this, this) @@ -260,6 +269,13 @@ class MainActivity : AppCompatActivity(), MenuProvider { override fun onStart() { super.onStart() + if (openedDirectly) { + openedDirectly = false + + // the landing screen, before the user has picked anything + InAppReview.requestIfEarned(this, analyticsManager, UsageCounters.recordAppOpen(this)) + } + documentFragment = supportFragmentManager.findFragmentByTag(DOCUMENT_FRAGMENT_TAG) as DocumentFragment? diff --git a/app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt b/app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt index 990bf6eddb3f..367380bbbd8a 100644 --- a/app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt +++ b/app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt @@ -222,7 +222,12 @@ constructor(context: Context, attributeSet: AttributeSet?) : } post { - documentFragment.loadUri(AndroidFileCache.getCacheFileUri(context, tmpFile), false) + // the user is mid-read, not opening something + documentFragment.loadUri( + AndroidFileCache.getCacheFileUri(context, tmpFile), + false, + freshOpen = false, + ) } } catch (e: IOException) { crashManager.log(e) From 34a5cda9773411fc1adf2162719ac5cb86d3cb87 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 2 Aug 2026 21:17:18 +0200 Subject: [PATCH 2/2] count launcher opens that resume the existing task Tapping the launcher while the task is still alive resumes MainActivity instead of creating it, so a flag set in onCreate missed exactly the users who keep the app in recents - the ones most likely to have something to say. Count in onStart instead, when the landing screen is what is on show. Coming back from the document picker also lands there, so the picker marks itself before it starts and that return is not counted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E1ANEeai87KtnX5pBzgDEJ --- .../droid/ui/activity/MainActivity.kt | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 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 506bf2eb08cf..f32a826b2759 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 @@ -111,8 +111,9 @@ class MainActivity : AppCompatActivity(), MenuProvider { // landing screen instead of closing the app private var documentOpenedExternally = false - // launched from the launcher rather than with a document, consumed by the next onStart - private var openedDirectly = false + // set before we start an activity of our own, so coming back from it is not counted as + // the user opening the app + private var leftForOwnActivity = false lateinit var loaderServiceQueue: LoaderServiceQueue private set @@ -252,15 +253,11 @@ class MainActivity : AppCompatActivity(), MenuProvider { ) } else { analyticsManager.setCurrentScreen(this, "screen_main") - - openedDirectly = true } } else { crashManager.log("onCreate empty") analyticsManager.setCurrentScreen(this, "screen_main") - - openedDirectly = true } addMenuProvider(this, this) @@ -269,13 +266,6 @@ class MainActivity : AppCompatActivity(), MenuProvider { override fun onStart() { super.onStart() - if (openedDirectly) { - openedDirectly = false - - // the landing screen, before the user has picked anything - InAppReview.requestIfEarned(this, analyticsManager, UsageCounters.recordAppOpen(this)) - } - documentFragment = supportFragmentManager.findFragmentByTag(DOCUMENT_FRAGMENT_TAG) as DocumentFragment? @@ -286,6 +276,20 @@ class MainActivity : AppCompatActivity(), MenuProvider { crashManager.log("onStart") + // here rather than in onCreate: tapping the launcher while the task is still alive + // resumes this activity instead of creating it, and those opens count too + if (documentFragment == null && loadOnStart == null) { + if (leftForOwnActivity) { + leftForOwnActivity = false + } else { + InAppReview.requestIfEarned( + this, + analyticsManager, + UsageCounters.recordAppOpen(this), + ) + } + } + val loadOnStart = this.loadOnStart ?: return // loadOnStart either came from an external intent or from a restored @@ -342,6 +346,7 @@ class MainActivity : AppCompatActivity(), MenuProvider { intent.type = documentFragment.lastFileType + leftForOwnActivity = true createDocumentLauncher.launch(intent) } catch (e: ActivityNotFoundException) { // happens on a variety devices, e.g. Samsung Galaxy Tab4 7.0 with Android 4.4.2 @@ -706,6 +711,7 @@ class MainActivity : AppCompatActivity(), MenuProvider { try { OpenFileIdling.increment() + leftForOwnActivity = true openDocumentLauncher.launch(intent) } catch (e: Exception) { OpenFileIdling.decrement()