diff --git a/.forgejo/workflows/pull_request.yml b/.forgejo/workflows/pull_request.yml index 7299bee..87cb19f 100644 --- a/.forgejo/workflows/pull_request.yml +++ b/.forgejo/workflows/pull_request.yml @@ -55,6 +55,8 @@ jobs: with: distribution: 'zulu' java-version: '17' + - name: Setup Android SDK + uses: https://git.wbrawner.com/android-actions/setup-android@v3 - name: Build with Gradle uses: https://git.wbrawner.com/gradle/gradle-build-action@v2 with: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f6aac4a..f977812 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -97,6 +97,9 @@ android { commit.set(true) } } + lint { + warningsAsErrors = true + } } play { diff --git a/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/MarkdownTests.kt b/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/MarkdownTests.kt index 261f968..a3876b7 100644 --- a/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/MarkdownTests.kt +++ b/app/src/androidTest/kotlin/com/wbrawner/simplemarkdown/MarkdownTests.kt @@ -6,13 +6,10 @@ 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.SemanticsNodeInteraction import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed @@ -27,13 +24,8 @@ 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 @@ -233,7 +225,7 @@ class MarkdownTests { private fun ComposeTestRule.checkTitleEquals(title: String) = onNode(hasAnySibling(hasContentDescription("Main Menu")).and(hasText(title))) - .assertIsDisplayed() + .waitUntilIsDisplayed() private fun ComposeTestRule.typeMarkdown(markdown: String) = onNode(hasSetTextAction()).performTextReplacement(markdown) @@ -243,7 +235,9 @@ class MarkdownTests { val markdownMatcher = SemanticsMatcher("Markdown = [$markdown]") { it.config.getOrNull(SemanticsProperties.EditableText)?.text == markdown } - onNode(hasSetTextAction()).assert(markdownMatcher) + onNode(hasSetTextAction()).waitUntil { + assert(markdownMatcher) + } } private fun ComposeTestRule.openPreview() = onNodeWithText("Preview").performClick() @@ -258,12 +252,38 @@ class MarkdownTests { private fun ComposeTestRule.clickSaveMenuItem() = onNodeWithText("Save").performClick() private fun ComposeTestRule.verifyDialogIsShown(text: String) = - onNode(isDialog().and(hasAnyDescendant(hasText(text)))).assertIsDisplayed() + onNode(isDialog().and(hasAnyDescendant(hasText(text)))).waitUntilIsDisplayed() - private fun ComposeTestRule.verifyDialogIsNotShown() = onNode(isDialog()).assertIsNotDisplayed() + private fun ComposeTestRule.verifyDialogIsNotShown() = + onNode(isDialog()).waitUntilIsNotDisplayed() private fun ComposeTestRule.discardChanges() = onNodeWithText("No").performClick() private fun ComposeTestRule.verifyTextIsShown(text: String) = - onNodeWithText(text).assertIsDisplayed() + onNodeWithText(text).waitUntilIsDisplayed() + + private val ASSERTION_TIMEOUT = 5_000L + + private fun SemanticsNodeInteraction.waitUntil(assertion: SemanticsNodeInteraction.() -> Unit) { + val start = System.currentTimeMillis() + lateinit var assertionError: AssertionError + while (System.currentTimeMillis() - start < ASSERTION_TIMEOUT) { + try { + assertion() + return + } catch (e: AssertionError) { + assertionError = e + Thread.sleep(10) + } + } + throw assertionError + } + + private fun SemanticsNodeInteraction.waitUntilIsDisplayed() = waitUntil { + assertIsDisplayed() + } + + private fun SemanticsNodeInteraction.waitUntilIsNotDisplayed() = waitUntil { + assertIsNotDisplayed() + } } diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/MainActivity.kt b/app/src/main/java/com/wbrawner/simplemarkdown/MainActivity.kt index 873f940..406123b 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/MainActivity.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/MainActivity.kt @@ -4,6 +4,7 @@ import android.os.Build import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.compose.animation.AnimatedContentTransitionScope @@ -27,6 +28,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.core.app.ActivityCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat @@ -135,21 +137,21 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes } composable(Route.HELP.path) { MarkdownInfoScreen( - title = Route.HELP.title, + title = stringResource(Route.HELP.title), file = "Cheatsheet.md", navController = navController ) } composable(Route.ABOUT.path) { MarkdownInfoScreen( - title = Route.ABOUT.title, + title = stringResource(Route.ABOUT.title), file = "Libraries.md", navController = navController ) } composable(Route.PRIVACY.path) { MarkdownInfoScreen( - title = Route.PRIVACY.title, + title = stringResource(Route.PRIVACY.title), file = "Privacy Policy.md", navController = navController ) @@ -162,13 +164,14 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes enum class Route( val path: String, - val title: String, + @StringRes + val title: Int, val icon: ImageVector ) { - EDITOR("/", "Editor", Icons.Default.Edit), - SETTINGS("/settings", "Settings", Icons.Default.Settings), - SUPPORT("/support", "Support SimpleMarkdown", Icons.Default.Favorite), - HELP("/help", "Help", Icons.AutoMirrored.Filled.Help), - ABOUT("/about", "About", Icons.Default.Info), - PRIVACY("/privacy", "Privacy", Icons.Default.PrivacyTip), + EDITOR("/", R.string.title_editor, Icons.Default.Edit), + SETTINGS("/settings", R.string.title_settings, Icons.Default.Settings), + SUPPORT("/support", R.string.support_title, Icons.Default.Favorite), + HELP("/help", R.string.title_help, Icons.AutoMirrored.Filled.Help), + ABOUT("/about", R.string.title_about, Icons.Default.Info), + PRIVACY("/privacy", R.string.action_privacy, Icons.Default.PrivacyTip), } \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownApplication.kt b/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownApplication.kt index 7aa1ef4..b4f3b9b 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownApplication.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownApplication.kt @@ -9,13 +9,16 @@ import com.wbrawner.simplemarkdown.utility.FileHelper import com.wbrawner.simplemarkdown.utility.PersistentTree import com.wbrawner.simplemarkdown.utility.PreferenceHelper import com.wbrawner.simplemarkdown.utility.ReviewHelper -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import timber.log.Timber import java.io.File class MarkdownApplication : Application() { + private val coroutineScope = CoroutineScope(Dispatchers.Default) + override fun onCreate() { if (BuildConfig.DEBUG) { StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder() @@ -27,9 +30,9 @@ class MarkdownApplication : Application() { .penaltyLog() .build()) Timber.plant(Timber.DebugTree()) - GlobalScope.launch { + coroutineScope.launch { try { - Timber.plant(PersistentTree.create(File(getExternalFilesDir(null), "logs"))) + Timber.plant(PersistentTree.create(coroutineScope, File(getExternalFilesDir(null), "logs"))) } catch (e: Exception) { Timber.e(e, "Unable to create PersistentTree") } diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownViewModel.kt b/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownViewModel.kt index f95bf64..c2d0955 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownViewModel.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownViewModel.kt @@ -1,5 +1,6 @@ package com.wbrawner.simplemarkdown +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -22,7 +23,7 @@ data class EditorState( val fileName: String = "Untitled.md", val markdown: String = "", val path: URI? = null, - val toast: String? = null, + val toast: ParameterizedText? = null, val alert: AlertDialogModel? = null, val saveCallback: (() -> Unit)? = null, /** @@ -95,7 +96,7 @@ class MarkdownViewModel( markdown = content, initialMarkdown = content, reloadToggle = currentState.reloadToggle.inv(), - toast = "Successfully loaded $name" + toast = ParameterizedText(R.string.file_loaded, arrayOf(name)) ) preferenceHelper[Preference.AUTOSAVE_URI] = actualLoadPath } ?: throw IllegalStateException("Opened file was null") @@ -103,8 +104,8 @@ class MarkdownViewModel( 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) + text = ParameterizedText(R.string.file_load_error), + confirmButton = AlertDialogModel.ButtonModel(ParameterizedText(R.string.ok), onClick = ::dismissAlert) ) ) } @@ -130,20 +131,19 @@ class MarkdownViewModel( fileName = name, path = actualSavePath, initialMarkdown = currentState.markdown, - toast = if (interactive) "Successfully saved $name" else null + toast = if (interactive) ParameterizedText(R.string.file_saved, arrayOf(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 $actualSavePath" - Timber.e(e, message) + Timber.e(e, "Failed to save file to $actualSavePath") _state.value = _state.value.copy( alert = AlertDialogModel( - text = message, + text = ParameterizedText(R.string.file_save_error), confirmButton = AlertDialogModel.ButtonModel( - text = "OK", + text = ParameterizedText(R.string.ok), onClick = ::dismissAlert ) ) @@ -189,9 +189,9 @@ class MarkdownViewModel( Timber.i("Resetting view model to default state") if (!force && _state.value.dirty) { _state.value = _state.value.copy(alert = AlertDialogModel( - text = "Would you like to save your changes?", + text = ParameterizedText(R.string.prompt_save_changes), confirmButton = AlertDialogModel.ButtonModel( - text = "Yes", + text = ParameterizedText(R.string.yes), onClick = { _state.value = _state.value.copy( saveCallback = { @@ -201,7 +201,7 @@ class MarkdownViewModel( } ), dismissButton = AlertDialogModel.ButtonModel( - text = "No", + text = ParameterizedText(R.string.no), onClick = { reset(untitledFileName, true) } @@ -209,7 +209,8 @@ class MarkdownViewModel( )) return } - _state.value = EditorState(fileName = untitledFileName) + _state.value = + EditorState(fileName = untitledFileName, reloadToggle = _state.value.reloadToggle.inv()) Timber.i("Removing autosave uri from shared prefs") preferenceHelper[Preference.AUTOSAVE_URI] = null } @@ -231,9 +232,29 @@ class MarkdownViewModel( } data class AlertDialogModel( - val text: String, + val text: ParameterizedText, val confirmButton: ButtonModel, val dismissButton: ButtonModel? = null ) { - data class ButtonModel(val text: String, val onClick: () -> Unit) + data class ButtonModel(val text: ParameterizedText, val onClick: () -> Unit) +} + +data class ParameterizedText(@StringRes val text: Int, val params: Array = arrayOf()) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ParameterizedText + + if (text != other.text) return false + if (!params.contentEquals(other.params)) return false + + return true + } + + override fun hashCode(): Int { + var result = text + result = 31 * result + params.contentHashCode() + return result + } } \ 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 12a47b6..62a39d0 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MainScreen.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MainScreen.kt @@ -52,15 +52,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource 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.ParameterizedText import com.wbrawner.simplemarkdown.R import com.wbrawner.simplemarkdown.Route import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -98,7 +101,7 @@ fun MainScreen( initialMarkdown = initialMarkdown, markdown = markdown, setMarkdown = viewModel::updateMarkdown, - message = toast, + message = toast?.stringRes(), dismissMessage = viewModel::dismissToast, alert = alert, dismissAlert = viewModel::dismissAlert, @@ -181,17 +184,17 @@ private fun MainScreen( onDismissRequest = dismissAlert, confirmButton = { TextButton(onClick = it.confirmButton.onClick) { - Text(it.confirmButton.text) + Text(stringResource(it.confirmButton.text.text)) } }, dismissButton = { it.dismissButton?.let { dismissButton -> TextButton(onClick = dismissButton.onClick) { - Text(dismissButton.text) + Text(dismissButton.text.stringRes()) } } }, - text = { Text(it.text) } + text = { Text(it.text.stringRes()) } ) } val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() @@ -215,28 +218,28 @@ private fun MainScreen( ), null ) }) { - Icon(imageVector = Icons.Default.Share, contentDescription = "Share") + Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(R.string.action_share)) } Box { var menuExpanded by remember { mutableStateOf(false) } IconButton(onClick = { menuExpanded = true }) { - Icon(imageVector = Icons.Default.MoreVert, "Editor Actions") + Icon(imageVector = Icons.Default.MoreVert, stringResource(R.string.action_editor_actions)) } DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { - DropdownMenuItem(text = { Text("New") }, onClick = { + DropdownMenuItem(text = { Text(stringResource(R.string.action_new)) }, onClick = { menuExpanded = false reset() }) - DropdownMenuItem(text = { Text("Open") }, onClick = { + DropdownMenuItem(text = { Text(stringResource(R.string.action_open)) }, onClick = { menuExpanded = false openFileLauncher.launch(arrayOf("text/*")) }) - DropdownMenuItem(text = { Text("Save") }, onClick = { + DropdownMenuItem(text = { Text(stringResource(R.string.action_save)) }, onClick = { menuExpanded = false saveFile(null) }) - DropdownMenuItem(text = { Text("Save as…") }, + DropdownMenuItem(text = { Text(stringResource(R.string.action_save_as )) }, onClick = { menuExpanded = false saveFileLauncher.launch(fileName) @@ -244,7 +247,7 @@ private fun MainScreen( if (!enableWideLayout) { DropdownMenuItem(text = { Row(verticalAlignment = Alignment.CenterVertically) { - Text("Lock Swiping") + Text(stringResource(R.string.action_lock_swipe)) Checkbox( checked = lockSwiping, onCheckedChange = { lockSwiping = !lockSwiping }) @@ -332,10 +335,10 @@ private fun TabbedMarkdownEditor( val coroutineScope = rememberCoroutineScope() val pagerState = rememberPagerState { 2 } TabRow(selectedTabIndex = pagerState.currentPage) { - Tab(text = { Text("Edit") }, + Tab(text = { Text(stringResource(R.string.action_edit)) }, selected = pagerState.currentPage == 0, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }) - Tab(text = { Text("Preview") }, + Tab(text = { Text(stringResource(R.string.action_preview)) }, selected = pagerState.currentPage == 1, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }) } @@ -374,5 +377,7 @@ private fun TabbedMarkdownEditor( @Composable fun

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

