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 'com.google.android.material:material:1.3.0'
implementation 'androidx.legacy:legacy-support-v13:1.0.0' implementation 'androidx.legacy:legacy-support-v13:1.0.0'
implementation 'com.commonsware.cwac:anddown:0.3.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.android.billingclient:billing:3.0.2'
playImplementation 'com.google.android.play:core-ktx:1.8.1' playImplementation 'com.google.android.play:core-ktx:1.8.1'
playImplementation 'com.google.firebase:firebase-crashlytics:17.3.1' playImplementation 'com.google.firebase:firebase-crashlytics:17.3.1'
@ -178,3 +179,8 @@ fladle {
] ]
projectId = 'simplemarkdown' 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?) { 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 { object ReviewHelper {
// No review library for F-droid, so this is a no-op // No review library for F-droid, so this is a no-op
fun init(application: Application) { 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.app.Application
import android.os.StrictMode import android.os.StrictMode
import com.wbrawner.simplemarkdown.utility.PersistentTree
import com.wbrawner.simplemarkdown.utility.ReviewHelper 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() { class MarkdownApplication : Application() {
override fun onCreate() { override fun onCreate() {
@ -15,6 +20,14 @@ class MarkdownApplication : Application() {
.detectAll() .detectAll()
.penaltyLog() .penaltyLog()
.build()) .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() super.onCreate()
ReviewHelper.init(this) 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) AppCompatDelegate.setDefaultNightMode(darkMode)
val uri = withContext(Dispatchers.IO) { val uri = withContext(Dispatchers.IO) {
intent?.data intent?.data?.let {
?: PreferenceManager.getDefaultSharedPreferences(this@SplashActivity) Timber.d("Using uri from intent: $it")
it
} ?: PreferenceManager.getDefaultSharedPreferences(this@SplashActivity)
.getString(PREF_KEY_AUTOSAVE_URI, null) .getString(PREF_KEY_AUTOSAVE_URI, null)
?.let { ?.let {
Timber.d("Using uri from shared preferences: $it")
Uri.parse(it) Uri.parse(it)
} }
} }
if (uri == null) {
Timber.d("No intent provided to load data from")
}
val startIntent = Intent(this@SplashActivity, MainActivity::class.java) val startIntent = Intent(this@SplashActivity, MainActivity::class.java)
.apply { .apply {
data = uri data = uri

View file

@ -7,7 +7,6 @@ import android.text.Editable
import android.text.SpannableString import android.text.SpannableString
import android.text.TextWatcher import android.text.TextWatcher
import android.text.style.BackgroundColorSpan import android.text.style.BackgroundColorSpan
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
@ -27,6 +26,7 @@ import com.wbrawner.simplemarkdown.utility.showKeyboard
import com.wbrawner.simplemarkdown.view.ViewPagerPage import com.wbrawner.simplemarkdown.view.ViewPagerPage
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
import kotlinx.coroutines.* import kotlinx.coroutines.*
import timber.log.Timber
import kotlin.math.abs import kotlin.math.abs
class EditFragment : Fragment(), ViewPagerPage { class EditFragment : Fragment(), ViewPagerPage {
@ -153,13 +153,16 @@ class EditFragment : Fragment(), ViewPagerPage {
var color = Color.TRANSPARENT var color = Color.TRANSPARENT
if (sentence.syllableCount() > 25) color = Color.argb(100, 229, 232, 42) if (sentence.syllableCount() > 25) color = Color.argb(100, 229, 232, 42)
if (sentence.syllableCount() > 35) color = Color.argb(100, 193, 66, 66) 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) span.setSpan(BackgroundColorSpan(color), sentence.start(), sentence.end(), 0)
} }
markdownEditor?.setTextKeepState(span, TextView.BufferType.SPANNABLE) markdownEditor?.setTextKeepState(span, TextView.BufferType.SPANNABLE)
previousValue = searchFor previousValue = searchFor
val timeTakenMs = System.currentTimeMillis() - start 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.core.content.edit
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.AppBarConfiguration 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.MarkdownViewModel
import com.wbrawner.simplemarkdown.viewmodel.PREF_KEY_AUTOSAVE_URI import com.wbrawner.simplemarkdown.viewmodel.PREF_KEY_AUTOSAVE_URI
import kotlinx.android.synthetic.main.fragment_main.* 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 import timber.log.Timber
class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallback { class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallback {
@ -81,7 +82,7 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
tabLayout!!.visibility = View.GONE tabLayout!!.visibility = View.GONE
} }
@Suppress("CAST_NEVER_SUCCEEDS") @Suppress("CAST_NEVER_SUCCEEDS")
viewModel.fileName.observe(viewLifecycleOwner, Observer { viewModel.fileName.observe(viewLifecycleOwner, {
toolbar?.title = it toolbar?.title = it
}) })
} }
@ -108,10 +109,12 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
true true
} }
R.id.action_save_as -> { R.id.action_save_as -> {
Timber.d("Save as clicked")
requestFileOp(REQUEST_SAVE_FILE) requestFileOp(REQUEST_SAVE_FILE)
true true
} }
R.id.action_share -> { R.id.action_share -> {
Timber.d("Share clicked")
val shareIntent = Intent(Intent.ACTION_SEND) val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdownUpdates.value) shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdownUpdates.value)
shareIntent.type = "text/plain" shareIntent.type = "text/plain"
@ -122,14 +125,17 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
true true
} }
R.id.action_load -> { R.id.action_load -> {
Timber.d("Load clicked")
requestFileOp(REQUEST_OPEN_FILE) requestFileOp(REQUEST_OPEN_FILE)
true true
} }
R.id.action_new -> { R.id.action_new -> {
Timber.d("New clicked")
promptSaveOrDiscardChanges() promptSaveOrDiscardChanges()
true true
} }
R.id.action_lock_swipe -> { R.id.action_lock_swipe -> {
Timber.d("Lock swiping clicked")
item.isChecked = !item.isChecked item.isChecked = !item.isChecked
pager!!.setSwipeLocked(item.isChecked) pager!!.setSwipeLocked(item.isChecked)
true true
@ -144,6 +150,7 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val enableErrorReports = PreferenceManager.getDefaultSharedPreferences(requireContext()) val enableErrorReports = PreferenceManager.getDefaultSharedPreferences(requireContext())
.getBoolean(getString(R.string.pref_key_error_reports_enabled), true) .getBoolean(getString(R.string.pref_key_error_reports_enabled), true)
Timber.d("MainFragment started. Error reports enabled? $enableErrorReports")
errorHandler.enable(enableErrorReports) errorHandler.enable(enableErrorReports)
} }
} }
@ -159,10 +166,13 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
override fun onConfigurationChanged(newConfig: Configuration) { override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)
if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) {
tabLayout!!.visibility = View.GONE Timber.d("Orientation changed to landscape, hiding tabs")
else tabLayout?.visibility = View.GONE
tabLayout!!.visibility = View.VISIBLE } else {
Timber.d("Orientation changed to portrait, showing tabs")
tabLayout?.visibility = View.VISIBLE
}
} }
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
@ -175,9 +185,11 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
// If request is cancelled, the result arrays are empty. // If request is cancelled, the result arrays are empty.
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// Permission granted, open file save dialog // Permission granted, open file save dialog
Timber.d("Storage permissions granted")
requestFileOp(requestCode) requestFileOp(requestCode)
} else { } else {
// Permission denied, do nothing // Permission denied, do nothing
Timber.d("Storage permissions denied, unable to save or load files")
context?.let { context?.let {
Toast.makeText(it, R.string.no_permissions, Toast.LENGTH_SHORT) Toast.makeText(it, R.string.no_permissions, Toast.LENGTH_SHORT)
.show() .show()
@ -191,6 +203,11 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
when (requestCode) { when (requestCode) {
REQUEST_OPEN_FILE -> { REQUEST_OPEN_FILE -> {
if (resultCode != Activity.RESULT_OK || data?.data == null) { 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 return
} }
@ -204,6 +221,10 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
.show() .show()
} }
} else { } else {
Timber.d(
"File load succeeded, updating autosave uri in shared prefs: %s",
data.data.toString()
)
PreferenceManager.getDefaultSharedPreferences(requireContext()).edit { PreferenceManager.getDefaultSharedPreferences(requireContext()).edit {
putString(PREF_KEY_AUTOSAVE_URI, data.data.toString()) putString(PREF_KEY_AUTOSAVE_URI, data.data.toString())
} }
@ -212,12 +233,16 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
} }
REQUEST_SAVE_FILE -> { REQUEST_SAVE_FILE -> {
if (resultCode != Activity.RESULT_OK || data?.data == null) { 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 return
} }
lifecycleScope.launch { lifecycleScope.launch {
context?.let { context?.let {
Log.d("SimpleMarkdown", "Saving file from onActivityResult")
viewModel.save(it, data.data) viewModel.save(it, data.data)
} }
} }
@ -229,22 +254,28 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
private fun promptSaveOrDiscardChanges() { private fun promptSaveOrDiscardChanges() {
if (!viewModel.shouldPromptSave()) { if (!viewModel.shouldPromptSave()) {
viewModel.reset("Untitled.md") viewModel.reset("Untitled.md")
Timber.i("Removing autosave uri from shared prefs")
PreferenceManager.getDefaultSharedPreferences(requireContext()).edit { PreferenceManager.getDefaultSharedPreferences(requireContext()).edit {
remove(PREF_KEY_AUTOSAVE_URI) remove(PREF_KEY_AUTOSAVE_URI)
} }
return 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) AlertDialog.Builder(context)
.setTitle(R.string.save_changes) .setTitle(R.string.save_changes)
.setMessage(R.string.prompt_save_changes) .setMessage(R.string.prompt_save_changes)
.setNegativeButton(R.string.action_discard) { _, _ -> .setNegativeButton(R.string.action_discard) { _, _ ->
Timber.d("Discarding changes and deleting autosave uri from shared preferences")
viewModel.reset("Untitled.md") viewModel.reset("Untitled.md")
PreferenceManager.getDefaultSharedPreferences(requireContext()).edit { PreferenceManager.getDefaultSharedPreferences(requireContext()).edit {
remove(PREF_KEY_AUTOSAVE_URI) remove(PREF_KEY_AUTOSAVE_URI)
} }
} }
.setPositiveButton(R.string.action_save) { _, _ -> .setPositiveButton(R.string.action_save) { _, _ ->
Timber.d("Saving changes")
requestFileOp(REQUEST_SAVE_FILE) requestFileOp(REQUEST_SAVE_FILE)
} }
.create() .create()
@ -252,25 +283,29 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
} }
private fun requestFileOp(requestType: Int) { 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) if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) { != PackageManager.PERMISSION_GRANTED) {
Timber.i("Storage permission not granted, requesting")
requestPermissions( requestPermissions(
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
requestType requestType
) )
return 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) { val intent = when (requestType) {
REQUEST_SAVE_FILE -> { REQUEST_SAVE_FILE -> {
Timber.d("Requesting save op")
Intent(Intent.ACTION_CREATE_DOCUMENT).apply { Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = "text/markdown" type = "text/markdown"
putExtra(Intent.EXTRA_TITLE, viewModel.fileName.value) putExtra(Intent.EXTRA_TITLE, viewModel.fileName.value)
} }
} }
REQUEST_OPEN_FILE -> { REQUEST_OPEN_FILE -> {
Timber.d("Requesting open op")
Intent(Intent.ACTION_OPEN_DOCUMENT).apply { Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = "*/*" type = "*/*"
if (MimeTypeMap.getSingleton().hasMimeType("md")) { 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 } ?: return
intent.addCategory(Intent.CATEGORY_OPENABLE) intent.addCategory(Intent.CATEGORY_OPENABLE)
startActivityForResult( startActivityForResult(
@ -289,11 +327,6 @@ class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallba
) )
} }
override fun onResume() {
super.onResume()
shouldAutoSave = true
}
companion object { companion object {
// Request codes // Request codes
const val REQUEST_OPEN_FILE = 1 const val REQUEST_OPEN_FILE = 1

View file

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

View file

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

View file

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