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..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 @@ -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,10 @@ class MainActivity : AppCompatActivity(), MenuProvider { // landing screen instead of closing the app private var documentOpenedExternally = 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 @@ -270,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 @@ -326,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 @@ -690,6 +711,7 @@ class MainActivity : AppCompatActivity(), MenuProvider { try { OpenFileIdling.increment() + leftForOwnActivity = true openDocumentLauncher.launch(intent) } catch (e: Exception) { OpenFileIdling.decrement() 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)