= - state.map { prop.get(it) } - .collectAsState(initial) \ No newline at end of file + remember(prop) { state.map { prop.get(it) }.distinctUntilChanged() }.collectAsState(initial) + +@Composable +fun ParameterizedText.stringRes() = stringResource(text, *params) \ 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 fc4afce..36e1086 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownInfoScreen.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownInfoScreen.kt @@ -31,7 +31,7 @@ fun MarkdownInfoScreen( val context = LocalContext.current val (markdown, setMarkdown) = remember { mutableStateOf("") } LaunchedEffect(file) { - setMarkdown(context.assets.readAssetToString(file) ?: "Failed to load $file") + setMarkdown(context.assets.readAssetToString(file)) } MarkdownText( modifier = Modifier diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownNavigationDrawer.kt b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownNavigationDrawer.kt index 6585f2d..0551b0c 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownNavigationDrawer.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownNavigationDrawer.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.wbrawner.simplemarkdown.R import com.wbrawner.simplemarkdown.Route @@ -44,7 +45,7 @@ fun MarkdownNavigationDrawer( tint = MaterialTheme.colorScheme.onSurface ) Text( - text = "Simple Markdown", + text = stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge ) } @@ -56,7 +57,7 @@ fun MarkdownNavigationDrawer( icon = { Icon(imageVector = route.icon, contentDescription = null) }, - label = { Text(route.title) }, + label = { Text(stringResource(route.title)) }, selected = false, onClick = { navigate(route) diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownText.kt b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownText.kt index 82ee9f2..dbdbc61 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownText.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownText.kt @@ -1,5 +1,6 @@ package com.wbrawner.simplemarkdown.ui +import android.annotation.SuppressLint import android.view.ViewGroup import android.webkit.WebView import androidx.compose.foundation.isSystemInDarkTheme @@ -69,6 +70,7 @@ fun MarkdownText(modifier: Modifier = Modifier, markdown: String) { } } +@SuppressLint("SetJavaScriptEnabled") @OptIn(ExperimentalStdlibApi::class) @Composable fun HtmlText(html: String, modifier: Modifier = Modifier) { diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownTextField.kt b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownTextField.kt index bb36db2..2efdad7 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownTextField.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownTextField.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextRange @@ -33,6 +34,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp +import com.wbrawner.simplemarkdown.R import com.wbrawner.simplemarkdown.model.Readability @OptIn(ExperimentalMaterial3Api::class) @@ -96,7 +98,7 @@ fun MarkdownTextField( visualTransformation = VisualTransformation.None, innerTextField = innerTextField, placeholder = { - Text("Markdown here…") + Text(stringResource(R.string.markdown_here)) }, singleLine = false, enabled = true, diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownTopAppBar.kt b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownTopAppBar.kt index 7e8d6d7..126ef92 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownTopAppBar.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/MarkdownTopAppBar.kt @@ -11,14 +11,14 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarColors -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults.topAppBarColors import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow +import com.wbrawner.simplemarkdown.R import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -32,6 +32,7 @@ fun MarkdownTopAppBar( scrollBehavior: TopAppBarScrollBehavior? = null, ) { val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current TopAppBar( title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) @@ -40,10 +41,10 @@ fun MarkdownTopAppBar( navigationIcon = { val (icon, contentDescription, onClick) = remember { if (backAsUp) { - Triple(Icons.AutoMirrored.Filled.ArrowBack, "Go Back", goBack) + Triple(Icons.AutoMirrored.Filled.ArrowBack, context.getString(R.string.action_back), goBack) } else { Triple( - Icons.Default.Menu, "Main Menu" + Icons.Default.Menu, context.getString(R.string.action_menu) ) { coroutineScope.launch { if (drawerState?.isOpen == true) { 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 e612a91..a501a2b 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/SettingsScreen.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/SettingsScreen.kt @@ -26,10 +26,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.wbrawner.simplemarkdown.BuildConfig +import com.wbrawner.simplemarkdown.R import com.wbrawner.simplemarkdown.ui.theme.SimpleMarkdownTheme import com.wbrawner.simplemarkdown.utility.Preference import com.wbrawner.simplemarkdown.utility.PreferenceHelper @@ -47,36 +50,36 @@ fun SettingsScreen(navController: NavController, preferenceHelper: PreferenceHel .verticalScroll(rememberScrollState()) ) { BooleanPreference( - title = "Autosave", - enabledDescription = "Files will be saved automatically", - disabledDescription = "Files will not be saved automatically", + title = stringResource(R.string.pref_title_autosave), + enabledDescription = stringResource(R.string.pref_autosave_on), + disabledDescription = stringResource(R.string.pref_autosave_off), preference = Preference.AUTOSAVE_ENABLED, preferenceHelper = preferenceHelper ) ListPreference( - title = "Dark mode", - options = listOf("auto", "dark", "light"), + title = stringResource(R.string.title_dark_mode), + options = stringArrayResource(R.array.pref_values_dark_mode), preference = Preference.DARK_MODE, preferenceHelper = preferenceHelper ) BooleanPreference( - title = "Send crash reports", - enabledDescription = "Error reports will be sent", - disabledDescription = "Error reports will not be sent", + title = stringResource(R.string.pref_title_error_reports), + enabledDescription = stringResource(R.string.pref_error_reports_on), + disabledDescription = stringResource(R.string.pref_error_reports_off), preference = Preference.ERROR_REPORTS_ENABLED, preferenceHelper = preferenceHelper ) BooleanPreference( - title = "Send analytics", - enabledDescription = "Analytics events will be sent", - disabledDescription = "Analytics events will not be sent", + title = stringResource(R.string.pref_title_analytics), + enabledDescription = stringResource(R.string.pref_analytics_on), + disabledDescription = stringResource(R.string.pref_analytics_off), preference = Preference.ANALYTICS_ENABLED, preferenceHelper = preferenceHelper ) BooleanPreference( - title = "Readability highlighting", - enabledDescription = "Readability highlighting is on", - disabledDescription = "Readability highlighting is off", + title = stringResource(R.string.pref_title_readability), + enabledDescription = stringResource(R.string.pref_readability_on), + disabledDescription = stringResource(R.string.pref_readability_off), preference = Preference.READABILITY_ENABLED, preferenceHelper = preferenceHelper ) @@ -90,9 +93,9 @@ fun SettingsScreen(navController: NavController, preferenceHelper: PreferenceHel horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Column(verticalArrangement = Arrangement.Center) { - Text(text = "Force a crash", style = MaterialTheme.typography.bodyLarge) + Text(text = stringResource(R.string.action_force_crash), style = MaterialTheme.typography.bodyLarge) Text( - text = "Purposefully crash the app for testing purposes", + text = stringResource(R.string.description_force_crash), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -155,7 +158,7 @@ fun BooleanPreference( @Composable fun ListPreference( title: String, - options: List, + options: Array, preference: Preference, preferenceHelper: PreferenceHelper ) { @@ -171,7 +174,7 @@ fun ListPreference( @Composable fun ListPreference( - title: String, options: List, selected: String, setSelected: (String) -> Unit + title: String, options: Array, selected: String, setSelected: (String) -> Unit ) { var dialogShowing by remember { mutableStateOf(false) } Column(modifier = Modifier @@ -248,7 +251,7 @@ fun ListPreference_Preview() { SimpleMarkdownTheme { Surface { ListPreference( - "Dark mode", listOf("Light", "Dark", "Auto"), selected, setSelected + "Dark mode", arrayOf("Light", "Dark", "Auto"), selected, setSelected ) } } 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 484d9f5..0a40fcb 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/ui/SupportScreen.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/ui/SupportScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat.startActivity @@ -39,7 +40,7 @@ import com.wbrawner.simplemarkdown.utility.SupportLinks @Composable fun SupportScreen(navController: NavController) { Scaffold(topBar = { - MarkdownTopAppBar(title = "Support SimpleMarkdown", goBack = { navController.popBackStack() }) + MarkdownTopAppBar(title = stringResource(R.string.support_title), goBack = { navController.popBackStack() }) }) { paddingValues -> val context = LocalContext.current Column( @@ -56,7 +57,7 @@ fun SupportScreen(navController: NavController) { contentDescription = null, tint = MaterialTheme.colorScheme.primary ) - Text(context.getString(R.string.support_info), textAlign = TextAlign.Center) + Text(stringResource(R.string.support_info), textAlign = TextAlign.Center) Spacer(modifier = Modifier.height(8.dp)) Button( modifier = Modifier.fillMaxWidth(), @@ -71,7 +72,7 @@ fun SupportScreen(navController: NavController) { contentColor = Color.White ) ) { - Text(context.getString(R.string.action_view_github)) + Text(stringResource(R.string.action_view_github)) } Button( modifier = Modifier.fillMaxWidth(), @@ -98,7 +99,7 @@ fun SupportScreen(navController: NavController) { contentColor = Color.White ) ) { - Text(context.getString(R.string.action_rate)) + Text(stringResource(R.string.action_rate)) } SupportLinks() } diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/utility/Extensions.kt b/app/src/main/java/com/wbrawner/simplemarkdown/utility/Extensions.kt index a3ea912..2b9ba0a 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/utility/Extensions.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/utility/Extensions.kt @@ -10,17 +10,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.Reader -fun View.showKeyboard() { - (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) - .toggleSoftInput(InputMethodManager.SHOW_FORCED, 0) - requestFocus() -} - -fun View.hideKeyboard() = - (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) - .hideSoftInputFromWindow(windowToken, 0) - -suspend fun AssetManager.readAssetToString(asset: String): String? { +suspend fun AssetManager.readAssetToString(asset: String): String { return withContext(Dispatchers.IO) { open(asset).reader().use(Reader::readText) } diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/utility/PersistentTree.kt b/app/src/main/java/com/wbrawner/simplemarkdown/utility/PersistentTree.kt index 33fb08f..4b09b65 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/utility/PersistentTree.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/utility/PersistentTree.kt @@ -2,6 +2,7 @@ package com.wbrawner.simplemarkdown.utility import android.util.Log import com.wbrawner.simplemarkdown.utility.PersistentTree.Companion.create +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -18,7 +19,10 @@ import java.util.* * A [Timber.Tree] implementation that persists all logs to disk for retrieval later. Create * instances via [create] instead of calling the constructor directly. */ -class PersistentTree private constructor(private val logFile: File) : Timber.Tree() { +class PersistentTree private constructor( + private val coroutineScope: CoroutineScope, + private val logFile: File +) : Timber.Tree() { private val dateFormat = object : ThreadLocal() { override fun initialValue(): SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) @@ -26,7 +30,7 @@ class PersistentTree private constructor(private val logFile: File) : Timber.Tre override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { val timestamp = dateFormat.get()!!.format(System.currentTimeMillis()) - GlobalScope.launch(Dispatchers.IO) { + coroutineScope.launch(Dispatchers.IO) { val priorityLetter = when (priority) { Log.ASSERT -> "A" Log.DEBUG -> "D" @@ -63,14 +67,14 @@ class PersistentTree private constructor(private val logFile: File) : Timber.Tre * created/written to */ @Throws(IllegalArgumentException::class, IOException::class) - suspend fun create(logDir: File): PersistentTree = withContext(Dispatchers.IO) { + suspend fun create(coroutineScope: CoroutineScope, logDir: File): PersistentTree = withContext(Dispatchers.IO) { if (!logDir.mkdirs() && !logDir.isDirectory) throw IllegalArgumentException("Unable to create log directory at ${logDir.absolutePath}") val timestamp = SimpleDateFormat("yyyyMMddHHmmss", Locale.US).format(Date()) val logFile = File(logDir, "persistent-log-$timestamp.log") if (!logFile.createNewFile()) throw IOException("Unable to create logFile at ${logFile.absolutePath}") - PersistentTree(logFile) + PersistentTree(coroutineScope, logFile) } } } \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/utility/PreferenceHelper.kt b/app/src/main/java/com/wbrawner/simplemarkdown/utility/PreferenceHelper.kt index bae6b30..747da2f 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/utility/PreferenceHelper.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/utility/PreferenceHelper.kt @@ -23,7 +23,7 @@ interface PreferenceHelper { fun observe(preference: Preference): StateFlow } -class AndroidPreferenceHelper(context: Context, val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO)): PreferenceHelper { +class AndroidPreferenceHelper(context: Context, private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO)): PreferenceHelper { private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) private val states = mapOf( Preference.ANALYTICS_ENABLED to MutableStateFlow(get(Preference.ANALYTICS_ENABLED)), diff --git a/app/src/main/res/drawable-hdpi/splash_fg.png b/app/src/main/res/drawable-hdpi/splash_fg.png deleted file mode 100644 index 6ef4bcb..0000000 Binary files a/app/src/main/res/drawable-hdpi/splash_fg.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/splash_fg.png b/app/src/main/res/drawable-mdpi/splash_fg.png deleted file mode 100644 index 85f5d03..0000000 Binary files a/app/src/main/res/drawable-mdpi/splash_fg.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/splash_fg.png b/app/src/main/res/drawable-xhdpi/splash_fg.png deleted file mode 100644 index 0ac3d2d..0000000 Binary files a/app/src/main/res/drawable-xhdpi/splash_fg.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/splash_fg.png b/app/src/main/res/drawable-xxhdpi/splash_fg.png deleted file mode 100644 index a820e6d..0000000 Binary files a/app/src/main/res/drawable-xxhdpi/splash_fg.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/splash_fg.png b/app/src/main/res/drawable-xxxhdpi/splash_fg.png deleted file mode 100644 index abf9773..0000000 Binary files a/app/src/main/res/drawable-xxxhdpi/splash_fg.png and /dev/null differ diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml deleted file mode 100644 index 0a4164e..0000000 --- a/app/src/main/res/values-night/colors.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - #000000 - #FFFFFF - diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index afeaf77..8277eaa 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -4,8 +4,6 @@ #b71c1c #d32f2f #FFFFFF - #000000 #24292e - #FFFFFFFF #689f38 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml deleted file mode 100644 index 59a0b0c..0000000 --- a/app/src/main/res/values/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 16dp - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 603f757..b8fd010 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,65 +2,52 @@ Simple Markdown Markdown - Settings - Help + Editor + Settings + Help + About Edit Preview - + OK Markdown here… Save + Editor Actions Share - Export - Unable to share file - no capable apps installed Share file to… - Unable to save file without permissions Successfully saved %1$s - Load - Select a file to open - No file browser apps found An error occurred while saving the file - File successfully loaded - An error occurred while writing the file + Successfully loaded %1$s An error occurred while opening the file - Libraries + Back + Main Menu New - Done Open Yes No - Settings Enable autosave - Automatically save files when closing the app Lock Swiping - Select Privacy - crashlytics.enable Enable automated error reports Error reports will not be sent Error reports will be sent - readability.enable + Send analytics + Analytics events will not be sent + Analytics events will be sent + Force a crash + Purposefully crash the app for testing purposes Enable readability highlighting (experimental) Readability highlighting is off Readability highlighting is on Files will be automatically saved Files will not be automatically saved - pref.custom_css - Custom CSS - Paste or write your own CSS to be used for the preview pane - pre {overflow:scroll; padding:15px; background: #F1F1F1;} - body{background: - #000000;color: #F1F1F1;}a{color:#7b91ff;}pre{background:#111111;} - darkMode Dark Mode Light Dark - Auto + light dark auto - Save Changes Would you like to save your changes? - Discard Save as… SimpleMarkdown is a personal project of mine that I develop and maintain in my free time. I very much appreciate any and all forms of support, whether @@ -73,12 +60,11 @@ View SimpleMarkdown on GitHub Support SimpleMarkdown Rate SimpleMarkdown - Heart - - @string/pref_value_light - @string/pref_value_dark - @string/pref_value_auto - + + + + + @string/pref_key_dark_mode_light @string/pref_key_dark_mode_dark diff --git a/app/src/test/java/com/wbrawner/simplemarkdown/MarkdownViewModelTest.kt b/app/src/test/java/com/wbrawner/simplemarkdown/MarkdownViewModelTest.kt index dc31c6b..44fe955 100644 --- a/app/src/test/java/com/wbrawner/simplemarkdown/MarkdownViewModelTest.kt +++ b/app/src/test/java/com/wbrawner/simplemarkdown/MarkdownViewModelTest.kt @@ -5,14 +5,11 @@ import androidx.lifecycle.viewmodel.CreationExtras import com.wbrawner.simplemarkdown.utility.Preference 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 @@ -25,8 +22,6 @@ 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 @@ -185,7 +180,7 @@ class MarkdownViewModelTest { requireNotNull(onClick) onClick.invoke() } - assertEquals(viewModel.state.value, EditorState()) + assertEquals(viewModel.state.value, EditorState(reloadToggle = 0.inv())) } @Test @@ -204,7 +199,7 @@ class MarkdownViewModelTest { viewModel.save(uri) assertNotNull(viewModel.state.value.saveCallback) requireNotNull(viewModel.state.value.saveCallback).invoke() - assertEquals(viewModel.state.value, EditorState()) + assertEquals(viewModel.state.value, EditorState(reloadToggle = 0.inv())) } @Test diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 46113d5..bdf55b5 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -47,6 +47,9 @@ android { kotlinOptions { jvmTarget = "1.8" } + lint { + warningsAsErrors = true + } } dependencies { diff --git a/free/build.gradle.kts b/free/build.gradle.kts index fa1de8c..82c9ede 100644 --- a/free/build.gradle.kts +++ b/free/build.gradle.kts @@ -33,6 +33,9 @@ android { kotlinOptions { jvmTarget = "1.8" } + lint { + warningsAsErrors = true + } } dependencies { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1fe08d6..74dd6ee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] acra = "5.11.3" -activityKtx = "1.9.0" +activityKtx = "1.9.1" animationCore = "1.6.8" appcompat = "1.7.0" billing = "7.0.0" @@ -20,7 +20,7 @@ androidGradlePlugin = "8.5.1" hamcrestCore = "1.3" junit = "4.13.2" kotlin = "2.0.0" -lifecycleViewmodelKtx = "2.8.3" +lifecycleViewmodelKtx = "2.8.4" material = "1.12.0" material3WindowSizeClassAndroid = "1.2.1" materialIconsCore = "1.6.8" diff --git a/non-free/build.gradle.kts b/non-free/build.gradle.kts index 04c916e..0eca8ae 100644 --- a/non-free/build.gradle.kts +++ b/non-free/build.gradle.kts @@ -33,6 +33,9 @@ android { kotlinOptions { jvmTarget = "1.8" } + lint { + warningsAsErrors = true + } } dependencies {