Use Timber for logging

This commit is contained in:
William Brawner 2021-02-21 14:07:21 -07:00
parent 96e7b7c6b3
commit 14dc55433a
11 changed files with 224 additions and 54 deletions

View file

@ -113,6 +113,7 @@ dependencies {
implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.legacy:legacy-support-v13:1.0.0'
implementation 'com.commonsware.cwac:anddown:0.3.0'
implementation 'com.jakewharton.timber:timber:4.7.1'
playImplementation 'com.android.billingclient:billing:3.0.2'
playImplementation 'com.google.android.play:core-ktx:1.8.1'
playImplementation 'com.google.firebase:firebase-crashlytics:17.3.1'
@ -178,3 +179,8 @@ fladle {
]
projectId = 'simplemarkdown'
}
task pullLogFiles(type: Exec) {
commandLine 'adb', 'pull',
'/storage/emulated/0/Android/data/com.wbrawner.simplemarkdown/files/logs'
}

View file

@ -16,7 +16,7 @@ class errorHandlerImpl {
}
override fun reportException(t: Throwable, message: String?) {
Log.e("ErrorHandler", "Caught non-fatal exception. Message: $message", t)
Timber.e(t, "Caught non-fatal exception. Message: $message")
}
}
}

View file

@ -6,6 +6,6 @@ import android.util.Log
object ReviewHelper {
// No review library for F-droid, so this is a no-op
fun init(application: Application) {
Log.w("ReviewHelper", "ReviewHelper not enabled for free builds")
Timber.w("ReviewHelper", "ReviewHelper not enabled for free builds")
}
}

View file

@ -2,7 +2,12 @@ package com.wbrawner.simplemarkdown
import android.app.Application
import android.os.StrictMode
import com.wbrawner.simplemarkdown.utility.PersistentTree
import com.wbrawner.simplemarkdown.utility.ReviewHelper
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
class MarkdownApplication : Application() {
override fun onCreate() {
@ -15,6 +20,14 @@ class MarkdownApplication : Application() {
.detectAll()
.penaltyLog()
.build())
Timber.plant(Timber.DebugTree())
GlobalScope.launch {
try {
Timber.plant(PersistentTree.create(File(getExternalFilesDir(null), "logs")))
} catch (e: Exception) {
Timber.e(e, "Unable to create PersistentTree")
}
}
}
super.onCreate()
ReviewHelper.init(this)

View file

@ -0,0 +1,75 @@
package com.wbrawner.simplemarkdown.utility
import android.util.Log
import com.wbrawner.simplemarkdown.utility.PersistentTree.Companion.create
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.IOException
import java.io.PrintStream
import java.text.SimpleDateFormat
import java.util.*
/**
* A [Timber.Tree] implementation that persists all logs to disk for retrieval later. Create
* instances via [create] instead of calling the constructor directly.
*/
class PersistentTree private constructor(private val logFile: File) : Timber.Tree() {
private val dateFormat = object : ThreadLocal<SimpleDateFormat>() {
override fun initialValue(): SimpleDateFormat =
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
}
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
val timestamp = dateFormat.get()!!.format(System.currentTimeMillis())
GlobalScope.launch(Dispatchers.IO) {
val priorityLetter = when (priority) {
Log.ASSERT -> "A"
Log.DEBUG -> "D"
Log.ERROR -> "E"
Log.INFO -> "I"
Log.VERBOSE -> "V"
Log.WARN -> "W"
else -> "U"
}
logFile.outputStream().use { stream ->
stream.bufferedWriter().use {
it.appendLine("$timestamp $priorityLetter/${tag ?: "SimpleMarkdown"}: $message")
}
t?.let {
PrintStream(stream).use { pStream ->
it.printStackTrace(pStream)
}
}
}
}
}
init {
log(Log.INFO, "Persistent logging initialized, writing contents to ${logFile.absolutePath}")
}
companion object {
/**
* Create a new instance of a [PersistentTree].
* @param logDir A [File] pointing to a directory where the log files should be stored. Will be
* created if it doesn't exist.
* @throws IllegalArgumentException if [logDir] is a file instead of a directory
* @throws IOException if the directory does not exist or cannot be
* created/written to
*/
@Throws(IllegalArgumentException::class, IOException::class)
suspend fun create(logDir: File): PersistentTree = withContext(Dispatchers.IO) {
if (!logDir.mkdirs() && !logDir.isDirectory)
throw IllegalArgumentException("Unable to create log directory at ${logDir.absolutePath}")
val timestamp = SimpleDateFormat("yyyyMMddHHmmss", Locale.US).format(Date())
val logFile = File(logDir, "persistent-log-$timestamp.log")
if (!logFile.createNewFile())
throw IOException("Unable to create logFile at ${logFile.absolutePath}")
PersistentTree(logFile)
}
}
}

