Finish compose migration and remove unnecessary assets/code

This commit is contained in:
William Brawner 2023-09-12 21:20:43 -06:00
parent 493444aaab
commit ae5b13dfd0
23 changed files with 458 additions and 982 deletions

View file

@ -24,7 +24,6 @@ import androidx.test.espresso.web.webdriver.DriverAtoms.getText
import androidx.test.espresso.web.webdriver.Locator
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import androidx.test.rule.GrantPermissionRule
import com.wbrawner.simplemarkdown.view.activity.MainActivity
import org.hamcrest.Matchers.containsString
import org.junit.Assert.assertEquals
import org.junit.Before

View file

@ -15,7 +15,7 @@
android:theme="@style/Theme.App.Starting"
tools:ignore="AllowBackup"
tools:targetApi="n">
<activity android:name=".view.activity.MainActivity"
<activity android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name_short">
<intent-filter>

View file

@ -1,11 +1,9 @@
package com.wbrawner.simplemarkdown.view.activity
package com.wbrawner.simplemarkdown
import android.content.Context
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.animation.AnimatedContentTransitionScope
@ -22,7 +20,8 @@ import androidx.compose.material.icons.filled.Help
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PrivacyTip
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Text
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.core.app.ActivityCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@ -31,34 +30,28 @@ import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.findNavController
import androidx.preference.PreferenceManager
import com.wbrawner.plausible.android.Plausible
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.MarkdownApplication.Companion.fileHelper
import com.wbrawner.simplemarkdown.MarkdownApplication.Companion.preferenceHelper
import com.wbrawner.simplemarkdown.ui.MainScreen
import com.wbrawner.simplemarkdown.ui.MarkdownInfoScreen
import com.wbrawner.simplemarkdown.ui.SettingsScreen
import com.wbrawner.simplemarkdown.ui.SupportScreen
import com.wbrawner.simplemarkdown.ui.theme.SimpleMarkdownTheme
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
import com.wbrawner.simplemarkdown.utility.Preference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
const val KEY_AUTOSAVE = "autosave"
class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsResultCallback {
private val viewModel: MarkdownViewModel by viewModels()
private val viewModel: MarkdownViewModel by viewModels { MarkdownViewModel.factory(fileHelper, preferenceHelper) }
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
lifecycleScope.launch {
val darkMode = withContext(Dispatchers.IO) {
val darkModeValue = getStringPref(
R.string.pref_key_dark_mode,
getString(R.string.pref_value_auto)
)
val darkModeValue = preferenceHelper[Preference.DARK_MODE] as String
return@withContext when {
darkModeValue.equals(
@ -83,18 +76,20 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
AppCompatDelegate.setDefaultNightMode(darkMode)
}
WindowCompat.setDecorFitsSystemWindows(window, false)
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
val preferences = mutableMapOf<String, String>()
preferences["Autosave"] = sharedPreferences.getBoolean("autosave", true).toString()
val usingCustomCss = !getStringPref(R.string.pref_custom_css, null).isNullOrBlank()
preferences["Autosave"] = preferenceHelper[Preference.AUTOSAVE_ENABLED].toString()
val usingCustomCss = !(preferenceHelper[Preference.CUSTOM_CSS] as String?).isNullOrBlank()
preferences["Custom CSS"] = usingCustomCss.toString()
val darkModeSetting = getStringPref(R.string.pref_key_dark_mode, "auto").toString()
val darkModeSetting = preferenceHelper[Preference.DARK_MODE].toString()
preferences["Dark Mode"] = darkModeSetting
preferences["Error Reports"] =
getBooleanPref(R.string.pref_key_error_reports_enabled, true).toString()
preferences["Readability"] = getBooleanPref(R.string.readability_enabled, false).toString()
preferences["Error Reports"] = preferenceHelper[Preference.ERROR_REPORTS_ENABLED].toString()
preferences["Readability"] = preferenceHelper[Preference.READABILITY_ENABLED].toString()
Plausible.event("settings", props = preferences, url = "/")
setContent {
val autosaveEnabled by preferenceHelper.observe<Boolean>(Preference.AUTOSAVE_ENABLED)
.collectAsState()
val readabilityEnabled by preferenceHelper.observe<Boolean>(Preference.READABILITY_ENABLED)
.collectAsState()
SimpleMarkdownTheme {
val navController = rememberNavController()
NavHost(
@ -121,10 +116,15 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
}
) {
composable(Route.EDITOR.path) {
MainScreen(navController = navController, viewModel = viewModel)
MainScreen(
navController = navController,
viewModel = viewModel,
enableAutosave = autosaveEnabled,
enableReadability = readabilityEnabled
)
}
composable(Route.SETTINGS.path) {
SettingsScreen(navController = navController)
SettingsScreen(navController = navController, preferenceHelper)
}
composable(Route.SUPPORT.path) {
SupportScreen(navController = navController)
@ -155,16 +155,4 @@ enum class Route(
HELP("/help", "Help", Icons.Default.Help),
ABOUT("/about", "About", Icons.Default.Info),
PRIVACY("/privacy", "Privacy", Icons.Default.PrivacyTip),
}
fun Context.getBooleanPref(@StringRes key: Int, defaultValue: Boolean) =
PreferenceManager.getDefaultSharedPreferences(this).getBoolean(
getString(key),
defaultValue
)
fun Context.getStringPref(@StringRes key: Int, defaultValue: String?) =
PreferenceManager.getDefaultSharedPreferences(this).getString(
getString(key),
defaultValue
)
}

View file

@ -3,7 +3,11 @@ package com.wbrawner.simplemarkdown
import android.app.Application
import android.os.StrictMode
import com.wbrawner.plausible.android.Plausible
import com.wbrawner.simplemarkdown.utility.AndroidFileHelper
import com.wbrawner.simplemarkdown.utility.AndroidPreferenceHelper
import com.wbrawner.simplemarkdown.utility.FileHelper
import com.wbrawner.simplemarkdown.utility.PersistentTree
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
import com.wbrawner.simplemarkdown.utility.ReviewHelper
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@ -11,6 +15,7 @@ import timber.log.Timber
import java.io.File
class MarkdownApplication : Application() {
override fun onCreate() {
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
@ -40,5 +45,14 @@ class MarkdownApplication : Application() {
"Flavor" to BuildConfig.FLAVOR,
"App Version" to BuildConfig.VERSION_NAME
))
fileHelper = AndroidFileHelper(this)
preferenceHelper = AndroidPreferenceHelper(this)
}
companion object {
lateinit var fileHelper: FileHelper
private set
lateinit var preferenceHelper: PreferenceHelper
private set
}
}

View file

@ -0,0 +1,173 @@
package com.wbrawner.simplemarkdown
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import com.wbrawner.simplemarkdown.utility.FileHelper
import com.wbrawner.simplemarkdown.utility.Preference
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.io.File
import java.net.URI
import java.util.concurrent.atomic.AtomicBoolean
class MarkdownViewModel(
private val fileHelper: FileHelper,
private val preferenceHelper: PreferenceHelper
) : ViewModel() {
private val _fileName = MutableStateFlow("Untitled.md")
val fileName = _fileName.asStateFlow()
private val _markdown = MutableStateFlow("")
val markdown = _markdown.asStateFlow()
private val path = MutableStateFlow<URI?>(null)
private val isDirty = AtomicBoolean(false)
private val _effects = MutableSharedFlow<Effect>()
val effects = _effects.asSharedFlow()
private val saveMutex = Mutex()
fun updateMarkdown(markdown: String?) = viewModelScope.launch {
this@MarkdownViewModel._markdown.emit(markdown ?: "")
isDirty.set(true)
}
suspend fun load(loadPath: String?) {
if (loadPath.isNullOrBlank()) {
Timber.i("No URI provided to load, attempting to load last autosaved file")
preferenceHelper[Preference.AUTOSAVE_URI]
?.let {
val autosaveUri = it as? String
if (autosaveUri.isNullOrBlank()) {
preferenceHelper[Preference.AUTOSAVE_URI] = null
} else {
Timber.d("Using uri from shared preferences: $it")
load(autosaveUri)
}
}
return
}
try {
val uri = URI.create(loadPath)
fileHelper.open(uri)
?.let { (name, content) ->
path.emit(uri)
_effects.emit(Effect.ClearText)
_fileName.emit(name)
_markdown.emit(content)
isDirty.set(false)
preferenceHelper[Preference.AUTOSAVE_URI] = loadPath
} ?: _effects.emit(Effect.Error("Failed to open file at path: $loadPath"))
} catch (e: Exception) {
Timber.e(e, "Failed to open file at path: $loadPath")
}
}
suspend fun save(savePath: URI? = path.value, promptSavePath: Boolean = true): Boolean {
return saveMutex.withLock {
if (savePath == null) {
Timber.w("Attempted to save file with empty path")
if (promptSavePath) {
_effects.emit(Effect.OpenSaveDialog {})
}
return false
}
try {
val name = fileHelper.save(savePath, markdown.value)
_fileName.emit(name)
path.emit(savePath)
isDirty.set(false)
Timber.i("Saved file ${fileName.value} to uri $savePath")
Timber.i("Persisting autosave uri in shared prefs: $savePath")
preferenceHelper[Preference.AUTOSAVE_URI] = savePath
true
} catch (e: Exception) {
val message = "Failed to save file to $savePath"
Timber.e(e, message)
_effects.emit(Effect.Error(message))
false
}
}
}
suspend fun autosave() {
if (!isDirty.get()) {
Timber.d("Ignoring autosave as contents haven't changed")
return
}
if (saveMutex.isLocked) {
Timber.i("Ignoring autosave since manual save is already in progress")
return
}
val isAutoSaveEnabled = preferenceHelper[Preference.AUTOSAVE_ENABLED] as Boolean
if (!isAutoSaveEnabled) {
Timber.i("Ignoring autosave as autosave not enabled")
return
}
if (!save(promptSavePath = false)) {
// 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 file = File(fileHelper.defaultDirectory, fileName.value).toURI()
Timber.i("No cached uri for autosave, saving to $file instead")
save(file)
}
}
suspend fun reset(untitledFileName: String, force: Boolean = false) {
Timber.i("Resetting view model to default state")
if (!force && isDirty.get()) {
_effects.emit(Effect.Prompt(
"Would you like to save your changes?",
confirm = {
viewModelScope.launch {
_effects.emit(Effect.OpenSaveDialog {
reset(untitledFileName, false)
})
}
},
cancel = {
viewModelScope.launch {
reset(untitledFileName, true)
}
}
))
return
}
_fileName.emit(untitledFileName)
_markdown.emit("")
path.emit(null)
_effects.emit(Effect.ClearText)
isDirty.set(false)
Timber.i("Removing autosave uri from shared prefs")
preferenceHelper[Preference.AUTOSAVE_URI] = null
}
companion object {
fun factory(fileHelper: FileHelper, preferenceHelper: PreferenceHelper): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
modelClass: Class<T>,
extras: CreationExtras
): T {
return MarkdownViewModel(fileHelper, preferenceHelper) as T
}
}
}
sealed interface Effect {
data class OpenSaveDialog(val postSaveBlock: suspend () -> Unit) : Effect
data class Prompt(val text: String, val confirm: () -> Unit, val cancel: () -> Unit) :
Effect
data object ClearText : Effect
data class Error(val text: String) : Effect
}
}

View file

@ -1,8 +1,8 @@
package com.wbrawner.simplemarkdown.ui
import android.content.Intent
import android.text.SpannableString
import android.text.style.BackgroundColorSpan
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DismissibleDrawerSheet
import androidx.compose.material3.DismissibleNavigationDrawer
@ -35,9 +36,11 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -46,7 +49,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
@ -59,21 +61,95 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.startActivity
import androidx.navigation.NavController
import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.MarkdownViewModel
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.Route
import com.wbrawner.simplemarkdown.model.Readability
import com.wbrawner.simplemarkdown.view.activity.Route
import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import timber.log.Timber
import java.net.URI
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MainScreen(navController: NavController, viewModel: MarkdownViewModel) {
fun MainScreen(
navController: NavController,
viewModel: MarkdownViewModel,
enableAutosave: Boolean,
enableReadability: Boolean,
) {
var lockSwiping by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val fileName by viewModel.fileName.collectAsState()
val openFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
coroutineScope.launch {
viewModel.load(it.toString())
}
}
val saveFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) {
it?.let {
coroutineScope.launch {
viewModel.save(URI.create(it.toString()))
}
}
}
var errorMessage by remember { mutableStateOf<String?>(null) }
var promptEffect by remember { mutableStateOf<MarkdownViewModel.Effect.Prompt?>(null) }
var clearText by remember { mutableStateOf(0) }
LaunchedEffect(viewModel) {
viewModel.effects.collect { effect ->
when (effect) {
is MarkdownViewModel.Effect.OpenSaveDialog -> saveFileLauncher.launch(fileName)
is MarkdownViewModel.Effect.Error -> errorMessage = effect.text
is MarkdownViewModel.Effect.Prompt -> promptEffect = effect
is MarkdownViewModel.Effect.ClearText -> clearText++
}
}
}
LaunchedEffect(enableAutosave) {
if (!enableAutosave) return@LaunchedEffect
while (isActive) {
delay(30_000)
viewModel.autosave()
}
}
errorMessage?.let { message ->
AlertDialog(
onDismissRequest = { errorMessage = null },
confirmButton = {
TextButton(onClick = { errorMessage = null }) {
Text("OK")
}
},
text = { Text(message) }
)
}
promptEffect?.let { prompt ->
AlertDialog(
onDismissRequest = { errorMessage = null },
confirmButton = {
TextButton(onClick = {
prompt.confirm()
promptEffect = null
}) {
Text("Yes")
}
},
dismissButton = {
TextButton(onClick = {
prompt.cancel()
promptEffect = null
}) {
Text("No")
}
},
text = { Text(prompt.text) }
)
}
MarkdownNavigationDrawer(navigate = { navController.navigate(it.path) }) { drawerState ->
Scaffold(topBar = {
val fileName by viewModel.fileName.collectAsState()
val context = LocalContext.current
MarkdownTopAppBar(title = fileName,
backAsUp = false,
@ -82,7 +158,7 @@ fun MainScreen(navController: NavController, viewModel: MarkdownViewModel) {
actions = {
IconButton(onClick = {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdownUpdates.value)
shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdown.value)
shareIntent.type = "text/plain"
startActivity(
context, Intent.createChooser(
@ -101,16 +177,24 @@ fun MainScreen(navController: NavController, viewModel: MarkdownViewModel) {
onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(text = { Text("New") }, onClick = {
menuExpanded = false
coroutineScope.launch {
viewModel.reset("Untitled.md")
}
})
DropdownMenuItem(text = { Text("Open") }, onClick = {
menuExpanded = false
openFileLauncher.launch(arrayOf("text/*"))
})
DropdownMenuItem(text = { Text("Save") }, onClick = {
menuExpanded = false
coroutineScope.launch {
viewModel.save()
}
})
DropdownMenuItem(text = { Text("Save as…") },
onClick = {
menuExpanded = false
saveFileLauncher.launch(fileName)
})
DropdownMenuItem(text = {
Row(verticalAlignment = Alignment.CenterVertically) {
@ -127,13 +211,7 @@ fun MainScreen(navController: NavController, viewModel: MarkdownViewModel) {
}
})
}) { paddingValues ->
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { 2 }
val context = LocalContext.current
val enableReadability = remember {
PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(PREF_KEY_READABILITY, false)
}
Column(
modifier = Modifier
.fillMaxSize()
@ -151,8 +229,8 @@ fun MainScreen(navController: NavController, viewModel: MarkdownViewModel) {
modifier = Modifier.weight(1f), state = pagerState,
userScrollEnabled = !lockSwiping
) { page ->
val markdown by viewModel.markdownUpdates.collectAsState()
var textFieldValue by remember {
val markdown by viewModel.markdown.collectAsState()
var textFieldValue by remember(clearText) {
val annotatedMarkdown = if (enableReadability) {
markdown.annotateReadability()
} else {

View file

@ -1,10 +1,8 @@
package com.wbrawner.simplemarkdown.ui
import android.content.SharedPreferences
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@ -13,8 +11,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
@ -29,27 +25,18 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import androidx.navigation.NavController
import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.ui.theme.SimpleMarkdownTheme
const val PREF_KEY_AUTOSAVE = "autosave"
const val PREF_KEY_DARK_MODE = "darkMode"
const val PREF_KEY_ERROR_REPORTS = "crashlytics.enable"
const val PREF_KEY_ANALYTICS = "analytics.enable"
const val PREF_KEY_READABILITY = "readability.enable"
import com.wbrawner.simplemarkdown.utility.Preference
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
@Composable
fun SettingsScreen(navController: NavController) {
fun SettingsScreen(navController: NavController, preferenceHelper: PreferenceHelper) {
Scaffold(topBar = {
MarkdownTopAppBar(title = "Settings", navController = navController)
}) { paddingValues ->
val context = LocalContext.current
val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) }
Column(
modifier = Modifier
.fillMaxSize()
@ -60,37 +47,35 @@ fun SettingsScreen(navController: NavController) {
title = "Autosave",
enabledDescription = "Files will be saved automatically",
disabledDescription = "Files will not be saved automatically",
preferenceKey = PREF_KEY_AUTOSAVE,
sharedPreferences = sharedPreferences
preference = Preference.AUTOSAVE_ENABLED,
preferenceHelper = preferenceHelper
)
ListPreference(
title = "Dark mode",
options = listOf("Auto", "Dark", "Light"),
defaultValue = "Auto",
preferenceKey = PREF_KEY_DARK_MODE,
sharedPreferences = sharedPreferences
options = listOf("auto", "dark", "light"),
preference = Preference.DARK_MODE,
preferenceHelper = preferenceHelper
)
BooleanPreference(
title = "Send crash reports",
enabledDescription = "Error reports will be sent",
disabledDescription = "Error reports will not be sent",
preferenceKey = PREF_KEY_ERROR_REPORTS,
sharedPreferences = sharedPreferences
preference = Preference.ERROR_REPORTS_ENABLED,
preferenceHelper = preferenceHelper
)
BooleanPreference(
title = "Send analytics",
enabledDescription = "Analytics events will be sent",
disabledDescription = "Analytics events will not be sent",
preferenceKey = PREF_KEY_ANALYTICS,
sharedPreferences = sharedPreferences
preference = Preference.ANALYTICS_ENABLED,
preferenceHelper = preferenceHelper
)
BooleanPreference(
title = "Readability highlighting",
enabledDescription = "Readability highlighting is on",
disabledDescription = "Readability highlighting is off",
preferenceKey = PREF_KEY_READABILITY,
sharedPreferences = sharedPreferences,
defaultValue = false
preference = Preference.READABILITY_ENABLED,
preferenceHelper = preferenceHelper
)
}
}
@ -101,16 +86,11 @@ fun BooleanPreference(
title: String,
enabledDescription: String,
disabledDescription: String,
preferenceKey: String,
sharedPreferences: SharedPreferences,
defaultValue: Boolean = true
preference: Preference,
preferenceHelper: PreferenceHelper
) {
var enabled by remember {
mutableStateOf(
sharedPreferences.getBoolean(
preferenceKey, defaultValue
)
)
mutableStateOf(preferenceHelper[preference] as Boolean)
}
BooleanPreference(title = title,
enabledDescription = enabledDescription,
@ -118,9 +98,7 @@ fun BooleanPreference(
enabled = enabled,
setEnabled = {
enabled = it
sharedPreferences.edit {
putBoolean(preferenceKey, it)
}
preferenceHelper[preference] = it
})
}
@ -156,27 +134,19 @@ fun BooleanPreference(
fun ListPreference(
title: String,
options: List<String>,
defaultValue: String,
preferenceKey: String,
sharedPreferences: SharedPreferences
preference: Preference,
preferenceHelper: PreferenceHelper
) {
var selected by remember {
mutableStateOf(
sharedPreferences.getString(
preferenceKey, defaultValue
) ?: defaultValue
)
mutableStateOf(preferenceHelper[preference] as String)
}
ListPreference(title = title, options = options, selected = selected, setSelected = {
selected = it
sharedPreferences.edit {
putString(preferenceKey, it)
}
preferenceHelper[preference] = it
})
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListPreference(
title: String, options: List<String>, selected: String, setSelected: (String) -> Unit

View file

@ -0,0 +1,59 @@
package com.wbrawner.simplemarkdown.utility
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.io.Reader
import java.net.URI
interface FileHelper {
val defaultDirectory: File
/**
* Opens a file at the given path
* @param path The path of the file to open
* @return A [Pair] of the file name to the file's contents
*/
suspend fun open(source: URI): Pair<String, String>?
/**
* Saves the given content to the given path
* @param path
* @param content
* @return The name of the saved file
*/
suspend fun save(destination: URI, content: String): String
}
class AndroidFileHelper(private val context: Context) : FileHelper {
override val defaultDirectory: File = context.filesDir
override suspend fun open(source: URI): Pair<String, String>? = withContext(Dispatchers.IO) {
val uri = source.toString().toUri()
context.contentResolver.openFileDescriptor(uri, "r")
?.use {
uri.getName(context) to FileInputStream(it.fileDescriptor).reader()
.use(Reader::readText)
}
}
override suspend fun save(destination: URI, content: String): String = withContext(Dispatchers.IO) {
val uri = destination.toString().toUri()
context.contentResolver.openOutputStream(uri, "rwt")
?.writer()
?.use {
it.write(content)
}
?: run {
Timber.w("Open output stream returned null for uri: $uri")
throw IOException("Failed to save to $destination")
}
return@withContext uri.getName(context)
}
}

View file

@ -0,0 +1,67 @@
package com.wbrawner.simplemarkdown.utility
import android.content.Context
import android.net.Uri
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.IOException
interface PreferenceHelper {
operator fun get(preference: Preference): Any?
operator fun set(preference: Preference, value: Any?)
fun <T> observe(preference: Preference): StateFlow<T>
}
class AndroidPreferenceHelper(context: Context, val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO)): PreferenceHelper {
private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
private val states = mapOf(
Preference.ANALYTICS_ENABLED to MutableStateFlow(get(Preference.ANALYTICS_ENABLED)),
Preference.AUTOSAVE_ENABLED to MutableStateFlow(get(Preference.AUTOSAVE_ENABLED)),
Preference.AUTOSAVE_URI to MutableStateFlow(get(Preference.AUTOSAVE_URI)),
Preference.CUSTOM_CSS to MutableStateFlow(get(Preference.CUSTOM_CSS)),
Preference.DARK_MODE to MutableStateFlow(get(Preference.DARK_MODE)),
Preference.ERROR_REPORTS_ENABLED to MutableStateFlow(get(Preference.ERROR_REPORTS_ENABLED)),
Preference.READABILITY_ENABLED to MutableStateFlow(get(Preference.READABILITY_ENABLED)),
)
override fun get(preference: Preference): Any? = sharedPreferences.all[preference.key]?: preference.default
override fun set(preference: Preference, value: Any?) {
sharedPreferences.edit {
when (value) {
is Boolean -> putBoolean(preference.key, value)
is Float -> putFloat(preference.key, value)
is Int -> putInt(preference.key, value)
is Long -> putLong(preference.key, value)
is String -> putString(preference.key, value)
null -> remove(preference.key)
}
}
coroutineScope.launch {
states[preference]!!.emit(value)
}
}
override fun <T> observe(preference: Preference): StateFlow<T> = states[preference]!!.asStateFlow() as StateFlow<T>
}
enum class Preference(val key: String, val default: Any?) {
ANALYTICS_ENABLED("analytics.enable", true),
AUTOSAVE_ENABLED("autosave", true),
AUTOSAVE_URI("autosave.uri", null),
CUSTOM_CSS("pref.custom_css", null),
DARK_MODE("darkMode", "Auto"),
ERROR_REPORTS_ENABLED("crashlytics.enable", true),
READABILITY_ENABLED("readability.enable", false)
}

View file

@ -1,179 +0,0 @@
//package com.wbrawner.simplemarkdown.view.fragment
//
//import android.annotation.SuppressLint
//import android.graphics.Color
//import android.os.Bundle
//import android.text.Editable
//import android.text.SpannableString
//import android.text.TextWatcher
//import android.text.style.BackgroundColorSpan
//import android.view.LayoutInflater
//import android.view.MotionEvent
//import android.view.View
//import android.view.ViewGroup
//import android.widget.EditText
//import android.widget.TextView
//import androidx.core.widget.NestedScrollView
//import androidx.fragment.app.Fragment
//import androidx.fragment.app.viewModels
//import androidx.lifecycle.Observer
//import androidx.lifecycle.lifecycleScope
//import androidx.preference.PreferenceManager
//import com.wbrawner.simplemarkdown.R
//import com.wbrawner.simplemarkdown.model.Readability
//import com.wbrawner.simplemarkdown.utility.hideKeyboard
//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 {
// private var markdownEditor: EditText? = null
// private var markdownEditorScroller: NestedScrollView? = null
// private val viewModel: MarkdownViewModel by viewModels({ requireParentFragment() })
// private var readabilityWatcher: TextWatcher? = null
// private val markdownWatcher = object : TextWatcher {
// private var searchFor = ""
//
// override fun afterTextChanged(s: Editable?) {
// val searchText = s.toString().trim()
// if (searchText == searchFor)
// return
//
// searchFor = searchText
//
// lifecycleScope.launch {
// delay(50)
// if (searchText != searchFor)
// return@launch
// viewModel.updateMarkdown(searchText)
// }
// }
//
// override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
// }
//
// override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
// }
// }
//
// @SuppressLint("ClickableViewAccessibility")
// override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
// savedInstanceState: Bundle?): View? =
// inflater.inflate(R.layout.fragment_edit, container, false)
//
// @SuppressLint("ClickableViewAccessibility")
// override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// super.onViewCreated(view, savedInstanceState)
// markdownEditor = view.findViewById(R.id.markdown_edit)
// markdownEditorScroller = view.findViewById(R.id.markdown_edit_container)
// markdownEditor?.addTextChangedListener(markdownWatcher)
//
// var touchDown = 0L
// var oldX = 0f
// var oldY = 0f
// markdownEditorScroller!!.setOnTouchListener { _, event ->
// // The ScrollView's onClickListener doesn't seem to be called, so I've had to
// // implement a sort of custom click listener that checks that the tap was both quick
// // and didn't drag.
// when (event?.action) {
// MotionEvent.ACTION_DOWN -> {
// touchDown = System.currentTimeMillis()
// oldX = event.rawX
// oldY = event.rawY
// }
// MotionEvent.ACTION_UP -> {
// if (System.currentTimeMillis() - touchDown < 150
// && abs(event.rawX - oldX) < 25
// && abs(event.rawY - oldY) < 25)
// markdownEditor?.showKeyboard()
// }
// }
// false
// }
// markdownEditor?.setText(viewModel.markdownUpdates.value)
// viewModel.editorActions.observe(viewLifecycleOwner, Observer {
// if (it.consumed.getAndSet(true)) return@Observer
// if (it is MarkdownViewModel.EditorAction.Load) {
// markdownEditor?.apply {
// removeTextChangedListener(markdownWatcher)
// setText(it.markdown)
// addTextChangedListener(markdownWatcher)
// }
// }
// })
// lifecycleScope.launch {
// val enableReadability = withContext(Dispatchers.IO) {
// context?.let {
// PreferenceManager.getDefaultSharedPreferences(it)
// .getBoolean(getString(R.string.readability_enabled), false)
// } ?: false
// }
// if (enableReadability) {
// if (readabilityWatcher == null) {
// readabilityWatcher = ReadabilityTextWatcher()
// }
// markdownEditor?.addTextChangedListener(readabilityWatcher)
// } else {
// readabilityWatcher?.let {
// markdownEditor?.removeTextChangedListener(it)
// }
// readabilityWatcher = null
// }
// }
// }
//
// override fun onSelected() {
// markdownEditor?.showKeyboard()
// }
//
// override fun onDeselected() {
// markdownEditor?.hideKeyboard()
// }
//
// inner class ReadabilityTextWatcher : TextWatcher {
// private var previousValue = ""
// private var searchFor = ""
//
// override fun afterTextChanged(s: Editable?) {
// val searchText = s.toString().trim()
// if (searchText == searchFor)
// return
//
// searchFor = searchText
//
// lifecycleScope.launch {
// delay(250)
// if (searchText != searchFor)
// return@launch
// val start = System.currentTimeMillis()
// if (searchFor.isEmpty()) return@launch
// if (previousValue == searchFor) return@launch
// val readability = Readability(searchFor)
// val span = SpannableString(searchFor)
// for (sentence in readability.sentences()) {
// 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)
// 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
// Timber.d("Handled markdown in $timeTakenMs ms")
// }
// }
//
// override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
// }
//
// override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
// }
// }
//}

View file

@ -1,335 +0,0 @@
//package com.wbrawner.simplemarkdown.view.fragment
//
//import android.app.Activity
//import android.content.Context
//import android.content.Intent
//import android.content.pm.PackageManager
//import android.content.res.Configuration
//import android.os.Build
//import android.os.Bundle
//import android.view.*
//import android.webkit.MimeTypeMap
//import android.widget.Toast
//import androidx.appcompat.app.AlertDialog
//import androidx.appcompat.app.AppCompatActivity
//import androidx.core.app.ActivityCompat
//import androidx.fragment.app.Fragment
//import androidx.fragment.app.viewModels
//import androidx.lifecycle.lifecycleScope
//import androidx.navigation.fragment.findNavController
//import androidx.navigation.ui.AppBarConfiguration
//import androidx.navigation.ui.setupWithNavController
//import androidx.preference.PreferenceManager
//import com.wbrawner.plausible.android.Plausible
//import com.wbrawner.simplemarkdown.R
//import com.wbrawner.simplemarkdown.databinding.FragmentMainBinding
//import com.wbrawner.simplemarkdown.utility.ErrorHandler
//import com.wbrawner.simplemarkdown.utility.errorHandlerImpl
//import com.wbrawner.simplemarkdown.view.adapter.EditPagerAdapter
//import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
//import kotlinx.coroutines.Dispatchers
//import kotlinx.coroutines.launch
//import kotlinx.coroutines.withContext
//import timber.log.Timber
//
//class MainFragment : Fragment(), ActivityCompat.OnRequestPermissionsResultCallback {
//
// private val viewModel: MarkdownViewModel by viewModels()
// private var appBarConfiguration: AppBarConfiguration? = null
// private val errorHandler: ErrorHandler by errorHandlerImpl()
// private var _binding: FragmentMainBinding? = null
// private val binding: FragmentMainBinding
// get() = _binding!!
//
// override fun onAttach(context: Context) {
// super.onAttach(context)
// if (context !is Activity) return
// lifecycleScope.launch {
// viewModel.load(context, context.intent?.data)
// context.intent?.data = null
// }
// }
//
// override fun onCreate(savedInstanceState: Bundle?) {
// super.onCreate(savedInstanceState)
// setHasOptionsMenu(true)
// }
//
// override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
// inflater.inflate(R.menu.menu_edit, menu)
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// menu.findItem(R.id.action_save_as)
// ?.setAlphabeticShortcut('S', KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON)
// }
// }
//
// override fun onCreateView(
// inflater: LayoutInflater,
// container: ViewGroup?,
// savedInstanceState: Bundle?
// ): View {
// _binding = FragmentMainBinding.inflate(inflater, container, false)
// return binding.root
// }
//
// override fun onDestroyView() {
// super.onDestroyView()
// _binding = null
// }
//
// override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// with(findNavController()) {
// appBarConfiguration = AppBarConfiguration(graph, binding.drawerLayout)
// binding.toolbar.setupWithNavController(this, appBarConfiguration!!)
// (activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar)
// binding.navigationView.setupWithNavController(this)
// }
// val adapter = EditPagerAdapter(childFragmentManager, view.context)
// binding.pager.adapter = adapter
// binding.pager.addOnPageChangeListener(adapter)
// binding.pager.pageMargin = 1
// binding.pager.setPageMarginDrawable(R.color.colorAccent)
// binding.tabLayout.setupWithViewPager(binding.pager)
// if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
// binding.tabLayout.visibility = View.GONE
// }
// @Suppress("CAST_NEVER_SUCCEEDS")
// viewModel.fileName.observe(viewLifecycleOwner) {
// binding.toolbar.title = it
// }
// }
//
// override fun onOptionsItemSelected(item: MenuItem): Boolean {
// return when (item.itemId) {
// android.R.id.home -> {
// binding.drawerLayout.open()
// true
// }
//
// R.id.action_save -> {
// Timber.d("Save clicked")
// lifecycleScope.launch {
// if (!viewModel.save(requireContext())) {
// requestFileOp(REQUEST_SAVE_FILE)
// } else {
// Toast.makeText(
// requireContext(),
// getString(R.string.file_saved, viewModel.fileName.value),
// Toast.LENGTH_SHORT
// ).show()
// }
// }
// 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"
// startActivity(
// Intent.createChooser(
// shareIntent,
// getString(R.string.share_file)
// )
// )
// 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
// binding.pager.setSwipeLocked(item.isChecked)
// true
// }
//
// else -> super.onOptionsItemSelected(item)
// }
// }
//
// override fun onStart() {
// super.onStart()
// Plausible.pageView("")
// lifecycleScope.launch {
// 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)
// }
// }
// }
//
// override fun onStop() {
// super.onStop()
// val context = context?.applicationContext ?: return
// lifecycleScope.launch {
// viewModel.autosave(context, PreferenceManager.getDefaultSharedPreferences(context))
// }
// }
//
// override fun onConfigurationChanged(newConfig: Configuration) {
// super.onConfigurationChanged(newConfig)
// if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) {
// Timber.d("Orientation changed to landscape, hiding tabs")
// binding.tabLayout.visibility = View.GONE
// } else {
// Timber.d("Orientation changed to portrait, showing tabs")
// binding.tabLayout.visibility = View.VISIBLE
// }
// }
//
// override fun onRequestPermissionsResult(
// requestCode: Int,
// permissions: Array<String>,
// grantResults: IntArray
// ) {
// when (requestCode) {
// REQUEST_SAVE_FILE, REQUEST_OPEN_FILE -> {
// // 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()
// }
// }
// }
// }
// }
//
// override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
// 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
// }
//
// lifecycleScope.launch {
// context?.let {
// if (!viewModel.load(it, data.data)) {
// Toast.makeText(it, R.string.file_load_error, Toast.LENGTH_SHORT).show()
// }
// }
// }
// }
//
// 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 {
// viewModel.save(it, data.data)
// }
// }
// }
// }
// super.onActivityResult(requestCode, resultCode, data)
// }
//
// private fun promptSaveOrDiscardChanges() {
// if (!viewModel.shouldPromptSave()) {
// viewModel.reset(
// "Untitled.md",
// PreferenceManager.getDefaultSharedPreferences(requireContext())
// )
// 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")
// viewModel.reset(
// "Untitled.md",
// PreferenceManager.getDefaultSharedPreferences(requireContext())
// )
// }
// .setPositiveButton(R.string.action_save) { _, _ ->
// Timber.d("Saving changes")
// requestFileOp(REQUEST_SAVE_FILE)
// }
// .create()
// .show()
// }
//
// private fun requestFileOp(requestType: Int) {
// 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")) {
// // If the device doesn't recognize markdown files then we're not going to be
// // able to open them at all, so there's no sense in filtering them out.
// putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("text/plain", "text/markdown"))
// }
// }
// }
//
// else -> {
// Timber.w("Ignoring unknown file op request: $requestType")
// null
// }
// } ?: return
// intent.addCategory(Intent.CATEGORY_OPENABLE)
// startActivityForResult(
// intent,
// requestType
// )
// }
//
// companion object {
// // Request codes
// const val REQUEST_OPEN_FILE = 1
// const val REQUEST_SAVE_FILE = 2
// }
//}

View file

@ -1,96 +0,0 @@
//package com.wbrawner.simplemarkdown.view.fragment
//
//import android.content.Context
//import android.content.res.Configuration.UI_MODE_NIGHT_MASK
//import android.content.res.Configuration.UI_MODE_NIGHT_YES
//import android.os.Bundle
//import android.view.LayoutInflater
//import android.view.View
//import android.view.ViewGroup
//import android.webkit.WebView
//import androidx.appcompat.app.AppCompatDelegate
//import androidx.fragment.app.Fragment
//import androidx.fragment.app.viewModels
//import androidx.lifecycle.lifecycleScope
//import androidx.preference.PreferenceManager
//import com.wbrawner.simplemarkdown.BuildConfig
//import com.wbrawner.simplemarkdown.R
//import com.wbrawner.simplemarkdown.utility.toHtml
//import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel
//import kotlinx.coroutines.*
//
//class PreviewFragment : Fragment() {
// private val viewModel: MarkdownViewModel by viewModels({ requireParentFragment() })
// private var markdownPreview: WebView? = null
// private var style: String = ""
//
// override fun onCreateView(
// inflater: LayoutInflater,
// container: ViewGroup?,
// savedInstanceState: Bundle?
// ): View? = inflater.inflate(R.layout.fragment_preview, container, false)
//
// override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// markdownPreview = view.findViewById(R.id.markdown_view)
// WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
// lifecycleScope.launch {
// val isNightMode = AppCompatDelegate.getDefaultNightMode() ==
// AppCompatDelegate.MODE_NIGHT_YES
// || requireContext().resources.configuration.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES
// val defaultCssId = if (isNightMode) {
// R.string.pref_custom_css_default_dark
// } else {
// R.string.pref_custom_css_default
// }
// val css = withContext(Dispatchers.IO) {
// val context = context ?: return@withContext null
// @Suppress("ConstantConditionIf")
// if (!BuildConfig.ENABLE_CUSTOM_CSS) {
// context.getString(defaultCssId)
// } else {
// PreferenceManager.getDefaultSharedPreferences(context)
// .getString(
// getString(R.string.pref_custom_css),
// getString(defaultCssId)
// )
// }
// }
// style = String.format(FORMAT_CSS, css ?: "")
// }
// }
//
// override fun onAttach(context: Context) {
// super.onAttach(context)
// updateWebContent(viewModel.markdownUpdates.value ?: "")
//// viewModel.markdownUpdates.observe(this, {
//// updateWebContent(it)
//// })
// }
//
// private fun updateWebContent(markdown: String) {
// markdownPreview?.post {
// lifecycleScope.launch {
// markdownPreview?.loadDataWithBaseURL(null,
// style + markdown.toHtml(),
// "text/html",
// "UTF-8", null
// )
// }
// }
// }
//
// override fun onDestroyView() {
// markdownPreview?.let {
// (it.parent as ViewGroup).removeView(it)
// it.destroy()
// markdownPreview = null
// }
// super.onDestroyView()
// }
//
// companion object {
// var FORMAT_CSS = "<style>" +
// "%s" +
// "</style>"
// }
//}

View file

@ -1,178 +0,0 @@
package com.wbrawner.simplemarkdown.viewmodel
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import androidx.core.content.edit
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.preference.PreferenceManager
import com.wbrawner.simplemarkdown.utility.getName
import com.wbrawner.simplemarkdown.view.activity.KEY_AUTOSAVE
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileInputStream
import java.io.Reader
import java.util.concurrent.atomic.AtomicBoolean
const val PREF_KEY_AUTOSAVE_URI = "autosave.uri"
class MarkdownViewModel(val timber: Timber.Tree = Timber.asTree()) : ViewModel() {
val fileName = MutableStateFlow("Untitled.md")
val markdownUpdates = MutableStateFlow("")
val editorActions = MutableLiveData<EditorAction>()
val uri = MutableLiveData<Uri?>()
private val isDirty = AtomicBoolean(false)
private val saveMutex = Mutex()
init {
markdownUpdates
}
fun updateMarkdown(markdown: String?) = viewModelScope.launch {
markdownUpdates.emit(markdown ?: "")
isDirty.set(true)
}
suspend fun load(
context: Context,
uri: Uri?,
sharedPrefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
): Boolean {
if (uri == null) {
timber.i("No URI provided to load, attempting to load last autosaved file")
sharedPrefs.getString(PREF_KEY_AUTOSAVE_URI, null)
?.let {
Timber.d("Using uri from shared preferences: $it")
return load(context, Uri.parse(it), sharedPrefs)
} ?: return false
}
return withContext(Dispatchers.IO) {
try {
context.contentResolver.openFileDescriptor(uri, "r")?.use {
val fileInput = FileInputStream(it.fileDescriptor)
val fileName = uri.getName(context)
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
}
editorActions.postValue(EditorAction.Load(content))
markdownUpdates.emit(content)
this@MarkdownViewModel.fileName.emit(fileName)
this@MarkdownViewModel.uri.postValue(uri)
timber.i("Loaded file $fileName from $fileInput")
timber.v("File contents:\n$content")
isDirty.set(false)
timber.i("Persisting autosave uri in shared prefs: $uri")
sharedPrefs.edit()
.putString(PREF_KEY_AUTOSAVE_URI, uri.toString())
.apply()
true
} ?: 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? = null,
sharedPrefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
): Boolean = saveMutex.withLock {
val uri = givenUri?.let {
timber.i("Saving file with given uri: $it")
it
} ?: this.uri.value?.let {
timber.i("Saving file with cached uri: $it")
it
} ?: run {
timber.w("Save called with no uri")
return@save false
}
return withContext(Dispatchers.IO) {
try {
val fileName = uri.getName(context)
context.contentResolver.openOutputStream(uri, "rwt")
?.writer()
?.use {
it.write(markdownUpdates.value ?: "")
}
?: run {
timber.w("Open output stream returned null for uri: $uri")
return@withContext false
}
this@MarkdownViewModel.fileName.emit(fileName)
this@MarkdownViewModel.uri.postValue(uri)
isDirty.set(false)
timber.i("Saved file $fileName to uri $uri")
timber.i("Persisting autosave uri in shared prefs: $uri")
sharedPrefs.edit()
.putString(PREF_KEY_AUTOSAVE_URI, uri.toString())
.apply()
true
} catch (e: Exception) {
timber.e(e, "Failed to save file at uri: $uri")
false
}
}
}
suspend fun autosave(context: Context, sharedPrefs: SharedPreferences) {
if (saveMutex.isLocked) {
timber.i("Ignoring autosave since manual save is already in progress")
return
}
val isAutoSaveEnabled = sharedPrefs.getBoolean(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
}
if (save(context)) {
timber.i("Autosave with cached uri succeeded: ${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"))
timber.i("No cached uri for autosave, saving to $fileUri instead")
save(context, fileUri)
}
}
fun reset(untitledFileName: String, sharedPrefs: SharedPreferences) = viewModelScope.launch{
timber.i("Resetting view model to default state")
fileName.tryEmit(untitledFileName)
uri.postValue(null)
markdownUpdates.emit("")
editorActions.postValue(EditorAction.Load(""))
isDirty.set(false)
timber.i("Removing autosave uri from shared prefs")
sharedPrefs.edit {
remove(PREF_KEY_AUTOSAVE_URI)
}
}
fun shouldPromptSave() = isDirty.get()
sealed class EditorAction {
val consumed = AtomicBoolean(false)
data class Load(val markdown: String) : EditorAction()
}
}

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,19h-2v-2h2v2zM15.07,11.25l-0.9,0.92C13.45,12.9 13,13.5 13,15h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2L8,9c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorOnBackground"
android:pathData="M3,18h18v-2L3,16v2zM3,13h18v-2L3,11v2zM3,6v2h18L21,6L3,6z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M19.1,12.9a2.8,2.8 0,0 0,0.1 -0.9,2.8 2.8,0 0,0 -0.1,-0.9l2.1,-1.6a0.7,0.7 0,0 0,0.1 -0.6L19.4,5.5a0.7,0.7 0,0 0,-0.6 -0.2l-2.4,1a6.5,6.5 0,0 0,-1.6 -0.9l-0.4,-2.6a0.5,0.5 0,0 0,-0.5 -0.4H10.1a0.5,0.5 0,0 0,-0.5 0.4L9.3,5.4a5.6,5.6 0,0 0,-1.7 0.9l-2.4,-1a0.4,0.4 0,0 0,-0.5 0.2l-2,3.4c-0.1,0.2 0,0.4 0.2,0.6l2,1.6a2.8,2.8 0,0 0,-0.1 0.9,2.8 2.8,0 0,0 0.1,0.9L2.8,14.5a0.7,0.7 0,0 0,-0.1 0.6l1.9,3.4a0.7,0.7 0,0 0,0.6 0.2l2.4,-1a6.5,6.5 0,0 0,1.6 0.9l0.4,2.6a0.5,0.5 0,0 0,0.5 0.4h3.8a0.5,0.5 0,0 0,0.5 -0.4l0.3,-2.6a5.6,5.6 0,0 0,1.7 -0.9l2.4,1a0.4,0.4 0,0 0,0.5 -0.2l2,-3.4c0.1,-0.2 0,-0.4 -0.2,-0.6ZM12,15.6A3.6,3.6 0,1 1,15.6 12,3.6 3.6,0 0,1 12,15.6Z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorOnBackground"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z" />
</vector>

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:top="0dp"
android:bottom="0dp"
android:left="0dp"
android:right="0dp"
android:drawable="@color/colorPrimary"/>
<item
android:drawable="@drawable/splash_fg"
android:gravity="center"/>
</layer-list>

View file

@ -9,7 +9,7 @@ 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 com.wbrawner.simplemarkdown.MainActivity
import timber.log.Timber
private const val KEY_TIME_IN_APP = "timeInApp"