diff --git a/app/src/androidTest/java/com/wbrawner/simplemarkdown/MarkdownTests.kt b/app/src/androidTest/java/com/wbrawner/simplemarkdown/MarkdownTests.kt index 08c581f..bb847ef 100644 --- a/app/src/androidTest/java/com/wbrawner/simplemarkdown/MarkdownTests.kt +++ b/app/src/androidTest/java/com/wbrawner/simplemarkdown/MarkdownTests.kt @@ -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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9245ef4..a894a06 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,7 @@ android:theme="@style/Theme.App.Starting" tools:ignore="AllowBackup" tools:targetApi="n"> - diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/view/activity/MainActivity.kt b/app/src/main/java/com/wbrawner/simplemarkdown/MainActivity.kt similarity index 77% rename from app/src/main/java/com/wbrawner/simplemarkdown/view/activity/MainActivity.kt rename to app/src/main/java/com/wbrawner/simplemarkdown/MainActivity.kt index 1ee9ca0..6c65f34 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/view/activity/MainActivity.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/MainActivity.kt @@ -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() - 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(Preference.AUTOSAVE_ENABLED) + .collectAsState() + val readabilityEnabled by preferenceHelper.observe(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 - ) \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownApplication.kt b/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownApplication.kt index e6f232f..00af74a 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownApplication.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownApplication.kt @@ -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 } } diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownViewModel.kt b/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownViewModel.kt new file mode 100644 index 0000000..ba3483c --- /dev/null +++ b/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownViewModel.kt @@ -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(null) + private val isDirty = AtomicBoolean(false) + private val _effects = MutableSharedFlow() + 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 create( + modelClass: Class, + 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 + } +} diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MainScreen.kt b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MainScreen.kt index 7a0aa39..b74f62b 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MainScreen.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MainScreen.kt @@ -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(null) } + var promptEffect by remember { mutableStateOf(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 { diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/ui/SettingsScreen.kt b/app/src/main/java/com/wbrawner/simplemarkdown/ui/SettingsScreen.kt index 3aef1ed..97b3189 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/SettingsScreen.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/SettingsScreen.kt @@ -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, - 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, selected: String, setSelected: (String) -> Unit diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/utility/FileHelper.kt b/app/src/main/java/com/wbrawner/simplemarkdown/utility/FileHelper.kt new file mode 100644 index 0000000..326b6c7 --- /dev/null +++ b/app/src/main/java/com/wbrawner/simplemarkdown/utility/FileHelper.kt @@ -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? + + /** + * 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? = 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/utility/PreferenceHelper.kt b/app/src/main/java/com/wbrawner/simplemarkdown/utility/PreferenceHelper.kt new file mode 100644 index 0000000..e50bc10 --- /dev/null +++ b/app/src/main/java/com/wbrawner/simplemarkdown/utility/PreferenceHelper.kt @@ -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 observe(preference: Preference): StateFlow +} + +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 observe(preference: Preference): StateFlow = states[preference]!!.asStateFlow() as StateFlow +} + +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) +} \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/EditFragment.kt b/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/EditFragment.kt deleted file mode 100644 index ee93280..0000000 --- a/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/EditFragment.kt +++ /dev/null @@ -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) { -// } -// } -//} diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/MainFragment.kt b/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/MainFragment.kt deleted file mode 100644 index b44b6d9..0000000 --- a/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/MainFragment.kt +++ /dev/null @@ -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, -// 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 -// } -//} diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/PreviewFragment.kt b/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/PreviewFragment.kt deleted file mode 100644 index 090129d..0000000 --- a/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/PreviewFragment.kt +++ /dev/null @@ -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 = "" -// } -//} diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/viewmodel/MarkdownViewModel.kt b/app/src/main/java/com/wbrawner/simplemarkdown/viewmodel/MarkdownViewModel.kt deleted file mode 100644 index d582ac3..0000000 --- a/app/src/main/java/com/wbrawner/simplemarkdown/viewmodel/MarkdownViewModel.kt +++ /dev/null @@ -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() - val uri = MutableLiveData() - 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() - } -} diff --git a/app/src/main/res/drawable/ic_action_select.xml b/app/src/main/res/drawable/ic_action_select.xml deleted file mode 100644 index 599bf75..0000000 --- a/app/src/main/res/drawable/ic_action_select.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_eye_black_24dp.xml b/app/src/main/res/drawable/ic_eye_black_24dp.xml deleted file mode 100644 index 64e7429..0000000 --- a/app/src/main/res/drawable/ic_eye_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_favorite_black_24dp.xml b/app/src/main/res/drawable/ic_favorite_black_24dp.xml deleted file mode 100644 index 17cea92..0000000 --- a/app/src/main/res/drawable/ic_favorite_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_help_black_24dp.xml b/app/src/main/res/drawable/ic_help_black_24dp.xml deleted file mode 100644 index b1d7a2c..0000000 --- a/app/src/main/res/drawable/ic_help_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_info_black_24dp.xml b/app/src/main/res/drawable/ic_info_black_24dp.xml deleted file mode 100644 index 0cbb996..0000000 --- a/app/src/main/res/drawable/ic_info_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_menu_black_24dp.xml b/app/src/main/res/drawable/ic_menu_black_24dp.xml deleted file mode 100644 index bdceee4..0000000 --- a/app/src/main/res/drawable/ic_menu_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_settings_black_24dp.xml b/app/src/main/res/drawable/ic_settings_black_24dp.xml deleted file mode 100644 index a671514..0000000 --- a/app/src/main/res/drawable/ic_settings_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml deleted file mode 100644 index 88573a9..0000000 --- a/app/src/main/res/drawable/ic_share.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/splash_bg.xml b/app/src/main/res/drawable/splash_bg.xml deleted file mode 100644 index 5c50008..0000000 --- a/app/src/main/res/drawable/splash_bg.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/play/java/com/wbrawner/simplemarkdown/utility/ReviewHelper.kt b/app/src/play/java/com/wbrawner/simplemarkdown/utility/ReviewHelper.kt index 7dbd706..eea5807 100644 --- a/app/src/play/java/com/wbrawner/simplemarkdown/utility/ReviewHelper.kt +++ b/app/src/play/java/com/wbrawner/simplemarkdown/utility/ReviewHelper.kt @@ -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"