View file

@ -41,14 +41,21 @@ class SplashActivity : AppCompatActivity() {
AppCompatDelegate.setDefaultNightMode(darkMode)
val uri = withContext(Dispatchers.IO) {
intent?.data
?: PreferenceManager.getDefaultSharedPreferences(this@SplashActivity)
intent?.data?.let {
Timber.d("Using uri from intent: $it")
it
} ?: PreferenceManager.getDefaultSharedPreferences(this@SplashActivity)
.getString(PREF_KEY_AUTOSAVE_URI, null)
?.let {
Timber.d("Using uri from shared preferences: $it")
Uri.parse(it)
}
}
if (uri == null) {
Timber.d("No intent provided to load data from")
}
val startIntent = Intent(this@SplashActivity, MainActivity::class.java)
.apply {
data = uri

View file

@ -7,7 +7,6 @@ import android.text.Editable
import android.text.SpannableString
import android.text.TextWatcher
import android.text.style.BackgroundColorSpan
import android.util.Log
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
@ -27,6 +26,7 @@ import com.wbrawner.simplemarkdown.utility.showKeyboard
import com.wbrawner.simplemarkdown.view.ViewPagerPage
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
import kotlinx.coroutines.*
import timber.log.Timber
import kotlin.math.abs
class EditFragment : Fragment(), ViewPagerPage {
@ -153,13 +153,16 @@ class EditFragment : Fragment(), ViewPagerPage {
var color = Color.TRANSPARENT
if (sentence.syllableCount() > 25) color = Color.argb(100, 229, 232, 42)
if (sentence.syllableCount() > 35) color = Color.argb(100, 193, 66, 66)
Log.d("SimpleMarkdown", "Sentence start: ${sentence.start()} end: ${sentence.end()}")
Timber.d("Sentence start: ${sentence.start()} end: ${
sentence
.end()
}")
span.setSpan(BackgroundColorSpan(color), sentence.start(), sentence.end(), 0)
}
markdownEditor?.setTextKeepState(span, TextView.BufferType.SPANNABLE)
previousValue = searchFor
val timeTakenMs = System.currentTimeMillis() - start
Log.d("SimpleMarkdown", "Handled markdown in $timeTakenMs ms")
Timber.d("Handled markdown in $timeTakenMs ms")
}
}

View file

@ -18,7 +18,6 @@ import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.AppBarConfiguration
@ -31,7 +30,9 @@ import com.wbrawner.simplemarkdown.view.adapter.EditPagerAdapter
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
import com.wbrawner.simplemarkdown.viewmodel.PREF_KEY_AUTOSAVE_URI
import kotlinx.android.synthetic.main.fragment_main.*
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallback {
@ -81,7 +82,7 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
tabLayout!!.visibility = View.GONE
}
@Suppress("CAST_NEVER_SUCCEEDS")
viewModel.fileName.observe(viewLifecycleOwner, Observer {
viewModel.fileName.observe(viewLifecycleOwner, {
toolbar?.title = it
})
}
@ -108,10 +109,12 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
true
}
R.id.action_save_as -> {
Timber.d("Save as clicked")
requestFileOp(REQUEST_SAVE_FILE)
true
}
R.id.action_share -> {
Timber.d("Share clicked")
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdownUpdates.value)
shareIntent.type = "text/plain"
@ -122,14 +125,17 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
true
}
R.id.action_load -> {
Timber.d("Load clicked")
requestFileOp(REQUEST_OPEN_FILE)
true
}
R.id.action_new -> {
Timber.d("New clicked")
promptSaveOrDiscardChanges()
true
}
R.id.action_lock_swipe -> {
Timber.d("Lock swiping clicked")
item.isChecked = !item.isChecked
pager!!.setSwipeLocked(item.isChecked)
true
@ -144,6 +150,7 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
withContext(Dispatchers.IO) {
val enableErrorReports = PreferenceManager.getDefaultSharedPreferences(requireContext())
.getBoolean(getString(R.string.pref_key_error_reports_enabled), true)
Timber.d("MainFragment started. Error reports enabled? $enableErrorReports")
errorHandler.enable(enableErrorReports)
}
}
@ -159,10 +166,13 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE)
tabLayout!!.visibility = View.GONE
else
tabLayout!!.visibility = View.VISIBLE
if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) {
Timber.d("Orientation changed to landscape, hiding tabs")
tabLayout?.visibility = View.GONE
} else {
Timber.d("Orientation changed to portrait, showing tabs")
tabLayout?.visibility = View.VISIBLE
}
}
override fun onRequestPermissionsResult(
@ -175,9 +185,11 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
// If request is cancelled, the result arrays are empty.
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// Permission granted, open file save dialog
Timber.d("Storage permissions granted")
requestFileOp(requestCode)
} else {
// Permission denied, do nothing
Timber.d("Storage permissions denied, unable to save or load files")
context?.let {
Toast.makeText(it, R.string.no_permissions, Toast.LENGTH_SHORT)
.show()
@ -191,6 +203,11 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
when (requestCode) {
REQUEST_OPEN_FILE -> {
if (resultCode != Activity.RESULT_OK || data?.data == null) {
Timber.w(
"Unable to open file. Result ok? %b Intent uri: %s",
resultCode == Activity.RESULT_OK,
data?.data?.toString()
)
return
}
@ -204,6 +221,10 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
.show()
}
} else {
Timber.d(
"File load succeeded, updating autosave uri in shared prefs: %s",
data.data.toString()
)
PreferenceManager.getDefaultSharedPreferences(requireContext()).edit {
putString(PREF_KEY_AUTOSAVE_URI, data.data.toString())
}
@ -212,12 +233,16 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
}
REQUEST_SAVE_FILE -> {
if (resultCode != Activity.RESULT_OK || data?.data == null) {
Timber.w(
"Unable to save file. Result ok? %b Intent uri: %s",
resultCode == Activity.RESULT_OK,
data?.data?.toString()
)
return
}
lifecycleScope.launch {
context?.let {
Log.d("SimpleMarkdown", "Saving file from onActivityResult")
viewModel.save(it, data.data)
}
}
@ -229,22 +254,28 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
private fun promptSaveOrDiscardChanges() {
if (!viewModel.shouldPromptSave()) {
viewModel.reset("Untitled.md")
Timber.i("Removing autosave uri from shared prefs")
PreferenceManager.getDefaultSharedPreferences(requireContext()).edit {
remove(PREF_KEY_AUTOSAVE_URI)
}
return
}
val context = context ?: return
val context = context ?: run {
Timber.w("Context is null, unable to show prompt for save or discard")
return
}
AlertDialog.Builder(context)
.setTitle(R.string.save_changes)
.setMessage(R.string.prompt_save_changes)
.setNegativeButton(R.string.action_discard) { _, _ ->
Timber.d("Discarding changes and deleting autosave uri from shared preferences")
viewModel.reset("Untitled.md")
PreferenceManager.getDefaultSharedPreferences(requireContext()).edit {
remove(PREF_KEY_AUTOSAVE_URI)
}
}
.setPositiveButton(R.string.action_save) { _, _ ->
Timber.d("Saving changes")
requestFileOp(REQUEST_SAVE_FILE)
}
.create()
@ -252,25 +283,29 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
}
private fun requestFileOp(requestType: Int) {
val context = context ?: return
val context = context ?: run {
Timber.w("File op requested but context was null, aborting")
return
}
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
Timber.i("Storage permission not granted, requesting")
requestPermissions(
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
requestType
)
return
}
// If the user is going to save the file, we don't want to auto-save it for them
shouldAutoSave = false
val intent = when (requestType) {
REQUEST_SAVE_FILE -> {
Timber.d("Requesting save op")
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = "text/markdown"
putExtra(Intent.EXTRA_TITLE, viewModel.fileName.value)
}
}
REQUEST_OPEN_FILE -> {
Timber.d("Requesting open op")
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = "*/*"
if (MimeTypeMap.getSingleton().hasMimeType("md")) {
@ -280,7 +315,10 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
}
}
}
else -> null
else -> {
Timber.w("Ignoring unknown file op request: $requestType")
null
}
} ?: return
intent.addCategory(Intent.CATEGORY_OPENABLE)
startActivityForResult(
@ -289,11 +327,6 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
)
}
override fun onResume() {
super.onResume()
shouldAutoSave = true
}
companion object {
// Request codes
const val REQUEST_OPEN_FILE = 1

View file

@ -3,15 +3,13 @@ package com.wbrawner.simplemarkdown.viewmodel
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.utility.getName
import com.wbrawner.simplemarkdown.view.fragment.MainFragment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileInputStream
import java.io.Reader
@ -19,7 +17,7 @@ import java.util.concurrent.atomic.AtomicBoolean
const val PREF_KEY_AUTOSAVE_URI = "autosave.uri"
class MarkdownViewModel : ViewModel() {
class MarkdownViewModel(val timber: Timber.Tree = Timber.asTree()) : ViewModel() {
val fileName = MutableLiveData<String?>("Untitled.md")
val markdownUpdates = MutableLiveData<String>()
val editorActions = MutableLiveData<EditorAction>()
@ -32,7 +30,10 @@ class MarkdownViewModel : ViewModel() {
}
suspend fun load(context: Context, uri: Uri?): Boolean {
if (uri == null) return false
if (uri == null) {
timber.i("Ignoring call to load null uri")
return false
}
return withContext(Dispatchers.IO) {
try {
context.contentResolver.openFileDescriptor(uri, "r")?.use {
@ -41,6 +42,7 @@ class MarkdownViewModel : ViewModel() {
val content = fileInput.reader().use(Reader::readText)
if (content.isBlank()) {
// If we don't get anything back, then we can assume that reading the file failed
timber.i("Ignoring load for empty file $fileName from $fileInput")
return@withContext false
}
isDirty.set(false)
@ -48,16 +50,31 @@ class MarkdownViewModel : ViewModel() {
markdownUpdates.postValue(content)
this@MarkdownViewModel.fileName.postValue(fileName)
this@MarkdownViewModel.uri.postValue(uri)
timber.i("Loaded file $fileName from $fileInput")
timber.v("File contents:\n$content")
true
} ?: false
} catch (ignored: Exception) {
} ?: run {
timber.w("Open file descriptor returned null for uri: $uri")
false
}
} catch (e: Exception) {
timber.e(e, "Failed to open file descriptor for uri: $uri")
false
}
}
}
suspend fun save(context: Context, givenUri: Uri? = this.uri.value): Boolean {
val uri = givenUri ?: this.uri.value ?: return false
suspend fun save(context: Context, givenUri: Uri? = null): Boolean {
val uri = givenUri?.let {
timber.i("Saving file with given uri: $uri")
it
} ?: this.uri.value?.let {
timber.i("Saving file with cached uri: $uri")
it
} ?: run {
timber.w("Save called with no uri")
return@save false
}
return withContext(Dispatchers.IO) {
try {
val fileName = uri.getName(context)
@ -66,11 +83,17 @@ class MarkdownViewModel : ViewModel() {
?.use {
it.write(markdownUpdates.value ?: "")
}
?: return@withContext false
?: run {
timber.w("Open output stream returned null for uri: $uri")
return@withContext false
}
this@MarkdownViewModel.fileName.postValue(fileName)
this@MarkdownViewModel.uri.postValue(uri)
isDirty.set(false)
timber.i("Saved file $fileName to uri $uri")
true
} catch (ignored: Exception) {
} catch (e: Exception) {
timber.e(e, "Failed to save file at uri: $uri")
false
}
}
@ -78,31 +101,38 @@ class MarkdownViewModel : ViewModel() {
suspend fun autosave(context: Context, sharedPrefs: SharedPreferences) {
val isAutoSaveEnabled = sharedPrefs.getBoolean(MainFragment.KEY_AUTOSAVE, true)
timber.d("Autosave called. isEnabled? $isAutoSaveEnabled")
if (!isDirty.get() || !isAutoSaveEnabled) {
timber.i("Ignoring call to autosave. Contents haven't changed or autosave not enabled")
return
}
val uri = if (save(context)) {
Log.d("SimpleMarkdown", "Saving file from onPause")
timber.i("Autosave with cached uri succeeded: ${uri.value}")
uri.value
} else {
// The user has left the app, with autosave enabled, and we don't already have a
// Uri for them or for some reason we were unable to save to the original Uri. In
// this case, we need to just save to internal file storage so that we can recover
val fileUri = Uri.fromFile(File(context.filesDir, fileName.value?: "Untitled.md"))
Log.d("SimpleMarkdown", "Saving file from onPause failed, trying again")
val fileUri = Uri.fromFile(File(context.filesDir, fileName.value ?: "Untitled.md"))
timber.i("No cached uri for autosave, saving to $fileUri instead")
if (save(context, fileUri)) {
fileUri
} else {
null
}
} ?: return
} ?: run {
timber.w("Unable to perform autosave, uri was null")
return@autosave
}
timber.i("Persisting autosave uri in shared prefs: $uri")
sharedPrefs.edit()
.putString(PREF_KEY_AUTOSAVE_URI, uri.toString())
.apply()
}
fun reset(untitledFileName: String) {
timber.i("Resetting view model to default state")
fileName.postValue(untitledFileName)
uri.postValue(null)
markdownUpdates.postValue("")

View file

@ -1,8 +1,8 @@
package com.wbrawner.simplemarkdown.utility
import android.util.Log
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.wbrawner.simplemarkdown.BuildConfig
import timber.log.Timber
import kotlin.reflect.KProperty
class CrashlyticsErrorHandler : ErrorHandler {
@ -15,7 +15,7 @@ class CrashlyticsErrorHandler : ErrorHandler {
override fun reportException(t: Throwable, message: String?) {
@Suppress("ConstantConditionIf")
if (BuildConfig.DEBUG) {
Log.e("CrashlyticsErrorHandler", "Caught exception: $message", t)
Timber.e(t, "Caught exception: $message")
}
crashlytics.recordException(t)
}

View file

@ -5,12 +5,12 @@ import android.app.Application
import android.content.SharedPreferences
import android.os.Bundle
import android.os.SystemClock
import android.util.Log
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.google.android.play.core.review.ReviewManager
import com.google.android.play.core.review.ReviewManagerFactory
import com.wbrawner.simplemarkdown.view.activity.MainActivity
import timber.log.Timber
private const val KEY_TIME_IN_APP = "timeInApp"
@ -27,6 +27,7 @@ object ReviewHelper : Application.ActivityLifecycleCallbacks {
private lateinit var application: Application
private lateinit var reviewManager: ReviewManager
private lateinit var sharedPreferences: SharedPreferences
private lateinit var timber: Timber.Tree
private var activityCount = 0
set(value) {
field = if (value < 0) {
@ -40,14 +41,16 @@ object ReviewHelper : Application.ActivityLifecycleCallbacks {
fun init(
application: Application,
sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application),
reviewManager: ReviewManager = ReviewManagerFactory.create(application)
reviewManager: ReviewManager = ReviewManagerFactory.create(application),
timber: Timber.Tree = Timber.asTree()
) {
this.application = application
this.sharedPreferences = sharedPreferences
this.reviewManager = reviewManager
this.timber = timber
if (sharedPreferences.getLong(KEY_TIME_IN_APP, 0L) == -1L) {
// We've already prompted the user for the review so let's not be annoying about it
Log.i("ReviewHelper", "User already prompted for review, not configuring ReviewHelper")
timber.i("User already prompted for review, not configuring ReviewHelper")
return
}
application.registerActivityLifecycleCallbacks(this)
@ -63,24 +66,24 @@ object ReviewHelper : Application.ActivityLifecycleCallbacks {
}
if (activity !is MainActivity || sharedPreferences.getLong(KEY_TIME_IN_APP, 0L) < TIME_TO_PROMPT) {
// Not ready to prompt just yet
Log.v("ReviewHelper", "Not ready to prompt user for review yet")
timber.v("Not ready to prompt user for review yet")
return
}
Log.v("ReviewHelper", "Prompting user for review")
timber.v("Prompting user for review")
reviewManager.requestReviewFlow().addOnCompleteListener { request ->
if (!request.isSuccessful) {
val exception = request.exception
?: RuntimeException("Failed to request review")
Log.e("ReviewHelper", "Failed to prompt user for review", exception)
timber.e(exception, "Failed to prompt user for review")
return@addOnCompleteListener
}
reviewManager.launchReviewFlow(activity, request.result).addOnCompleteListener { _ ->
reviewManager.launchReviewFlow(activity, request.result).addOnCompleteListener {
// According to the docs, this may or may not have actually been shown. Either
// way, it's not a critical piece of functionality for the app so I'm not
// worried about it failing silently. Link for reference:
// https://developer.android.com/guide/playcore/in-app-review/kotlin-java#launch-review-flow
Log.v("ReviewHelper", "User finished review, ending activity watch")
timber.v("User finished review, ending activity watch")
application.unregisterActivityLifecycleCallbacks(this)
sharedPreferences.edit {
putLong(KEY_TIME_IN_APP, -1L)