Fix tests

This commit is contained in:
William Brawner 2024-02-15 16:09:31 -07:00
parent a6616550dd
commit e0d43d5154
13 changed files with 948 additions and 295 deletions

View file

@ -153,6 +153,7 @@ dependencies {
androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.compose.ui:ui-test-junit4")
val coroutinesVersion = "1.7.1" val coroutinesVersion = "1.7.1"
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
val lifecycleVersion = "2.2.0" val lifecycleVersion = "2.2.0"
implementation("androidx.lifecycle:lifecycle-extensions:$lifecycleVersion") implementation("androidx.lifecycle:lifecycle-extensions:$lifecycleVersion")
kapt("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion") kapt("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion")

View file

@ -0,0 +1,269 @@
package com.wbrawner.simplemarkdown
import android.app.Activity.RESULT_OK
import android.app.Instrumentation
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.webkit.WebView
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.NativeKeyEvent
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.hasAnyDescendant
import androidx.compose.ui.test.hasAnySibling
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performKeyPress
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTextInputSelection
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.printToLog
import androidx.compose.ui.text.TextRange
import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.rule.IntentsRule
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches
import androidx.test.espresso.web.sugar.Web.onWebView
import androidx.test.espresso.web.webdriver.DriverAtoms.findElement
import androidx.test.espresso.web.webdriver.DriverAtoms.getText
import androidx.test.espresso.web.webdriver.Locator
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import kotlinx.coroutines.test.runTest
import org.hamcrest.CoreMatchers.containsString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.io.File
import java.io.Reader
class MarkdownTests {
@get:Rule
val composeRule = createEmptyComposeRule()
@get:Rule
val intentsRule = IntentsRule()
private lateinit var file: File
@Before
fun setup() {
file = File(getApplicationContext<Context>().filesDir.absolutePath + "/tmp", "temp.md")
assertTrue(requireNotNull(file.parentFile).mkdirs())
file.delete()
}
@Test
@Throws(Exception::class)
fun openAppTest() {
val context = getInstrumentation().targetContext
context.packageManager
.getLaunchIntentForPackage(context.packageName)
.apply { context.startActivity(this) }
}
@Test
fun editAndPreviewMarkdownTest() {
ActivityScenario.launch(MainActivity::class.java)
composeRule.typeMarkdown("# Header test")
composeRule.checkMarkdownEquals("# Header test")
composeRule.openPreview()
onWebView(isAssignableFrom(WebView::class.java))
.forceJavascriptEnabled()
.withElement(findElement(Locator.TAG_NAME, "h1"))
.check(webMatches(getText(), containsString("Header test")))
}
@Test
fun openThenNewMarkdownTest() {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
ActivityScenario.launch(MainActivity::class.java)
composeRule.openMenu()
composeRule.clickOpenMenuItem()
composeRule.checkMarkdownEquals(markdownText)
composeRule.openMenu()
composeRule.clickNewMenuItem()
composeRule.verifyDialogIsNotShown()
composeRule.checkMarkdownEquals("")
}
@Test
fun editThenNewMarkdownTest() {
ActivityScenario.launch(MainActivity::class.java)
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
composeRule.typeMarkdown(markdownText)
composeRule.openMenu()
composeRule.clickNewMenuItem()
composeRule.onNode(isDialog()).printToLog("TestDebugging")
composeRule.verifyDialogIsShown("Would you like to save your changes?")
composeRule.discardChanges()
composeRule.checkMarkdownEquals("")
}
@Test
fun saveMarkdownWithFileUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
composeRule.typeMarkdown(markdownText)
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
})
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
composeRule.openMenu()
composeRule.clickSaveMenuItem()
composeRule.awaitIdle()
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
composeRule.checkTitleEquals("temp.md")
}
@Test
fun saveMarkdownWithContentUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
composeRule.typeMarkdown(markdownText)
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = FileProvider.getUriForFile(
getApplicationContext(),
"${BuildConfig.APPLICATION_ID}.fileprovider",
file
)
})
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
composeRule.openMenu()
composeRule.clickSaveMenuItem()
composeRule.awaitIdle()
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
composeRule.checkTitleEquals("temp.md")
}
@Test
fun loadMarkdownWithFileUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
composeRule.openMenu()
composeRule.clickOpenMenuItem()
composeRule.awaitIdle()
composeRule.checkMarkdownEquals(markdownText)
composeRule.checkTitleEquals("temp.md")
}
@Test
fun loadMarkdownWithContentUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = FileProvider.getUriForFile(
getApplicationContext(),
"${BuildConfig.APPLICATION_ID}.fileprovider",
file
)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
composeRule.openMenu()
composeRule.clickOpenMenuItem()
composeRule.awaitIdle()
composeRule.checkMarkdownEquals(markdownText)
composeRule.checkTitleEquals("temp.md")
}
@Test
fun openEditAndSaveMarkdownTest() = runTest {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md")
composeRule.openMenu()
composeRule.clickOpenMenuItem()
composeRule.awaitIdle()
composeRule.verifyTextIsShown("Successfully loaded temp.md")
composeRule.checkMarkdownEquals(markdownText)
composeRule.checkTitleEquals("temp.md")
val additionalText = "# More info\n\nThis is some additional text"
composeRule.typeMarkdown(additionalText)
composeRule.openMenu()
composeRule.clickSaveMenuItem()
composeRule.awaitIdle()
composeRule.verifyTextIsShown("Successfully saved temp.md")
assertEquals(additionalText, file.inputStream().reader().use(Reader::readText))
composeRule.checkTitleEquals("temp.md")
}
private fun ComposeTestRule.checkTitleEquals(title: String) =
onNode(hasAnySibling(hasContentDescription("Main Menu")).and(hasText(title)))
.assertIsDisplayed()
private fun ComposeTestRule.typeMarkdown(markdown: String) =
onNode(hasSetTextAction()).performTextReplacement(markdown)
private fun ComposeTestRule.checkMarkdownEquals(markdown: String) {
val markdownMatcher = SemanticsMatcher("Markdown = [$markdown]") {
it.config.getOrNull(SemanticsProperties.EditableText)?.text == markdown
}
onNode(hasSetTextAction()).assert(markdownMatcher)
}
private fun ComposeTestRule.openPreview() = onNodeWithText("Preview").performClick()
private fun ComposeTestRule.openMenu() =
onNodeWithContentDescription("Editor Actions").performClick()
private fun ComposeTestRule.clickOpenMenuItem() = onNodeWithText("Open").performClick()
private fun ComposeTestRule.clickNewMenuItem() = onNodeWithText("New").performClick()
private fun ComposeTestRule.clickSaveMenuItem() = onNodeWithText("Save").performClick()
private fun ComposeTestRule.verifyDialogIsShown(text: String) =
onNode(isDialog().and(hasAnyDescendant(hasText(text)))).assertIsDisplayed()
private fun ComposeTestRule.verifyDialogIsNotShown() = onNode(isDialog()).assertIsNotDisplayed()
private fun ComposeTestRule.discardChanges() = onNodeWithText("No").performClick()
private fun ComposeTestRule.verifyTextIsShown(text: String) =
onNodeWithText(text).assertIsDisplayed()
}

