diff --git a/app/build.gradle.kts b/app/build.gradle.kts index be5d033..705cd15 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -153,6 +153,7 @@ dependencies { androidTestImplementation("androidx.compose.ui:ui-test-junit4") val coroutinesVersion = "1.7.1" implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") val lifecycleVersion = "2.2.0" implementation("androidx.lifecycle:lifecycle-extensions:$lifecycleVersion") kapt("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion") diff --git a/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/MarkdownTests.kt b/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/MarkdownTests.kt new file mode 100644 index 0000000..261f968 --- /dev/null +++ b/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/MarkdownTests.kt @@ -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().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() +} diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/MainActivity.kt b/app/src/main/java/com/wbrawner/simplemarkdown/MainActivity.kt index 7ed5b0b..3e4ba3c 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/MainActivity.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/MainActivity.kt @@ -14,9 +14,9 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut 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.Favorite -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 @@ -128,8 +128,7 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes viewModel = viewModel, enableWideLayout = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded, enableAutosave = autosaveEnabled, - enableReadability = readabilityEnabled, - darkMode = darkModePreference + enableReadability = readabilityEnabled ) } composable(Route.SETTINGS.path) { @@ -173,7 +172,7 @@ enum class Route( EDITOR("/", "Editor", Icons.Default.Edit), SETTINGS("/settings", "Settings", Icons.Default.Settings), 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), PRIVACY("/privacy", "Privacy", Icons.Default.PrivacyTip), } \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownViewModel.kt b/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownViewModel.kt index 88a4e19..1efe69f 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownViewModel.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownViewModel.kt @@ -7,30 +7,34 @@ 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.Dispatchers +import kotlinx.coroutines.coroutineScope 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 kotlinx.coroutines.withContext import timber.log.Timber import java.io.File 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( 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 _state = MutableStateFlow(EditorState()) + val state = _state.asStateFlow() private val saveMutex = Mutex() init { @@ -39,71 +43,114 @@ class MarkdownViewModel( } } - suspend fun updateMarkdown(markdown: String?) { - this@MarkdownViewModel._markdown.emit(markdown ?: "") - isDirty.set(true) + fun updateMarkdown(markdown: String?) { + _state.value = _state.value.copy( + 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?) { - 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") + saveMutex.withLock { + val actualLoadPath = loadPath + ?.ifBlank { null } + ?: preferenceHelper[Preference.AUTOSAVE_URI] + ?.let { + val autosaveUri = it as? String + if (autosaveUri.isNullOrBlank()) { + preferenceHelper[Preference.AUTOSAVE_URI] = null + null + } else { + Timber.d("Using uri from shared preferences: $it") + autosaveUri + } + } ?: return + Timber.d("Loading file at $actualLoadPath") + try { + val uri = URI.create(actualLoadPath) + fileHelper.open(uri) + ?.let { (name, content) -> + _state.value = _state.value.copy( + path = uri, + fileName = name, + markdown = content, + dirty = false, + toast = "Successfully loaded $name" + ) + 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 { - return saveMutex.withLock { - if (savePath == null) { - Timber.w("Attempted to save file with empty path") - if (promptSavePath) { - _effects.emit(Effect.OpenSaveDialog {}) + suspend fun save(savePath: URI? = null, interactive: Boolean = true): Boolean = + saveMutex.withLock { + val actualSavePath = savePath + ?: _state.value.path + ?: run { + Timber.w("Attempted to save file with empty path") + if (interactive) { + _state.value = _state.value.copy(saveCallback = ::unsetSaveCallback) + } + return@withLock false } - 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 + Timber.i("Saving file to $actualSavePath...") + val currentState = _state.value + val name = fileHelper.save(actualSavePath, currentState.markdown) + _state.value = currentState.copy( + fileName = name, + path = actualSavePath, + 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 } catch (e: Exception) { - val message = "Failed to save file to $savePath" + val message = "Failed to save file to $actualSavePath" 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 } } - } 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") return } @@ -111,52 +158,59 @@ class MarkdownViewModel( 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) + Timber.d("Performing autosave") + if (!save(interactive = false)) { + withContext(Dispatchers.IO) { + // 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, _state.value.fileName).toURI() + Timber.i("No cached uri for autosave, saving to $file instead") + // Here we call the fileHelper directly so that the file is still registered as dirty. + // This prevents the user from ending up in a scenario where they've autosaved the file + // to an internal storage location, thus marking it as not dirty, but no longer able to + // 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") - 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) - }) + if (!force && _state.value.dirty) { + _state.value = _state.value.copy(alert = AlertDialogModel( + text = "Would you like to save your changes?", + confirmButton = AlertDialogModel.ButtonModel( + text = "Yes", + onClick = { + _state.value = _state.value.copy( + saveCallback = { + reset(untitledFileName, false) + } + ) } - }, - cancel = { - viewModelScope.launch { + ), + dismissButton = AlertDialogModel.ButtonModel( + text = "No", + onClick = { reset(untitledFileName, true) } - } + ) )) return } - _fileName.emit(untitledFileName) - _markdown.emit("") - path.emit(null) - _effects.emit(Effect.ClearText) - isDirty.set(false) + _state.value = EditorState(fileName = untitledFileName) 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 { + fun factory( + fileHelper: FileHelper, + preferenceHelper: PreferenceHelper + ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create( modelClass: Class, @@ -166,13 +220,12 @@ class MarkdownViewModel( } } } - - 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 - } } + +data class AlertDialogModel( + val text: String, + val confirmButton: ButtonModel, + val dismissButton: ButtonModel? = null +) { + data class ButtonModel(val text: String, val onClick: () -> Unit) +} \ No newline at end of file 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 c6d46d3..0cb10b8 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MainScreen.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MainScreen.kt @@ -1,6 +1,7 @@ package com.wbrawner.simplemarkdown.ui import android.content.Intent +import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts 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.text.KeyboardOptions 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.MoreVert 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.NavigationDrawerItem 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.TabRow import androidx.compose.material3.Text @@ -47,11 +51,9 @@ import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar 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.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -59,7 +61,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color 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.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily 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.core.content.ContextCompat.startActivity import androidx.navigation.NavController +import com.wbrawner.simplemarkdown.AlertDialogModel +import com.wbrawner.simplemarkdown.EditorState import com.wbrawner.simplemarkdown.MarkdownViewModel import com.wbrawner.simplemarkdown.R import com.wbrawner.simplemarkdown.Route import com.wbrawner.simplemarkdown.model.Readability -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.net.URI +import kotlin.reflect.KProperty1 @Composable fun MainScreen( @@ -91,164 +96,216 @@ fun MainScreen( viewModel: MarkdownViewModel, enableWideLayout: Boolean, enableAutosave: Boolean, - enableReadability: Boolean, - darkMode: String, + 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++ - } - } - } + val fileName by viewModel.collectAsState(EditorState::fileName, "") + val markdown by viewModel.collectAsState(EditorState::markdown, "") + val alert by viewModel.collectAsState(EditorState::alert, null) + val saveCallback by viewModel.collectAsState(EditorState::saveCallback, null) LaunchedEffect(enableAutosave) { if (!enableAutosave) return@LaunchedEffect while (isActive) { - delay(30_000) + delay(500) viewModel.autosave() } } - errorMessage?.let { message -> - AlertDialog( - onDismissRequest = { errorMessage = null }, - confirmButton = { - TextButton(onClick = { errorMessage = null }) { - Text("OK") - } - }, - text = { Text(message) } - ) + val toast by viewModel.collectAsState(EditorState::toast, null) + MainScreen( + fileName = fileName, + markdown = markdown, + setMarkdown = viewModel::updateMarkdown, + message = toast, + dismissMessage = viewModel::dismissToast, + alert = alert, + 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( - onDismissRequest = { errorMessage = null }, + onDismissRequest = dismissAlert, confirmButton = { - TextButton(onClick = { - prompt.confirm() - promptEffect = null - }) { - Text("Yes") + TextButton(onClick = it.confirmButton.onClick) { + Text(it.confirmButton.text) } }, dismissButton = { - TextButton(onClick = { - prompt.cancel() - promptEffect = null - }) { - Text("No") + it.dismissButton?.let { dismissButton -> + TextButton(onClick = dismissButton.onClick) { + Text(dismissButton.text) + } } }, - text = { Text(prompt.text) } + text = { Text(it.text) } ) } - MarkdownNavigationDrawer(navigate = { navController.navigate(it.path) }) { drawerState -> - Scaffold(topBar = { - val context = LocalContext.current - MarkdownTopAppBar(title = fileName, - backAsUp = false, - navController = navController, - drawerState = drawerState, - actions = { - IconButton(onClick = { - val shareIntent = Intent(Intent.ACTION_SEND) - shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdown.value) - shareIntent.type = "text/plain" - startActivity( - context, Intent.createChooser( - shareIntent, context.getString(R.string.share_file) - ), null - ) - }) { - Icon(imageVector = Icons.Default.Share, contentDescription = "Share") - } - Box { - var menuExpanded by remember { mutableStateOf(false) } - IconButton(onClick = { menuExpanded = true }) { - Icon(imageVector = Icons.Default.MoreVert, "Editor Actions") + + MarkdownNavigationDrawer(navigate) { drawerState -> + Scaffold( + topBar = { + val context = LocalContext.current + MarkdownTopAppBar(title = fileName, + backAsUp = false, + goBack = navigateBack, + drawerState = drawerState, + actions = { + IconButton(onClick = { + val shareIntent = Intent(Intent.ACTION_SEND) + shareIntent.putExtra(Intent.EXTRA_TEXT, markdown) + shareIntent.type = "text/plain" + startActivity( + context, Intent.createChooser( + shareIntent, context.getString(R.string.share_file) + ), null + ) + }) { + Icon(imageVector = Icons.Default.Share, contentDescription = "Share") } - DropdownMenu(expanded = menuExpanded, - 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 = { + Box { + var menuExpanded by remember { mutableStateOf(false) } + IconButton(onClick = { menuExpanded = true }) { + Icon(imageVector = Icons.Default.MoreVert, "Editor Actions") + } + DropdownMenu(expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }) { + DropdownMenuItem(text = { Text("New") }, onClick = { menuExpanded = false - saveFileLauncher.launch(fileName) + reset() }) - if (!enableWideLayout) { - DropdownMenuItem(text = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Lock Swiping") - Checkbox( - checked = lockSwiping, - onCheckedChange = { lockSwiping = !lockSwiping }) - } - }, onClick = { - lockSwiping = !lockSwiping + DropdownMenuItem(text = { Text("Open") }, onClick = { 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() - val (textFieldValue, setTextFieldValue) = remember(clearText) { - val annotatedMarkdown = if (enableReadability) { - markdown.annotateReadability() - } else { - AnnotatedString(markdown) - } - mutableStateOf(TextFieldValue(annotatedMarkdown)) - } - val setTextFieldAndViewModelValues: (TextFieldValue) -> Unit = { - setTextFieldValue(it) - coroutineScope.launch { - viewModel.updateMarkdown(it.text) + ) + }, + snackbarHost = { + SnackbarHost( + modifier = Modifier.imePadding(), + hostState = snackBarState + ) { + Snackbar(it) } } + ) { paddingValues -> if (enableWideLayout) { - Row(modifier = Modifier.fillMaxSize().padding(paddingValues)) { - MarkdownTextField(modifier = Modifier.fillMaxHeight().weight(1f), textFieldValue, setTextFieldAndViewModelValues) - Spacer(modifier = Modifier.fillMaxHeight().width(1.dp).background(color = MaterialTheme.colorScheme.primary)) - MarkdownPreview(modifier = Modifier.fillMaxHeight().weight(1f), markdown, darkMode) + Row( + modifier = Modifier + .fillMaxSize() + .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 { Column( @@ -257,12 +314,10 @@ fun MainScreen( .padding(paddingValues) ) { TabbedMarkdownEditor( - coroutineScope, - lockSwiping, - textFieldValue, - setTextFieldAndViewModelValues, - markdown, - darkMode + markdown = markdown, + setMarkdown = setMarkdown, + lockSwiping = lockSwiping, + enableReadability = enableReadability ) } } @@ -273,13 +328,12 @@ fun MainScreen( @Composable @OptIn(ExperimentalFoundationApi::class) private fun TabbedMarkdownEditor( - coroutineScope: CoroutineScope, - lockSwiping: Boolean, - textFieldValue: TextFieldValue, - setTextFieldAndViewModelValues: (TextFieldValue) -> Unit, markdown: String, - darkMode: String + setMarkdown: (String) -> Unit, + lockSwiping: Boolean, + enableReadability: Boolean ) { + val coroutineScope = rememberCoroutineScope() val pagerState = rememberPagerState { 2 } TabRow(selectedTabIndex = pagerState.currentPage) { Tab(text = { Text("Edit") }, @@ -301,9 +355,14 @@ private fun TabbedMarkdownEditor( } } if (page == 0) { - MarkdownTextField(modifier = Modifier.fillMaxSize(), textFieldValue, setTextFieldAndViewModelValues) + MarkdownTextField( + modifier = Modifier.fillMaxSize(), + markdown = markdown, + setMarkdown = setMarkdown, + enableReadability = enableReadability + ) } else { - MarkdownPreview(modifier = Modifier.fillMaxSize(), markdown, darkMode) + MarkdownPreview(modifier = Modifier.fillMaxSize(), markdown) } } } @@ -371,7 +430,7 @@ fun MarkdownNavigationDrawer( @Composable fun MarkdownTopAppBar( title: String, - navController: NavController, + goBack: () -> Unit, backAsUp: Boolean = true, drawerState: DrawerState? = null, actions: (@Composable RowScope.() -> Unit)? = null @@ -382,7 +441,7 @@ fun MarkdownTopAppBar( }, navigationIcon = { val (icon, contentDescription, onClick) = remember { if (backAsUp) { - Triple(Icons.Default.ArrowBack, "Go Back") { navController.popBackStack() } + Triple(Icons.AutoMirrored.Filled.ArrowBack, "Go Back", goBack) } else { Triple( Icons.Default.Menu, "Main Menu" @@ -406,10 +465,26 @@ fun MarkdownTopAppBar( @Composable fun MarkdownTextField( modifier: Modifier, - textFieldValue: TextFieldValue, - setTextFieldValue: (TextFieldValue) -> Unit, + markdown: String, + setMarkdown: (String) -> Unit, enableReadability: Boolean = false ) { + val (selection, setSelection) = remember { mutableStateOf(TextRange.Zero) } + val (composition, setComposition) = remember { mutableStateOf(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( modifier = modifier.imePadding(), colors = TextFieldDefaults.colors( @@ -421,15 +496,7 @@ fun MarkdownTextField( unfocusedIndicatorColor = Color.Transparent ), value = textFieldValue, - onValueChange = { - setTextFieldValue( - if (enableReadability) { - it.copy(annotatedString = it.text.annotateReadability()) - } else { - it - } - ) - }, + onValueChange = setTextFieldAndViewModelValues, placeholder = { Text("Markdown here…") }, @@ -439,4 +506,9 @@ fun MarkdownTextField( ), keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences) ) -} \ No newline at end of file +} + +@Composable +fun

MarkdownViewModel.collectAsState(prop: KProperty1, initial: P): State

= + state.map { prop.get(it) } + .collectAsState(initial) \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownInfoScreen.kt b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownInfoScreen.kt index 877a0d5..3a7c3ce 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownInfoScreen.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownInfoScreen.kt @@ -22,7 +22,7 @@ fun MarkdownInfoScreen( topBar = { MarkdownTopAppBar( title = title, - navController = navController, + goBack = { navController.popBackStack() }, ) } ) { paddingValues -> @@ -35,8 +35,7 @@ fun MarkdownInfoScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues), - markdown = markdown, - "Auto" + markdown = markdown ) } } diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownPreview.kt b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownPreview.kt index 19eb506..98bd070 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownPreview.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownPreview.kt @@ -3,6 +3,7 @@ package com.wbrawner.simplemarkdown.ui import android.content.Context import android.view.ViewGroup import android.webkit.WebView +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -24,9 +25,9 @@ private const val container = "

" @OptIn(ExperimentalStdlibApi::class) @Composable -fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String, darkMode: String) { +fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String) { val materialColors = MaterialTheme.colorScheme - val style = remember(darkMode) { + val style = remember(isSystemInDarkTheme()) { """body { | background: #${materialColors.surface.toArgb().toHexString().substring(2)}; | color: #${materialColors.onSurface.toArgb().toHexString().substring(2)}; 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 97b3189..f86c480 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/SettingsScreen.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/SettingsScreen.kt @@ -35,7 +35,7 @@ import com.wbrawner.simplemarkdown.utility.PreferenceHelper @Composable fun SettingsScreen(navController: NavController, preferenceHelper: PreferenceHelper) { Scaffold(topBar = { - MarkdownTopAppBar(title = "Settings", navController = navController) + MarkdownTopAppBar(title = "Settings", goBack = { navController.popBackStack() }) }) { paddingValues -> Column( modifier = Modifier diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/ui/SupportScreen.kt b/app/src/main/java/com/wbrawner/simplemarkdown/ui/SupportScreen.kt index 96f85f7..fd70442 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/SupportScreen.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/SupportScreen.kt @@ -37,7 +37,7 @@ import com.wbrawner.simplemarkdown.utility.SupportLinks @Composable fun SupportScreen(navController: NavController) { Scaffold(topBar = { - MarkdownTopAppBar(title = "Support SimpleMarkdown", navController = navController) + MarkdownTopAppBar(title = "Support SimpleMarkdown", goBack = { navController.popBackStack() }) }) { paddingValues -> val context = LocalContext.current Column( diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/utility/FileHelper.kt b/app/src/main/java/com/wbrawner/simplemarkdown/utility/FileHelper.kt index 6d1928a..e74879e 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/utility/FileHelper.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/utility/FileHelper.kt @@ -2,6 +2,8 @@ package com.wbrawner.simplemarkdown.utility import android.content.Context import android.content.Intent +import android.os.Environment +import android.provider.MediaStore import androidx.core.net.toUri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -32,14 +34,21 @@ interface 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? = withContext(Dispatchers.IO) { val uri = source.toString().toUri() - context.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) + try { + context.contentResolver.takePersistableUriPermission( + 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") ?.use { uri.getName(context) to FileInputStream(it.fileDescriptor).reader() diff --git a/app/src/test/java/com/wbrawner/simplemarkdown/FakeFileHelper.kt b/app/src/test/java/com/wbrawner/simplemarkdown/FakeFileHelper.kt index 3d44de2..f354428 100644 --- a/app/src/test/java/com/wbrawner/simplemarkdown/FakeFileHelper.kt +++ b/app/src/test/java/com/wbrawner/simplemarkdown/FakeFileHelper.kt @@ -1,29 +1,39 @@ package com.wbrawner.simplemarkdown import com.wbrawner.simplemarkdown.utility.FileHelper +import kotlinx.coroutines.delay import java.io.File import java.net.URI class FakeFileHelper : FileHelper { - override val defaultDirectory: File - get() = File.createTempFile("sm", null) + override val defaultDirectory: File by lazy { + File.createTempFile("simplemarkdown", null) .apply { delete() mkdir() } + } var file: Pair = "Untitled.md" to "This is a test file" var openedUris = ArrayDeque() var savedData = ArrayDeque() + @Volatile + var errorOnOpen: Boolean = false + @Volatile + var errorOnSave: Boolean = false override suspend fun open(source: URI): Pair { + delay(1000) + if (errorOnOpen) error("errorOnOpen set to true") openedUris.addLast(source) return file } override suspend fun save(destination: URI, content: String): String { + delay(1000) + if (errorOnSave) error("errorOnSave set to true") savedData.addLast(SavedData(destination, content)) - return file.first + return destination.path.substringAfterLast("/") } } diff --git a/app/src/test/java/com/wbrawner/simplemarkdown/FakePreferenceHelper.kt b/app/src/test/java/com/wbrawner/simplemarkdown/FakePreferenceHelper.kt index 655e3e4..bd00a9c 100644 --- a/app/src/test/java/com/wbrawner/simplemarkdown/FakePreferenceHelper.kt +++ b/app/src/test/java/com/wbrawner/simplemarkdown/FakePreferenceHelper.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.StateFlow class FakePreferenceHelper: PreferenceHelper { val preferences = mutableMapOf() - 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?) { preferences[preference] = value diff --git a/app/src/test/java/com/wbrawner/simplemarkdown/MarkdownViewModelTest.kt b/app/src/test/java/com/wbrawner/simplemarkdown/MarkdownViewModelTest.kt index 0d99a38..dc31c6b 100644 --- a/app/src/test/java/com/wbrawner/simplemarkdown/MarkdownViewModelTest.kt +++ b/app/src/test/java/com/wbrawner/simplemarkdown/MarkdownViewModelTest.kt @@ -1,56 +1,296 @@ package com.wbrawner.simplemarkdown +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras 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.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import timber.log.Timber +import java.io.File import java.net.URI +import java.util.Deque +import java.util.concurrent.ConcurrentLinkedDeque class MarkdownViewModelTest { private lateinit var fileHelper: FakeFileHelper private lateinit var preferenceHelper: FakePreferenceHelper + private lateinit var viewModelFactory: ViewModelProvider.Factory private lateinit var viewModel: MarkdownViewModel + private lateinit var viewModelScope: TestScope + @OptIn(ExperimentalCoroutinesApi::class) @Before 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() preferenceHelper = FakePreferenceHelper() - viewModel = MarkdownViewModel(fileHelper, preferenceHelper) + viewModelFactory = MarkdownViewModel.factory(fileHelper, preferenceHelper) + viewModel = viewModelFactory.create(MarkdownViewModel::class.java, CreationExtras.Empty) + viewModelScope.advanceUntilIdle() } @Test - fun testMarkdownUpdate() = runBlocking { - assertEquals("", viewModel.markdown.value) + fun testMarkdownUpdate() = runTest { + assertEquals("", viewModel.state.value.markdown) viewModel.updateMarkdown("Updated content") - assertEquals("Updated content", viewModel.markdown.value) + assertEquals("Updated content", viewModel.state.value.markdown) } @Test - fun testLoadWithNoPathAndNoAutosaveUri() = runBlocking { + fun testLoadWithNoPathAndNoAutosaveUri() = runTest { viewModel.load(null) assertTrue(fileHelper.openedUris.isEmpty()) } @Test - fun testLoadWithNoPathAndAutosaveUri() = runBlocking { + fun testAutoLoad() = runTest { val uri = URI.create("file:///home/user/Untitled.md") preferenceHelper[Preference.AUTOSAVE_URI] = uri.toString() - viewModel.load(null) + viewModel = viewModelFactory.create(MarkdownViewModel::class.java, CreationExtras.Empty) + viewModelScope.advanceUntilIdle() assertEquals(uri, fileHelper.openedUris.firstOrNull()) val (fileName, contents) = fileHelper.file - assertEquals(fileName, viewModel.fileName.value) - assertEquals(contents, viewModel.markdown.value) + assertEquals(fileName, viewModel.state.value.fileName) + assertEquals(contents, viewModel.state.value.markdown) } @Test - fun testLoadWithPath() = runBlocking { + fun testLoadWithPath() = runTest { val uri = URI.create("file:///home/user/Untitled.md") viewModel.load(uri.toString()) assertEquals(uri, fileHelper.openedUris.firstOrNull()) val (fileName, contents) = fileHelper.file - assertEquals(fileName, viewModel.fileName.value) - assertEquals(contents, viewModel.markdown.value) + assertEquals(fileName, viewModel.state.value.fileName) + 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()) } } \ No newline at end of file