Use Timber for logging
This commit is contained in:
parent
96e7b7c6b3
commit
14dc55433a
11 changed files with 224 additions and 54 deletions
|
@ -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'
|
||||||
|
}
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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("")
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in a new issue