View file

@ -14,9 +14,9 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Help
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PrivacyTip import androidx.compose.material.icons.filled.PrivacyTip
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@ -128,8 +128,7 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
viewModel = viewModel, viewModel = viewModel,
enableWideLayout = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded, enableWideLayout = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded,
enableAutosave = autosaveEnabled, enableAutosave = autosaveEnabled,
enableReadability = readabilityEnabled, enableReadability = readabilityEnabled
darkMode = darkModePreference
) )
} }
composable(Route.SETTINGS.path) { composable(Route.SETTINGS.path) {
@ -173,7 +172,7 @@ enum class Route(
EDITOR("/", "Editor", Icons.Default.Edit), EDITOR("/", "Editor", Icons.Default.Edit),
SETTINGS("/settings", "Settings", Icons.Default.Settings), SETTINGS("/settings", "Settings", Icons.Default.Settings),
SUPPORT("/support", "Support SimpleMarkdown", Icons.Default.Favorite), SUPPORT("/support", "Support SimpleMarkdown", Icons.Default.Favorite),
HELP("/help", "Help", Icons.Default.Help), HELP("/help", "Help", Icons.AutoMirrored.Filled.Help),
ABOUT("/about", "About", Icons.Default.Info), ABOUT("/about", "About", Icons.Default.Info),
PRIVACY("/privacy", "Privacy", Icons.Default.PrivacyTip), PRIVACY("/privacy", "Privacy", Icons.Default.PrivacyTip),
} }

View file

@ -7,30 +7,34 @@ import androidx.lifecycle.viewmodel.CreationExtras
import com.wbrawner.simplemarkdown.utility.FileHelper import com.wbrawner.simplemarkdown.utility.FileHelper
import com.wbrawner.simplemarkdown.utility.Preference import com.wbrawner.simplemarkdown.utility.Preference
import com.wbrawner.simplemarkdown.utility.PreferenceHelper import com.wbrawner.simplemarkdown.utility.PreferenceHelper
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.net.URI import java.net.URI
import java.util.concurrent.atomic.AtomicBoolean
data class EditorState(
val fileName: String = "Untitled.md",
val markdown: String = "",
val path: URI? = null,
val dirty: Boolean = false,
val toast: String? = null,
val alert: AlertDialogModel? = null,
val saveCallback: (() -> Unit)? = null
)
class MarkdownViewModel( class MarkdownViewModel(
private val fileHelper: FileHelper, private val fileHelper: FileHelper,
private val preferenceHelper: PreferenceHelper private val preferenceHelper: PreferenceHelper
) : ViewModel() { ) : ViewModel() {
private val _fileName = MutableStateFlow("Untitled.md") private val _state = MutableStateFlow(EditorState())
val fileName = _fileName.asStateFlow() val state = _state.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() private val saveMutex = Mutex()
init { init {
@ -39,71 +43,114 @@ class MarkdownViewModel(
} }
} }
suspend fun updateMarkdown(markdown: String?) { fun updateMarkdown(markdown: String?) {
this@MarkdownViewModel._markdown.emit(markdown ?: "") _state.value = _state.value.copy(
isDirty.set(true) markdown = markdown ?: "",
dirty = true
)
}
fun dismissToast() {
_state.value = _state.value.copy(toast = null)
}
fun dismissAlert() {
_state.value = _state.value.copy(alert = null)
}
private fun unsetSaveCallback() {
_state.value = _state.value.copy(saveCallback = null)
} }
suspend fun load(loadPath: String?) { suspend fun load(loadPath: String?) {
if (loadPath.isNullOrBlank()) { saveMutex.withLock {
Timber.i("No URI provided to load, attempting to load last autosaved file") val actualLoadPath = loadPath
preferenceHelper[Preference.AUTOSAVE_URI] ?.ifBlank { null }
?.let { ?: preferenceHelper[Preference.AUTOSAVE_URI]
val autosaveUri = it as? String ?.let {
if (autosaveUri.isNullOrBlank()) { val autosaveUri = it as? String
preferenceHelper[Preference.AUTOSAVE_URI] = null if (autosaveUri.isNullOrBlank()) {
} else { preferenceHelper[Preference.AUTOSAVE_URI] = null
Timber.d("Using uri from shared preferences: $it") null
load(autosaveUri) } else {
} Timber.d("Using uri from shared preferences: $it")
} autosaveUri
return }
} } ?: return
try { Timber.d("Loading file at $actualLoadPath")
val uri = URI.create(loadPath) try {
fileHelper.open(uri) val uri = URI.create(actualLoadPath)
?.let { (name, content) -> fileHelper.open(uri)
path.emit(uri) ?.let { (name, content) ->
_effects.emit(Effect.ClearText) _state.value = _state.value.copy(
_fileName.emit(name) path = uri,
_markdown.emit(content) fileName = name,
isDirty.set(false) markdown = content,
preferenceHelper[Preference.AUTOSAVE_URI] = loadPath dirty = false,
} ?: _effects.emit(Effect.Error("Failed to open file at path: $loadPath")) toast = "Successfully loaded $name"
} catch (e: Exception) { )
Timber.e(e, "Failed to open file at path: $loadPath") preferenceHelper[Preference.AUTOSAVE_URI] = actualLoadPath
} ?: throw IllegalStateException("Opened file was null")
} catch (e: Exception) {
Timber.e(e, "Failed to open file at path: $actualLoadPath")
_state.value = _state.value.copy(
alert = AlertDialogModel(
text = "Failed to open file at path: $actualLoadPath",
confirmButton = AlertDialogModel.ButtonModel("OK", onClick = ::dismissAlert)
)
)
}
} }
} }
suspend fun save(savePath: URI? = path.value, promptSavePath: Boolean = true): Boolean { suspend fun save(savePath: URI? = null, interactive: Boolean = true): Boolean =
return saveMutex.withLock { saveMutex.withLock {
if (savePath == null) { val actualSavePath = savePath
Timber.w("Attempted to save file with empty path") ?: _state.value.path
if (promptSavePath) { ?: run {
_effects.emit(Effect.OpenSaveDialog {}) Timber.w("Attempted to save file with empty path")
if (interactive) {
_state.value = _state.value.copy(saveCallback = ::unsetSaveCallback)
}
return@withLock false
} }
return false
}
try { try {
val name = fileHelper.save(savePath, markdown.value) Timber.i("Saving file to $actualSavePath...")
_fileName.emit(name) val currentState = _state.value
path.emit(savePath) val name = fileHelper.save(actualSavePath, currentState.markdown)
isDirty.set(false) _state.value = currentState.copy(
Timber.i("Saved file ${fileName.value} to uri $savePath") fileName = name,
Timber.i("Persisting autosave uri in shared prefs: $savePath") path = actualSavePath,
preferenceHelper[Preference.AUTOSAVE_URI] = savePath dirty = false,
toast = if (interactive) "Successfully saved $name" else null
)
Timber.i("Saved file $name to uri $actualSavePath")
Timber.i("Persisting autosave uri in shared prefs: $actualSavePath")
preferenceHelper[Preference.AUTOSAVE_URI] = actualSavePath
true true
} catch (e: Exception) { } catch (e: Exception) {
val message = "Failed to save file to $savePath" val message = "Failed to save file to $actualSavePath"
Timber.e(e, message) Timber.e(e, message)
_effects.emit(Effect.Error(message)) _state.value = _state.value.copy(
alert = AlertDialogModel(
text = message,
confirmButton = AlertDialogModel.ButtonModel(
text = "OK",
onClick = ::dismissAlert
)
)
)
false false
} }
} }
}
suspend fun autosave() { suspend fun autosave() {
if (!isDirty.get()) { val isAutoSaveEnabled = preferenceHelper[Preference.AUTOSAVE_ENABLED] as Boolean
if (!isAutoSaveEnabled) {
Timber.i("Ignoring autosave as autosave not enabled")
return
}
if (!_state.value.dirty) {
Timber.d("Ignoring autosave as contents haven't changed") Timber.d("Ignoring autosave as contents haven't changed")
return return
} }
@ -111,52 +158,59 @@ class MarkdownViewModel(
Timber.i("Ignoring autosave since manual save is already in progress") Timber.i("Ignoring autosave since manual save is already in progress")
return return
} }
val isAutoSaveEnabled = preferenceHelper[Preference.AUTOSAVE_ENABLED] as Boolean Timber.d("Performing autosave")
if (!isAutoSaveEnabled) { if (!save(interactive = false)) {
Timber.i("Ignoring autosave as autosave not enabled") withContext(Dispatchers.IO) {
return // 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
if (!save(promptSavePath = false)) { // this case, we need to just save to internal file storage so that we can recover
// The user has left the app, with autosave enabled, and we don't already have a val file = File(fileHelper.defaultDirectory, _state.value.fileName).toURI()
// Uri for them or for some reason we were unable to save to the original Uri. In Timber.i("No cached uri for autosave, saving to $file instead")
// this case, we need to just save to internal file storage so that we can recover // Here we call the fileHelper directly so that the file is still registered as dirty.
val file = File(fileHelper.defaultDirectory, fileName.value).toURI() // This prevents the user from ending up in a scenario where they've autosaved the file
Timber.i("No cached uri for autosave, saving to $file instead") // to an internal storage location, thus marking it as not dirty, but no longer able to
save(file) // access the file if the accidentally go to create a new file without properly saving
// the current one
fileHelper.save(file, _state.value.markdown)
preferenceHelper[Preference.AUTOSAVE_URI] = file
}
} }
} }
suspend fun reset(untitledFileName: String, force: Boolean = false) { fun reset(untitledFileName: String, force: Boolean = false) {
Timber.i("Resetting view model to default state") Timber.i("Resetting view model to default state")
if (!force && isDirty.get()) { if (!force && _state.value.dirty) {
_effects.emit(Effect.Prompt( _state.value = _state.value.copy(alert = AlertDialogModel(
"Would you like to save your changes?", text = "Would you like to save your changes?",
confirm = { confirmButton = AlertDialogModel.ButtonModel(
viewModelScope.launch { text = "Yes",
_effects.emit(Effect.OpenSaveDialog { onClick = {
reset(untitledFileName, false) _state.value = _state.value.copy(
}) saveCallback = {
reset(untitledFileName, false)
}
)
} }
}, ),
cancel = { dismissButton = AlertDialogModel.ButtonModel(
viewModelScope.launch { text = "No",
onClick = {
reset(untitledFileName, true) reset(untitledFileName, true)
} }
} )
)) ))
return return
} }
_fileName.emit(untitledFileName) _state.value = EditorState(fileName = untitledFileName)
_markdown.emit("")
path.emit(null)
_effects.emit(Effect.ClearText)
isDirty.set(false)
Timber.i("Removing autosave uri from shared prefs") Timber.i("Removing autosave uri from shared prefs")
preferenceHelper[Preference.AUTOSAVE_URI] = null preferenceHelper[Preference.AUTOSAVE_URI] = null
} }
companion object { companion object {
fun factory(fileHelper: FileHelper, preferenceHelper: PreferenceHelper): ViewModelProvider.Factory = object : ViewModelProvider.Factory { fun factory(
fileHelper: FileHelper,
preferenceHelper: PreferenceHelper
): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create( override fun <T : ViewModel> create(
modelClass: Class<T>, modelClass: Class<T>,
@ -166,13 +220,12 @@ class MarkdownViewModel(
} }
} }
} }
}
sealed interface Effect {
data class OpenSaveDialog(val postSaveBlock: suspend () -> Unit) : Effect data class AlertDialogModel(
data class Prompt(val text: String, val confirm: () -> Unit, val cancel: () -> Unit) : val text: String,
Effect val confirmButton: ButtonModel,
val dismissButton: ButtonModel? = null
data object ClearText : Effect ) {
data class Error(val text: String) : Effect data class ButtonModel(val text: String, val onClick: () -> Unit)
}
} }

View file

@ -1,6 +1,7 @@
package com.wbrawner.simplemarkdown.ui package com.wbrawner.simplemarkdown.ui
import android.content.Intent import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@ -21,7 +22,7 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
@ -39,6 +40,9 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -47,11 +51,9 @@ import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -59,7 +61,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -67,6 +68,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@ -75,15 +77,18 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.startActivity import androidx.core.content.ContextCompat.startActivity
import androidx.navigation.NavController import androidx.navigation.NavController
import com.wbrawner.simplemarkdown.AlertDialogModel
import com.wbrawner.simplemarkdown.EditorState
import com.wbrawner.simplemarkdown.MarkdownViewModel import com.wbrawner.simplemarkdown.MarkdownViewModel
import com.wbrawner.simplemarkdown.R import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.Route import com.wbrawner.simplemarkdown.Route
import com.wbrawner.simplemarkdown.model.Readability import com.wbrawner.simplemarkdown.model.Readability
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URI import java.net.URI
import kotlin.reflect.KProperty1
@Composable @Composable
fun MainScreen( fun MainScreen(
@ -91,164 +96,216 @@ fun MainScreen(
viewModel: MarkdownViewModel, viewModel: MarkdownViewModel,
enableWideLayout: Boolean, enableWideLayout: Boolean,
enableAutosave: Boolean, enableAutosave: Boolean,
enableReadability: Boolean, enableReadability: Boolean
darkMode: String,
) { ) {
var lockSwiping by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val fileName by viewModel.fileName.collectAsState() val fileName by viewModel.collectAsState(EditorState::fileName, "")
val openFileLauncher = val markdown by viewModel.collectAsState(EditorState::markdown, "")
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { val alert by viewModel.collectAsState(EditorState::alert, null)
coroutineScope.launch { val saveCallback by viewModel.collectAsState(EditorState::saveCallback, null)
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) { LaunchedEffect(enableAutosave) {
if (!enableAutosave) return@LaunchedEffect if (!enableAutosave) return@LaunchedEffect
while (isActive) { while (isActive) {
delay(30_000) delay(500)
viewModel.autosave() viewModel.autosave()
} }
} }
errorMessage?.let { message -> val toast by viewModel.collectAsState(EditorState::toast, null)
AlertDialog( MainScreen(
onDismissRequest = { errorMessage = null }, fileName = fileName,
confirmButton = { markdown = markdown,
TextButton(onClick = { errorMessage = null }) { setMarkdown = viewModel::updateMarkdown,
Text("OK") message = toast,
} dismissMessage = viewModel::dismissToast,
}, alert = alert,
text = { Text(message) } dismissAlert = viewModel::dismissAlert,
) navigate = {
navController.navigate(it.path)
},
navigateBack = { navController.popBackStack() },
loadFile = {
coroutineScope.launch {
viewModel.load(it.toString())
}
},
saveFile = {
coroutineScope.launch {
viewModel.save(it)
}
},
saveCallback = saveCallback,
reset = {
viewModel.reset("Untitled.md")
},
enableWideLayout = enableWideLayout,
enableReadability = enableReadability,
)
}
@Composable
private fun MainScreen(
fileName: String = "Untitled.md",
markdown: String = "",
setMarkdown: (String) -> Unit = {},
message: String? = null,
dismissMessage: () -> Unit = {},
alert: AlertDialogModel? = null,
dismissAlert: () -> Unit = {},
navigate: (Route) -> Unit = {},
navigateBack: () -> Unit = {},
loadFile: (Uri?) -> Unit = {},
saveFile: (URI?) -> Unit = {},
saveCallback: (() -> Unit)? = null,
reset: () -> Unit = {},
enableWideLayout: Boolean = false,
enableReadability: Boolean = false
) {
var lockSwiping by remember { mutableStateOf(false) }
val openFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
loadFile(it)
}
val saveFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) {
it?.let { uri -> saveFile(URI.create(uri.toString())) }
}
saveCallback?.let { callback ->
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) {
it?.let { uri -> saveFile(URI.create(uri.toString())) }
callback()
}
LaunchedEffect(callback) {
launcher.launch(fileName)
}
} }
promptEffect?.let { prompt ->
val snackBarState = remember { SnackbarHostState() }
LaunchedEffect(message) {
message?.let {
snackBarState.showSnackbar(it)
dismissMessage()
}
}
alert?.let {
AlertDialog( AlertDialog(
onDismissRequest = { errorMessage = null }, onDismissRequest = dismissAlert,
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = it.confirmButton.onClick) {
prompt.confirm() Text(it.confirmButton.text)
promptEffect = null
}) {
Text("Yes")
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { it.dismissButton?.let { dismissButton ->
prompt.cancel() TextButton(onClick = dismissButton.onClick) {
promptEffect = null Text(dismissButton.text)
}) { }
Text("No")
} }
}, },
text = { Text(prompt.text) } text = { Text(it.text) }
) )
} }
MarkdownNavigationDrawer(navigate = { navController.navigate(it.path) }) { drawerState ->
Scaffold(topBar = { MarkdownNavigationDrawer(navigate) { drawerState ->
val context = LocalContext.current Scaffold(
MarkdownTopAppBar(title = fileName, topBar = {
backAsUp = false, val context = LocalContext.current
navController = navController, MarkdownTopAppBar(title = fileName,
drawerState = drawerState, backAsUp = false,
actions = { goBack = navigateBack,
IconButton(onClick = { drawerState = drawerState,
val shareIntent = Intent(Intent.ACTION_SEND) actions = {
shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdown.value) IconButton(onClick = {
shareIntent.type = "text/plain" val shareIntent = Intent(Intent.ACTION_SEND)
startActivity( shareIntent.putExtra(Intent.EXTRA_TEXT, markdown)
context, Intent.createChooser( shareIntent.type = "text/plain"
shareIntent, context.getString(R.string.share_file) startActivity(
), null context, Intent.createChooser(
) shareIntent, context.getString(R.string.share_file)
}) { ), null
Icon(imageVector = Icons.Default.Share, contentDescription = "Share") )
} }) {
Box { Icon(imageVector = Icons.Default.Share, contentDescription = "Share")
var menuExpanded by remember { mutableStateOf(false) }
IconButton(onClick = { menuExpanded = true }) {
Icon(imageVector = Icons.Default.MoreVert, "Editor Actions")
} }
DropdownMenu(expanded = menuExpanded, Box {
onDismissRequest = { menuExpanded = false }) { var menuExpanded by remember { mutableStateOf(false) }
DropdownMenuItem(text = { Text("New") }, onClick = { IconButton(onClick = { menuExpanded = true }) {
menuExpanded = false Icon(imageVector = Icons.Default.MoreVert, "Editor Actions")
coroutineScope.launch { }
viewModel.reset("Untitled.md") DropdownMenu(expanded = menuExpanded,
} onDismissRequest = { menuExpanded = false }) {
}) DropdownMenuItem(text = { Text("New") }, onClick = {
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 menuExpanded = false
saveFileLauncher.launch(fileName) reset()
}) })
if (!enableWideLayout) { DropdownMenuItem(text = { Text("Open") }, onClick = {
DropdownMenuItem(text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Lock Swiping")
Checkbox(
checked = lockSwiping,
onCheckedChange = { lockSwiping = !lockSwiping })
}
}, onClick = {
lockSwiping = !lockSwiping
menuExpanded = false menuExpanded = false
openFileLauncher.launch(arrayOf("text/*"))
}) })
DropdownMenuItem(text = { Text("Save") }, onClick = {
menuExpanded = false
saveFile(null)
})
DropdownMenuItem(text = { Text("Save as…") },
onClick = {
menuExpanded = false
saveFileLauncher.launch(fileName)
})
if (!enableWideLayout) {
DropdownMenuItem(text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Lock Swiping")
Checkbox(
checked = lockSwiping,
onCheckedChange = { lockSwiping = !lockSwiping })
}
}, onClick = {
lockSwiping = !lockSwiping
menuExpanded = false
})
}
} }
} }
} }
}) )
}) { paddingValues -> },
val markdown by viewModel.markdown.collectAsState() snackbarHost = {
val (textFieldValue, setTextFieldValue) = remember(clearText) { SnackbarHost(
val annotatedMarkdown = if (enableReadability) { modifier = Modifier.imePadding(),
markdown.annotateReadability() hostState = snackBarState
} else { ) {
AnnotatedString(markdown) Snackbar(it)
}
mutableStateOf(TextFieldValue(annotatedMarkdown))
}
val setTextFieldAndViewModelValues: (TextFieldValue) -> Unit = {
setTextFieldValue(it)
coroutineScope.launch {
viewModel.updateMarkdown(it.text)
} }
} }
) { paddingValues ->
if (enableWideLayout) { if (enableWideLayout) {
Row(modifier = Modifier.fillMaxSize().padding(paddingValues)) { Row(
MarkdownTextField(modifier = Modifier.fillMaxHeight().weight(1f), textFieldValue, setTextFieldAndViewModelValues) modifier = Modifier
Spacer(modifier = Modifier.fillMaxHeight().width(1.dp).background(color = MaterialTheme.colorScheme.primary)) .fillMaxSize()
MarkdownPreview(modifier = Modifier.fillMaxHeight().weight(1f), markdown, darkMode) .padding(paddingValues)
) {
MarkdownTextField(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
markdown = markdown,
setMarkdown = setMarkdown,
enableReadability = enableReadability
)
Spacer(
modifier = Modifier
.fillMaxHeight()
.width(1.dp)
.background(color = MaterialTheme.colorScheme.primary)
)
MarkdownPreview(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
markdown = markdown
)
} }
} else { } else {
Column( Column(
@ -257,12 +314,10 @@ fun MainScreen(
.padding(paddingValues) .padding(paddingValues)
) { ) {
TabbedMarkdownEditor( TabbedMarkdownEditor(
coroutineScope, markdown = markdown,
lockSwiping, setMarkdown = setMarkdown,
textFieldValue, lockSwiping = lockSwiping,
setTextFieldAndViewModelValues, enableReadability = enableReadability
markdown,
darkMode
) )
} }
} }
@ -273,13 +328,12 @@ fun MainScreen(
@Composable @Composable
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
private fun TabbedMarkdownEditor( private fun TabbedMarkdownEditor(
coroutineScope: CoroutineScope,
lockSwiping: Boolean,
textFieldValue: TextFieldValue,
setTextFieldAndViewModelValues: (TextFieldValue) -> Unit,
markdown: String, markdown: String,
darkMode: String setMarkdown: (String) -> Unit,
lockSwiping: Boolean,
enableReadability: Boolean
) { ) {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { 2 } val pagerState = rememberPagerState { 2 }
TabRow(selectedTabIndex = pagerState.currentPage) { TabRow(selectedTabIndex = pagerState.currentPage) {
Tab(text = { Text("Edit") }, Tab(text = { Text("Edit") },
@ -301,9 +355,14 @@ private fun TabbedMarkdownEditor(
} }
} }
if (page == 0) { if (page == 0) {
MarkdownTextField(modifier = Modifier.fillMaxSize(), textFieldValue, setTextFieldAndViewModelValues) MarkdownTextField(
modifier = Modifier.fillMaxSize(),
markdown = markdown,
setMarkdown = setMarkdown,
enableReadability = enableReadability
)
} else { } else {
MarkdownPreview(modifier = Modifier.fillMaxSize(), markdown, darkMode) MarkdownPreview(modifier = Modifier.fillMaxSize(), markdown)
} }
} }
} }
@ -371,7 +430,7 @@ fun MarkdownNavigationDrawer(
@Composable @Composable
fun MarkdownTopAppBar( fun MarkdownTopAppBar(
title: String, title: String,
navController: NavController, goBack: () -> Unit,
backAsUp: Boolean = true, backAsUp: Boolean = true,
drawerState: DrawerState? = null, drawerState: DrawerState? = null,
actions: (@Composable RowScope.() -> Unit)? = null actions: (@Composable RowScope.() -> Unit)? = null
@ -382,7 +441,7 @@ fun MarkdownTopAppBar(
}, navigationIcon = { }, navigationIcon = {
val (icon, contentDescription, onClick) = remember { val (icon, contentDescription, onClick) = remember {
if (backAsUp) { if (backAsUp) {
Triple(Icons.Default.ArrowBack, "Go Back") { navController.popBackStack() } Triple(Icons.AutoMirrored.Filled.ArrowBack, "Go Back", goBack)
} else { } else {
Triple( Triple(
Icons.Default.Menu, "Main Menu" Icons.Default.Menu, "Main Menu"
@ -406,10 +465,26 @@ fun MarkdownTopAppBar(
@Composable @Composable
fun MarkdownTextField( fun MarkdownTextField(
modifier: Modifier, modifier: Modifier,
textFieldValue: TextFieldValue, markdown: String,
setTextFieldValue: (TextFieldValue) -> Unit, setMarkdown: (String) -> Unit,
enableReadability: Boolean = false enableReadability: Boolean = false
) { ) {
val (selection, setSelection) = remember { mutableStateOf(TextRange.Zero) }
val (composition, setComposition) = remember { mutableStateOf<TextRange?>(null) }
val textFieldValue = remember(markdown) {
val annotatedMarkdown = if (enableReadability) {
markdown.annotateReadability()
} else {
AnnotatedString(markdown)
}
TextFieldValue(annotatedMarkdown, selection, composition)
}
val setTextFieldAndViewModelValues: (TextFieldValue) -> Unit = {
setSelection(it.selection)
setComposition(it.composition)
setMarkdown(it.text)
}
TextField( TextField(
modifier = modifier.imePadding(), modifier = modifier.imePadding(),
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
@ -421,15 +496,7 @@ fun MarkdownTextField(
unfocusedIndicatorColor = Color.Transparent unfocusedIndicatorColor = Color.Transparent
), ),
value = textFieldValue, value = textFieldValue,
onValueChange = { onValueChange = setTextFieldAndViewModelValues,
setTextFieldValue(
if (enableReadability) {
it.copy(annotatedString = it.text.annotateReadability())
} else {
it
}
)
},
placeholder = { placeholder = {
Text("Markdown here…") Text("Markdown here…")
}, },
@ -440,3 +507,8 @@ fun MarkdownTextField(
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences) keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences)
) )
} }
@Composable
fun <P> MarkdownViewModel.collectAsState(prop: KProperty1<EditorState, P>, initial: P): State<P> =
state.map { prop.get(it) }
.collectAsState(initial)

View file

@ -22,7 +22,7 @@ fun MarkdownInfoScreen(
topBar = { topBar = {
MarkdownTopAppBar( MarkdownTopAppBar(
title = title, title = title,
navController = navController, goBack = { navController.popBackStack() },
) )
} }
) { paddingValues -> ) { paddingValues ->
@ -35,8 +35,7 @@ fun MarkdownInfoScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues), .padding(paddingValues),
markdown = markdown, markdown = markdown
"Auto"
) )
} }
} }

View file

@ -3,6 +3,7 @@ package com.wbrawner.simplemarkdown.ui
import android.content.Context import android.content.Context
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.WebView import android.webkit.WebView
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -24,9 +25,9 @@ private const val container = "<main id=\"content\"></main>"
@OptIn(ExperimentalStdlibApi::class) @OptIn(ExperimentalStdlibApi::class)
@Composable @Composable
fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String, darkMode: String) { fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String) {
val materialColors = MaterialTheme.colorScheme val materialColors = MaterialTheme.colorScheme
val style = remember(darkMode) { val style = remember(isSystemInDarkTheme()) {
"""body { """body {
| background: #${materialColors.surface.toArgb().toHexString().substring(2)}; | background: #${materialColors.surface.toArgb().toHexString().substring(2)};
| color: #${materialColors.onSurface.toArgb().toHexString().substring(2)}; | color: #${materialColors.onSurface.toArgb().toHexString().substring(2)};

View file

@ -35,7 +35,7 @@ import com.wbrawner.simplemarkdown.utility.PreferenceHelper
@Composable @Composable
fun SettingsScreen(navController: NavController, preferenceHelper: PreferenceHelper) { fun SettingsScreen(navController: NavController, preferenceHelper: PreferenceHelper) {
Scaffold(topBar = { Scaffold(topBar = {
MarkdownTopAppBar(title = "Settings", navController = navController) MarkdownTopAppBar(title = "Settings", goBack = { navController.popBackStack() })
}) { paddingValues -> }) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier

View file

@ -37,7 +37,7 @@ import com.wbrawner.simplemarkdown.utility.SupportLinks
@Composable @Composable
fun SupportScreen(navController: NavController) { fun SupportScreen(navController: NavController) {
Scaffold(topBar = { Scaffold(topBar = {
MarkdownTopAppBar(title = "Support SimpleMarkdown", navController = navController) MarkdownTopAppBar(title = "Support SimpleMarkdown", goBack = { navController.popBackStack() })
}) { paddingValues -> }) { paddingValues ->
val context = LocalContext.current val context = LocalContext.current
Column( Column(

View file

@ -2,6 +2,8 @@ package com.wbrawner.simplemarkdown.utility
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Environment
import android.provider.MediaStore
import androidx.core.net.toUri import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -32,14 +34,21 @@ interface FileHelper {
} }
class AndroidFileHelper(private val context: Context) : FileHelper { class AndroidFileHelper(private val context: Context) : FileHelper {
override val defaultDirectory: File = context.filesDir override val defaultDirectory: File = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
?: context.filesDir
override suspend fun open(source: URI): Pair<String, String>? = withContext(Dispatchers.IO) { override suspend fun open(source: URI): Pair<String, String>? = withContext(Dispatchers.IO) {
val uri = source.toString().toUri() val uri = source.toString().toUri()
context.contentResolver.takePersistableUriPermission( try {
uri, context.contentResolver.takePersistableUriPermission(
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION uri,
) Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} catch (e: SecurityException) {
// We weren't granted the persistent read/write permission for this file.
// TODO: Return whether or not we got the persistent permission in order to determine
// whether or not we should show this file in the recent files section
}
context.contentResolver.openFileDescriptor(uri, "r") context.contentResolver.openFileDescriptor(uri, "r")
?.use { ?.use {
uri.getName(context) to FileInputStream(it.fileDescriptor).reader() uri.getName(context) to FileInputStream(it.fileDescriptor).reader()

View file

@ -1,29 +1,39 @@
package com.wbrawner.simplemarkdown package com.wbrawner.simplemarkdown
import com.wbrawner.simplemarkdown.utility.FileHelper import com.wbrawner.simplemarkdown.utility.FileHelper
import kotlinx.coroutines.delay
import java.io.File import java.io.File
import java.net.URI import java.net.URI
class FakeFileHelper : FileHelper { class FakeFileHelper : FileHelper {
override val defaultDirectory: File override val defaultDirectory: File by lazy {
get() = File.createTempFile("sm", null) File.createTempFile("simplemarkdown", null)
.apply { .apply {
delete() delete()
mkdir() mkdir()
} }
}
var file: Pair<String, String> = "Untitled.md" to "This is a test file" var file: Pair<String, String> = "Untitled.md" to "This is a test file"
var openedUris = ArrayDeque<URI>() var openedUris = ArrayDeque<URI>()
var savedData = ArrayDeque<SavedData>() var savedData = ArrayDeque<SavedData>()
@Volatile
var errorOnOpen: Boolean = false
@Volatile
var errorOnSave: Boolean = false
override suspend fun open(source: URI): Pair<String, String> { override suspend fun open(source: URI): Pair<String, String> {
delay(1000)
if (errorOnOpen) error("errorOnOpen set to true")
openedUris.addLast(source) openedUris.addLast(source)
return file return file
} }
override suspend fun save(destination: URI, content: String): String { override suspend fun save(destination: URI, content: String): String {
delay(1000)
if (errorOnSave) error("errorOnSave set to true")
savedData.addLast(SavedData(destination, content)) savedData.addLast(SavedData(destination, content))
return file.first return destination.path.substringAfterLast("/")
} }
} }

View file

@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.StateFlow
class FakePreferenceHelper: PreferenceHelper { class FakePreferenceHelper: PreferenceHelper {
val preferences = mutableMapOf<Preference, Any?>() val preferences = mutableMapOf<Preference, Any?>()
override fun get(preference: Preference): Any? = preferences[preference] override fun get(preference: Preference): Any? = preferences[preference]?: preference.default
override fun set(preference: Preference, value: Any?) { override fun set(preference: Preference, value: Any?) {
preferences[preference] = value preferences[preference] = value

View file

@ -1,56 +1,296 @@
package com.wbrawner.simplemarkdown package com.wbrawner.simplemarkdown
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import com.wbrawner.simplemarkdown.utility.Preference import com.wbrawner.simplemarkdown.utility.Preference
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestResult
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import timber.log.Timber
import java.io.File
import java.net.URI import java.net.URI
import java.util.Deque
import java.util.concurrent.ConcurrentLinkedDeque
class MarkdownViewModelTest { class MarkdownViewModelTest {
private lateinit var fileHelper: FakeFileHelper private lateinit var fileHelper: FakeFileHelper
private lateinit var preferenceHelper: FakePreferenceHelper private lateinit var preferenceHelper: FakePreferenceHelper
private lateinit var viewModelFactory: ViewModelProvider.Factory
private lateinit var viewModel: MarkdownViewModel private lateinit var viewModel: MarkdownViewModel
private lateinit var viewModelScope: TestScope
@OptIn(ExperimentalCoroutinesApi::class)
@Before @Before
fun setup() { fun setup() {
Timber.plant(object: Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
println("$tag/$priority: $message")
t?.printStackTrace()
}
})
val scheduler = StandardTestDispatcher()
Dispatchers.setMain(scheduler)
viewModelScope = TestScope(scheduler)
fileHelper = FakeFileHelper() fileHelper = FakeFileHelper()
preferenceHelper = FakePreferenceHelper() preferenceHelper = FakePreferenceHelper()
viewModel = MarkdownViewModel(fileHelper, preferenceHelper) viewModelFactory = MarkdownViewModel.factory(fileHelper, preferenceHelper)
viewModel = viewModelFactory.create(MarkdownViewModel::class.java, CreationExtras.Empty)
viewModelScope.advanceUntilIdle()
} }
@Test @Test
fun testMarkdownUpdate() = runBlocking { fun testMarkdownUpdate() = runTest {
assertEquals("", viewModel.markdown.value) assertEquals("", viewModel.state.value.markdown)
viewModel.updateMarkdown("Updated content") viewModel.updateMarkdown("Updated content")
assertEquals("Updated content", viewModel.markdown.value) assertEquals("Updated content", viewModel.state.value.markdown)
} }
@Test @Test
fun testLoadWithNoPathAndNoAutosaveUri() = runBlocking { fun testLoadWithNoPathAndNoAutosaveUri() = runTest {
viewModel.load(null) viewModel.load(null)
assertTrue(fileHelper.openedUris.isEmpty()) assertTrue(fileHelper.openedUris.isEmpty())
} }
@Test @Test
fun testLoadWithNoPathAndAutosaveUri() = runBlocking { fun testAutoLoad() = runTest {
val uri = URI.create("file:///home/user/Untitled.md") val uri = URI.create("file:///home/user/Untitled.md")
preferenceHelper[Preference.AUTOSAVE_URI] = uri.toString() preferenceHelper[Preference.AUTOSAVE_URI] = uri.toString()
viewModel.load(null) viewModel = viewModelFactory.create(MarkdownViewModel::class.java, CreationExtras.Empty)
viewModelScope.advanceUntilIdle()
assertEquals(uri, fileHelper.openedUris.firstOrNull()) assertEquals(uri, fileHelper.openedUris.firstOrNull())
val (fileName, contents) = fileHelper.file val (fileName, contents) = fileHelper.file
assertEquals(fileName, viewModel.fileName.value) assertEquals(fileName, viewModel.state.value.fileName)
assertEquals(contents, viewModel.markdown.value) assertEquals(contents, viewModel.state.value.markdown)
} }
@Test @Test
fun testLoadWithPath() = runBlocking { fun testLoadWithPath() = runTest {
val uri = URI.create("file:///home/user/Untitled.md") val uri = URI.create("file:///home/user/Untitled.md")
viewModel.load(uri.toString()) viewModel.load(uri.toString())
assertEquals(uri, fileHelper.openedUris.firstOrNull()) assertEquals(uri, fileHelper.openedUris.firstOrNull())
val (fileName, contents) = fileHelper.file val (fileName, contents) = fileHelper.file
assertEquals(fileName, viewModel.fileName.value) assertEquals(fileName, viewModel.state.value.fileName)
assertEquals(contents, viewModel.markdown.value) assertEquals(contents, viewModel.state.value.markdown)
}
@Test
fun testLoadWithEmptyPath() = runTest {
preferenceHelper[Preference.AUTOSAVE_URI] = ""
viewModel.load("")
assertEquals(null, preferenceHelper[Preference.AUTOSAVE_URI])
assertTrue(fileHelper.openedUris.isEmpty())
}
@Test
fun testLoadWithInvalidUri() = runTest {
viewModel.load(":/:/")
}
@Test
fun testLoadWithError() = runTest {
fileHelper.errorOnOpen = true
val uri = URI.create("file:///home/user/Untitled.md")
viewModel.load(uri.toString())
assertNotNull(viewModel.state.value.alert)
}
@Test
fun testSaveWithNullPath() = runTest {
assertFalse(viewModel.save(null, false))
assertNull(viewModel.state.value.alert)
assertNull(viewModel.state.value.saveCallback)
}
@Test
fun testSaveWithNullPathAndPrompt() = runTest {
assertFalse(viewModel.save(null, true))
assertNotNull(viewModel.state.value.saveCallback)
viewModel.state.value.saveCallback!!.invoke()
assertNull(viewModel.state.value.saveCallback)
}
@Test
fun testSaveWithValidPath() = runTest {
val uri = URI.create("file:///home/user/Saved.md")
val testMarkdown = "# Test"
viewModel.updateMarkdown(testMarkdown)
assertEquals(testMarkdown, viewModel.state.value.markdown)
assertTrue(viewModel.save(uri))
assertEquals("Saved.md", viewModel.state.value.fileName)
assertEquals(uri, fileHelper.savedData.last().uri)
assertEquals(testMarkdown, fileHelper.savedData.last().content)
assertEquals(uri, preferenceHelper[Preference.AUTOSAVE_URI])
}
@Test
fun testSaveWithException() = runTest {
val uri = URI.create("file:///home/user/Untitled.md")
val testMarkdown = "# Test"
viewModel.updateMarkdown(testMarkdown)
assertEquals(testMarkdown, viewModel.state.value.markdown)
fileHelper.errorOnSave = true
assertNull(viewModel.state.value.alert)
assertFalse(viewModel.save(uri))
assertNotNull(viewModel.state.value.alert)
requireNotNull(viewModel.state.value.alert?.confirmButton?.onClick).invoke()
assertNull(viewModel.state.value.alert)
}
@Test
fun testResetWithSavedChanges() = runTest {
viewModel.updateMarkdown("# Test")
val uri = URI.create("file:///home/user/Saved.md")
assertTrue(viewModel.save(uri))
assertFalse(viewModel.state.value.dirty)
assertNull(viewModel.state.value.alert)
viewModel.reset("New.md")
assertNull(viewModel.state.value.alert)
with(viewModel.state.value) {
assertEquals("New.md", fileName)
assertEquals("", markdown)
assertNull(path)
assertNull(saveCallback)
assertNull(alert)
assertFalse(dirty)
}
}
@Test
fun testResetWithUnsavedChanges() = runTest {
viewModel.updateMarkdown("# Test")
assertTrue(viewModel.state.value.dirty)
assertNull(viewModel.state.value.alert)
viewModel.reset("Untitled.md")
with(viewModel.state.value.alert) {
assertNotNull(this)
requireNotNull(this)
val onClick = dismissButton?.onClick
assertNotNull(onClick)
requireNotNull(onClick)
onClick.invoke()
}
assertEquals(viewModel.state.value, EditorState())
}
@Test
fun testResetWithUnsavedChangesAndPrompt() = runTest {
viewModel.updateMarkdown("# Test")
assertTrue(viewModel.state.value.dirty)
assertNull(viewModel.state.value.alert)
viewModel.reset("Untitled.md")
assertNull(viewModel.state.value.saveCallback)
with(viewModel.state.value.alert) {
assertNotNull(this)
requireNotNull(this)
confirmButton.onClick.invoke()
}
val uri = URI.create("file:///home/user/Saved.md")
viewModel.save(uri)
assertNotNull(viewModel.state.value.saveCallback)
requireNotNull(viewModel.state.value.saveCallback).invoke()
assertEquals(viewModel.state.value, EditorState())
}
@Test
fun testForceResetWithUnsavedChanges() = runTest {
viewModel.updateMarkdown("# Test")
assertTrue(viewModel.state.value.dirty)
val uri = URI.create("file:///home/user/Saved.md")
assertTrue(viewModel.save(uri))
assertFalse(viewModel.state.value.dirty)
viewModel.updateMarkdown("# Test\n\nDirty changes")
assertTrue(viewModel.state.value.dirty)
assertNull(viewModel.state.value.alert)
viewModel.reset("Unsaved.md", true)
assertNull(viewModel.state.value.alert)
with(viewModel.state.value) {
assertEquals("Unsaved.md", fileName)
assertEquals("", markdown)
assertNull(path)
assertNull(saveCallback)
assertNull(alert)
assertFalse(dirty)
}
assertNull(preferenceHelper[Preference.AUTOSAVE_URI])
}
@Test
fun testAutosaveWithPreferenceDisabled() = runTest {
preferenceHelper[Preference.AUTOSAVE_ENABLED] = false
viewModel.updateMarkdown("# Test")
assertTrue(viewModel.state.value.dirty)
viewModel.autosave()
assertEquals(0, fileHelper.savedData.count())
}
@Test
fun testAutosaveWithNoNewData() = runTest {
viewModel.updateMarkdown("# Test")
val uri = URI.create("file:///home/user/Saved.md")
assertTrue(viewModel.save(uri))
assertEquals(1, fileHelper.savedData.count())
assertFalse(viewModel.state.value.dirty)
viewModel.autosave()
assertEquals(1, fileHelper.savedData.count())
}
@Test
fun testAutosaveWithSaveInProgress() = runTest {
viewModel.updateMarkdown("# Test")
val uri = URI.create("file:///home/user/Saved.md")
val coroutineScope = TestScope(StandardTestDispatcher())
coroutineScope.launch {
assertTrue(viewModel.save(uri))
}
coroutineScope.advanceTimeBy(500)
assertEquals(0, fileHelper.savedData.count())
assertTrue(viewModel.state.value.dirty)
viewModel.autosave()
assertEquals(0, fileHelper.savedData.count())
coroutineScope.advanceTimeBy(1000)
assertEquals(1, fileHelper.savedData.count())
}
@Test
fun testAutosaveWithUnknownUri() = runTest {
viewModel.updateMarkdown("# Test")
assertTrue(viewModel.state.value.dirty)
viewModel.autosave()
assertTrue(viewModel.state.value.dirty)
assertEquals(1, fileHelper.savedData.count())
assertEquals(
File(fileHelper.defaultDirectory, "Untitled.md").toURI(),
fileHelper.savedData.first().uri
)
}
@Test
fun testAutosaveWithKnownUri() = runTest {
viewModel.updateMarkdown("# Test")
assertTrue(viewModel.state.value.dirty)
val uri = URI.create("file:///home/user/Saved.md")
assertTrue(viewModel.save(uri))
assertEquals(1, fileHelper.savedData.count())
assertFalse(viewModel.state.value.dirty)
viewModel.updateMarkdown("# Test\n\nDirty changes")
assertTrue(viewModel.state.value.dirty)
viewModel.autosave()
assertEquals(2, fileHelper.savedData.count())
} }
} }