Compare commits

..

No commits in common. "main" and "fix-free-release" have entirely different histories.

24 changed files with 285 additions and 364 deletions

View file

@ -15,7 +15,7 @@ jobs:
distribution: 'zulu'
java-version: '17'
- name: Validate Gradle Wrapper
uses: https://git.wbrawner.com/gradle/actions/wrapper-validation@v4
uses: https://git.wbrawner.com/gradle/actions/wrapper-validation@v3
unit_tests:
name: Run Unit Tests
runs-on: ubuntu-latest
@ -30,10 +30,10 @@ jobs:
java-version: '17'
- name: Setup Android SDK
uses: https://git.wbrawner.com/android-actions/setup-android@v3
- name: Setup Gradle
uses: https://git.wbrawner.com/gradle/actions/setup-gradle@v4
- name: Run unit tests
run: ./gradlew check
uses: https://git.wbrawner.com/gradle/actions/setup-gradle@v3
with:
arguments: check
- name: Publish JUnit Results
uses: actions/upload-artifact@v3
if: always()
@ -55,10 +55,10 @@ jobs:
java-version: '17'
- name: Setup Android SDK
uses: https://git.wbrawner.com/android-actions/setup-android@v3
- name: Setup Gradle
uses: https://git.wbrawner.com/gradle/actions/setup-gradle@v4
- name: Build APKs
run: ./gradlew assemblePlayDebug assemblePlayDebugAndroidTest
- name: Build with Gradle
uses: https://git.wbrawner.com/gradle/gradle-build-action@v2
with:
arguments: assemblePlayDebug assemblePlayDebugAndroidTest
- name: Grant execute permission for flank_auth.sh
run: chmod +x flank_auth.sh
- name: Add auth for flank
@ -67,4 +67,6 @@ jobs:
run: |
./flank_auth.sh
- name: Run UI tests
run: ./gradlew runFlank
uses: https://git.wbrawner.com/gradle/gradle-build-action@v2
with:
arguments: runFlank

View file

@ -37,7 +37,7 @@ jobs:
distribution: 'zulu'
java-version: '17'
- name: Run unit tests
uses: gradle/gradle-build-action@v3
uses: gradle/gradle-build-action@v2
with:
arguments: testPlayDebugUnitTest
- name: Publish JUnit Results
@ -61,7 +61,7 @@ jobs:
distribution: 'zulu'
java-version: '17'
- name: Build with Gradle
uses: gradle/gradle-build-action@v3
uses: gradle/gradle-build-action@v2
with:
arguments: assemblePlayDebug assemblePlayDebugAndroidTest
- name: Grant execute permission for flank_auth.sh
@ -72,6 +72,6 @@ jobs:
run: |
./flank_auth.sh
- name: Run UI tests
uses: gradle/gradle-build-action@v3
uses: gradle/gradle-build-action@v2
with:
arguments: runFlank

View file

@ -21,7 +21,6 @@ try {
keystoreProperties["keyPassword"] = ""
keystoreProperties["storeFile"] = File.createTempFile("temp", ".tmp").absolutePath
keystoreProperties["storePassword"] = ""
keystoreProperties["publishCredentialsFile"] = ""
}
android {
@ -38,18 +37,18 @@ android {
}
compileSdk = libs.versions.maxSdk.get().toInt()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "11"
jvmTarget = "1.8"
}
defaultConfig {
applicationId = "com.wbrawner.simplemarkdown"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.maxSdk.get().toInt()
versionCode = 45
versionName = "2024.10.0"
versionCode = 44
versionName = "1.0.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
buildConfigField("boolean", "ENABLE_CUSTOM_CSS", "true")
@ -99,11 +98,7 @@ android {
}
}
lint {
disable += listOf(
"AndroidGradlePluginVersion",
"GradleDependency",
"ObsoleteLintCustomCheck"
)
disable += listOf("AndroidGradlePluginVersion", "GradleDependency")
warningsAsErrors = true
}
}
@ -113,9 +108,6 @@ play {
enabled.set(false)
track.set("production")
defaultToAppBundles.set(true)
(keystoreProperties["publishCredentialsFile"] as? String)?.ifBlank { null }?.let {
serviceAccountCredentials.set(file(it))
}
}
dependencies {
@ -162,6 +154,7 @@ dependencies {
androidTestImplementation(libs.androidx.core)
androidTestImplementation(libs.androidx.monitor)
androidTestImplementation(libs.junit)
androidTestImplementation(libs.hamcrest.core)
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.animation.core)

View file

@ -9,6 +9,7 @@ import androidx.compose.ui.test.junit4.createEmptyComposeRule
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
@ -181,29 +182,6 @@ class MarkdownTests {
}
}
@Test
fun launchWithContentUriTest() = runTest {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val fileUri = FileProvider.getUriForFile(
getApplicationContext(),
"${BuildConfig.APPLICATION_ID}.fileprovider",
file
)
ActivityScenario.launch<MainActivity>(
Intent(
Intent.ACTION_VIEW,
fileUri,
getInstrumentation().targetContext,
MainActivity::class.java
)
)
onMainScreen(composeRule) {
awaitIdle()
checkMarkdownEquals(markdownText)
checkTitleEquals("temp.md")
}
}
@Test
fun openEditAndSaveMarkdownTest() = runTest {
@ -232,23 +210,4 @@ class MarkdownTests {
checkTitleEquals("temp.md")
}
}
@Test
fun editAndViewHelpMarkdownTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
onMainScreen(composeRule) {
checkTitleEquals("Untitled.md")
typeMarkdown("# Header test")
checkMarkdownEquals("# Header test")
openDrawer()
} onNavigationDrawer {
openHelpPage()
} onHelpScreen {
checkTitleEquals("Help")
verifyH1("Headings/Titles")
pressBack()
} onMainScreen {
checkMarkdownEquals("# Header test")
}
}
}

View file

@ -27,10 +27,8 @@ fun onMainScreen(composeRule: ComposeTestRule, block: MainScreenRobot.() -> Unit
suspend fun CoroutineScope.onMainScreen(
composeRule: ComposeTestRule,
block: suspend MainScreenRobot.() -> Unit
): MainScreenRobot {
val mainScreenRobot = MainScreenRobot(composeRule)
block.invoke(mainScreenRobot)
return mainScreenRobot
) {
block.invoke(MainScreenRobot(composeRule))
}
class MainScreenRobot(private val composeRule: ComposeTestRule) :

View file

@ -1,14 +1,7 @@
package com.wbrawner.simplemarkdown.robot
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
class MarkdownInfoScreenRobot(private val composeTestRule: ComposeTestRule) :
TopAppBarRobot by ComposeTopAppBarRobot(composeTestRule),
WebViewRobot by EspressoWebViewRobot() {
fun pressBack() = composeTestRule.onNodeWithContentDescription("Back").performClick()
infix fun onMainScreen(block: MainScreenRobot.() -> Unit) =
MainScreenRobot(composeTestRule).apply(block)
}
WebViewRobot by EspressoWebViewRobot()

View file

@ -7,7 +7,6 @@
<application
android:name=".MarkdownApplication"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:resizeableActivity="true"
@ -15,7 +14,7 @@
android:supportsRtl="true"
android:theme="@style/Theme.App.Starting"
tools:ignore="AllowBackup"
tools:targetApi="tiramisu">
tools:targetApi="n">
<activity android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name_short">

View file

@ -1,7 +1,5 @@
package com.wbrawner.simplemarkdown
import android.app.ComponentCaller
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
@ -10,11 +8,12 @@ import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.core.EaseIn
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.scaleOut
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
@ -28,13 +27,11 @@ import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@ -46,7 +43,6 @@ import com.wbrawner.simplemarkdown.ui.SettingsScreen
import com.wbrawner.simplemarkdown.ui.SupportScreen
import com.wbrawner.simplemarkdown.ui.theme.SimpleMarkdownTheme
import com.wbrawner.simplemarkdown.utility.Preference
import kotlinx.coroutines.launch
import org.acra.ACRA
class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsResultCallback {
@ -65,6 +61,8 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
setContent {
val autosaveEnabled by preferenceHelper.observe<Boolean>(Preference.AUTOSAVE_ENABLED)
.collectAsState()
val readabilityEnabled by preferenceHelper.observe<Boolean>(Preference.READABILITY_ENABLED)
.collectAsState()
val darkModePreference by preferenceHelper.observe<String>(Preference.DARK_MODE)
.collectAsState()
LaunchedEffect(darkModePreference) {
@ -94,10 +92,6 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
LaunchedEffect(errorReporterPreference) {
ACRA.errorReporter.setEnabled(errorReporterPreference)
}
val intentData = remember(intent) { intent?.data }
LaunchedEffect(intentData) {
viewModel.load(intentData?.toString())
}
val windowSizeClass = calculateWindowSizeClass(this)
SimpleMarkdownTheme {
val navController = rememberNavController()
@ -114,9 +108,13 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
towards = AnimatedContentTransitionScope.SlideDirection.Start
)
},
popEnterTransition = { fadeIn() },
popEnterTransition = { EnterTransition.None },
popExitTransition = {
scaleOut(targetScale = 0.9f) + slideOutOfContainer(
fadeOut(
animationSpec = tween(
300, easing = LinearEasing
)
) + slideOutOfContainer(
animationSpec = tween(300, easing = EaseIn),
towards = AnimatedContentTransitionScope.SlideDirection.End
)
@ -128,6 +126,7 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
viewModel = viewModel,
enableWideLayout = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded,
enableAutosave = autosaveEnabled,
enableReadability = readabilityEnabled
)
}
composable(Route.SETTINGS.path) {
@ -161,15 +160,6 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
}
}
}
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
super.onNewIntent(intent, caller)
lifecycleScope.launch {
intent.data?.let {
viewModel.load(it.toString())
}
}
}
}
enum class Route(

View file

@ -1,16 +1,11 @@
package com.wbrawner.simplemarkdown
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import com.wbrawner.simplemarkdown.core.LocalOnlyException
import com.wbrawner.simplemarkdown.model.Readability
import com.wbrawner.simplemarkdown.utility.FileHelper
import com.wbrawner.simplemarkdown.utility.Preference
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
@ -19,6 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@ -28,17 +24,21 @@ import java.net.URI
data class EditorState(
val fileName: String = "Untitled.md",
val markdown: TextFieldValue = TextFieldValue(""),
val markdown: String = "",
val path: URI? = null,
val toast: ParameterizedText? = null,
val alert: AlertDialogModel? = null,
val saveCallback: (() -> Unit)? = null,
/**
* Used to signal to the view that it should reload due to an external change, like loading
* a new file
*/
val reloadToggle: Int = 0,
val lockSwiping: Boolean = false,
val enableReadability: Boolean = false,
val initialMarkdown: String = "",
private val initialMarkdown: String = "",
) {
val dirty: Boolean
get() = markdown.text != initialMarkdown
get() = markdown != initialMarkdown
}
class MarkdownViewModel(
@ -50,43 +50,32 @@ class MarkdownViewModel(
private val saveMutex = Mutex()
init {
viewModelScope.launch {
load(null)
}
preferenceHelper.observe<Boolean>(Preference.LOCK_SWIPING)
.onEach {
updateState { copy(lockSwiping = it) }
}
.launchIn(viewModelScope)
preferenceHelper.observe<Boolean>(Preference.READABILITY_ENABLED)
.onEach {
updateState {
copy(
enableReadability = it,
markdown = markdown.copy(annotatedString = markdown.text.annotate(it)),
)
}
_state.value = _state.value.copy(lockSwiping = it)
}
.launchIn(viewModelScope)
}
fun updateMarkdown(markdown: String?) = updateMarkdown(TextFieldValue(markdown.orEmpty()))
fun updateMarkdown(markdown: TextFieldValue) {
updateState {
copy(
markdown = markdown.copy(annotatedString = markdown.text.annotate(enableReadability)),
fun updateMarkdown(markdown: String?) {
_state.value = _state.value.copy(
markdown = markdown ?: "",
)
}
}
fun dismissToast() {
updateState { copy(toast = null) }
_state.value = _state.value.copy(toast = null)
}
fun dismissAlert() {
updateState { copy(alert = null) }
_state.value = _state.value.copy(alert = null)
}
private fun unsetSaveCallback() {
updateState { copy(saveCallback = null) }
_state.value = _state.value.copy(saveCallback = null)
}
suspend fun load(loadPath: String?) {
@ -109,30 +98,25 @@ class MarkdownViewModel(
val uri = URI.create(actualLoadPath)
fileHelper.open(uri)
?.let { (name, content) ->
updateState {
copy(
val currentState = _state.value
_state.value = currentState.copy(
path = uri,
fileName = name,
markdown = TextFieldValue(content),
markdown = content,
initialMarkdown = content,
reloadToggle = currentState.reloadToggle.inv(),
toast = ParameterizedText(R.string.file_loaded, arrayOf(name))
)
}
preferenceHelper[Preference.AUTOSAVE_URI] = actualLoadPath
} ?: throw IllegalStateException("Opened file was null")
} catch (e: Exception) {
Timber.e(LocalOnlyException(e), "Failed to open file at path: $actualLoadPath")
updateState {
copy(
_state.value = _state.value.copy(
alert = AlertDialogModel(
text = ParameterizedText(R.string.file_load_error),
confirmButton = AlertDialogModel.ButtonModel(
ParameterizedText(R.string.ok),
onClick = ::dismissAlert
confirmButton = AlertDialogModel.ButtonModel(ParameterizedText(R.string.ok), onClick = ::dismissAlert)
)
)
)
}
}
}
}
@ -144,35 +128,27 @@ class MarkdownViewModel(
?: run {
Timber.w("Attempted to save file with empty path")
if (interactive) {
updateState {
copy(saveCallback = ::unsetSaveCallback)
}
_state.value = _state.value.copy(saveCallback = ::unsetSaveCallback)
}
return@withLock false
}
try {
Timber.i("Saving file to $actualSavePath...")
val currentState = _state.value
val name = fileHelper.save(actualSavePath, currentState.markdown.text)
updateState {
currentState.copy(
val name = fileHelper.save(actualSavePath, currentState.markdown)
_state.value = currentState.copy(
fileName = name,
path = actualSavePath,
initialMarkdown = currentState.markdown.text,
toast = if (interactive) ParameterizedText(
R.string.file_saved,
arrayOf(name)
) else null
initialMarkdown = currentState.markdown,
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) {
Timber.e(e, "Failed to save file to $actualSavePath")
updateState {
copy(
_state.value = _state.value.copy(
alert = AlertDialogModel(
text = ParameterizedText(R.string.file_save_error),
confirmButton = AlertDialogModel.ButtonModel(
@ -181,7 +157,6 @@ class MarkdownViewModel(
)
)
)
}
false
}
}
@ -213,7 +188,7 @@ class MarkdownViewModel(
// 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.text)
fileHelper.save(file, _state.value.markdown)
preferenceHelper[Preference.AUTOSAVE_URI] = file
}
}
@ -222,8 +197,7 @@ class MarkdownViewModel(
fun reset(untitledFileName: String, force: Boolean = false) {
Timber.i("Resetting view model to default state")
if (!force && _state.value.dirty) {
updateState {
copy(alert = AlertDialogModel(
_state.value = _state.value.copy(alert = AlertDialogModel(
text = ParameterizedText(R.string.prompt_save_changes),
confirmButton = AlertDialogModel.ButtonModel(
text = ParameterizedText(R.string.yes),
@ -242,15 +216,14 @@ class MarkdownViewModel(
}
)
))
}
return
}
updateState {
_state.value =
EditorState(
fileName = untitledFileName,
reloadToggle = _state.value.reloadToggle.inv(),
lockSwiping = preferenceHelper[Preference.LOCK_SWIPING] as Boolean
)
}
Timber.i("Removing autosave uri from shared prefs")
preferenceHelper[Preference.AUTOSAVE_URI] = null
}
@ -259,10 +232,6 @@ class MarkdownViewModel(
preferenceHelper[Preference.LOCK_SWIPING] = enabled
}
private fun updateState(block: EditorState.() -> EditorState) {
_state.value = _state.value.block()
}
companion object {
fun factory(
fileHelper: FileHelper,
@ -306,16 +275,3 @@ data class ParameterizedText(@StringRes val text: Int, val params: Array<Any> =
return result
}
}
private fun String.annotate(enableReadability: Boolean): AnnotatedString {
if (!enableReadability) return AnnotatedString(this)
val readability = Readability(this)
val annotated = AnnotatedString.Builder(this)
for (sentence in readability.sentences()) {
var color = Color.Transparent
if (sentence.syllableCount() > 25) color = Color(229, 232, 42, 100)
if (sentence.syllableCount() > 35) color = Color(193, 66, 66, 100)
annotated.addStyle(SpanStyle(background = color), sentence.start(), sentence.end())
}
return annotated.toAnnotatedString()
}

View file

@ -4,6 +4,7 @@ import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -52,7 +53,6 @@ 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.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.startActivity
import androidx.navigation.NavController
@ -76,10 +76,13 @@ fun MainScreen(
viewModel: MarkdownViewModel,
enableWideLayout: Boolean,
enableAutosave: Boolean,
enableReadability: Boolean
) {
val coroutineScope = rememberCoroutineScope()
val fileName by viewModel.collectAsState(EditorState::fileName, "")
val markdown by viewModel.collectAsState(EditorState::markdown, TextFieldValue(""))
val initialMarkdown by viewModel.collectAsState(EditorState::markdown, "")
val reloadToggle by viewModel.collectAsState(EditorState::reloadToggle, 0)
val markdown by viewModel.collectAsState(EditorState::markdown, "")
val dirty by viewModel.collectAsState(EditorState::dirty, false)
val alert by viewModel.collectAsState(EditorState::alert, null)
val saveCallback by viewModel.collectAsState(EditorState::saveCallback, null)
@ -95,6 +98,8 @@ fun MainScreen(
MainScreen(
dirty = dirty,
fileName = fileName,
reloadToggle = reloadToggle,
initialMarkdown = initialMarkdown,
markdown = markdown,
setMarkdown = viewModel::updateMarkdown,
lockSwiping = lockSwiping,
@ -122,6 +127,7 @@ fun MainScreen(
viewModel.reset("Untitled.md")
},
enableWideLayout = enableWideLayout,
enableReadability = enableReadability,
)
}
@ -130,8 +136,10 @@ fun MainScreen(
private fun MainScreen(
fileName: String = "Untitled.md",
dirty: Boolean = false,
markdown: TextFieldValue = TextFieldValue(""),
setMarkdown: (TextFieldValue) -> Unit = {},
reloadToggle: Int = 0,
initialMarkdown: String = "",
markdown: String = "",
setMarkdown: (String) -> Unit = {},
lockSwiping: Boolean,
toggleLockSwiping: (Boolean) -> Unit,
message: String? = null,
@ -145,6 +153,7 @@ private fun MainScreen(
saveCallback: (() -> Unit)? = null,
reset: () -> Unit = {},
enableWideLayout: Boolean = false,
enableReadability: Boolean = false
) {
val openFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
@ -205,7 +214,7 @@ private fun MainScreen(
actions = {
IconButton(onClick = {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, markdown.text)
shareIntent.putExtra(Intent.EXTRA_TEXT, markdown)
shareIntent.type = "text/plain"
startActivity(
context, Intent.createChooser(
@ -278,8 +287,10 @@ private fun MainScreen(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
reload = reloadToggle,
markdown = markdown,
setMarkdown = setMarkdown,
enableReadability = enableReadability,
)
Spacer(
modifier = Modifier
@ -291,7 +302,7 @@ private fun MainScreen(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
markdown = markdown.text
markdown = markdown
)
}
} else {
@ -301,9 +312,12 @@ private fun MainScreen(
.padding(paddingValues)
) {
TabbedMarkdownEditor(
initialMarkdown = initialMarkdown,
markdown = markdown,
setMarkdown = setMarkdown,
lockSwiping = lockSwiping,
enableReadability = enableReadability,
reloadToggle = reloadToggle,
scrollBehavior = scrollBehavior
)
}
@ -313,11 +327,14 @@ private fun MainScreen(
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
private fun TabbedMarkdownEditor(
markdown: TextFieldValue,
setMarkdown: (TextFieldValue) -> Unit,
initialMarkdown: String,
markdown: String,
setMarkdown: (String) -> Unit,
lockSwiping: Boolean,
enableReadability: Boolean,
reloadToggle: Int,
scrollBehavior: TopAppBarScrollBehavior
) {
val coroutineScope = rememberCoroutineScope()
@ -332,7 +349,7 @@ private fun TabbedMarkdownEditor(
}
HorizontalPager(
modifier = Modifier.fillMaxSize(1f), state = pagerState,
beyondViewportPageCount = 1,
beyondBoundsPageCount = 1,
userScrollEnabled = !lockSwiping
) { page ->
val keyboardController = LocalSoftwareKeyboardController.current
@ -347,15 +364,17 @@ private fun TabbedMarkdownEditor(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
markdown = markdown,
markdown = initialMarkdown,
setMarkdown = setMarkdown,
enableReadability = enableReadability,
reload = reloadToggle,
)
} else {
MarkdownText(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
markdown.text
markdown
)
}
}

View file

@ -1,23 +1,33 @@
package com.wbrawner.simplemarkdown.ui
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardCapitalization
@ -25,14 +35,28 @@ 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)
@Composable
fun MarkdownTextField(
modifier: Modifier = Modifier,
markdown: TextFieldValue,
setMarkdown: (TextFieldValue) -> Unit,
markdown: String,
setMarkdown: (String) -> Unit,
reload: Int = 0,
enableReadability: Boolean = false,
) {
val (selection, setSelection) = remember { mutableStateOf(TextRange.Zero) }
val (composition, setComposition) = remember { mutableStateOf<TextRange?>(null) }
val (textFieldValue, setTextFieldValue) = remember(reload) {
mutableStateOf(TextFieldValue(markdown.annotate(enableReadability), selection, composition))
}
val setTextFieldAndViewModelValues: (TextFieldValue) -> Unit = {
setSelection(it.selection)
setComposition(it.composition)
setTextFieldValue(it.copy(annotatedString = it.text.annotate(enableReadability)))
setMarkdown(it.text)
}
val colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
@ -46,11 +70,17 @@ fun MarkdownTextField(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
Column(
modifier = modifier
.fillMaxSize()
.imePadding()
.verticalScroll(rememberScrollState())
) {
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
BasicTextField(
value = markdown,
modifier = modifier.imePadding(),
onValueChange = setMarkdown,
value = textFieldValue,
modifier = Modifier.fillMaxSize(),
onValueChange = setTextFieldAndViewModelValues,
enabled = true,
readOnly = false,
textStyle = textStyle,
@ -64,7 +94,7 @@ fun MarkdownTextField(
decorationBox = @Composable { innerTextField ->
// places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox(
value = markdown.text,
value = textFieldValue.text,
visualTransformation = VisualTransformation.None,
innerTextField = innerTextField,
placeholder = {
@ -76,7 +106,21 @@ fun MarkdownTextField(
colors = colors,
contentPadding = PaddingValues(8.dp)
)
},
}
)
}
}
}
private fun String.annotate(enableReadability: Boolean): AnnotatedString {
if (!enableReadability) return AnnotatedString(this)
val readability = Readability(this)
val annotated = AnnotatedString.Builder(this)
for (sentence in readability.sentences()) {
var color = Color.Transparent
if (sentence.syllableCount() > 25) color = Color(229, 232, 42, 100)
if (sentence.syllableCount() > 35) color = Color(193, 66, 66, 100)
annotated.addStyle(SpanStyle(background = color), sentence.start(), sentence.end())
}
return annotated.toAnnotatedString()
}

View file

@ -1,11 +1,11 @@
package com.wbrawner.simplemarkdown.utility
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.res.AssetManager
import android.net.Uri
import android.provider.OpenableColumns
import android.view.View
import android.view.inputmethod.InputMethodManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.Reader
@ -41,11 +41,3 @@ suspend fun Uri.getName(context: Context): String {
}
return fileName ?: "Untitled.md"
}
@Suppress("RecursivePropertyAccessor")
val Context.activity: Activity?
get() = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.activity
else -> null
}

View file

@ -47,7 +47,6 @@ class AndroidPreferenceHelper(context: Context, private val coroutineScope: Coro
}
}
@Suppress("UNCHECKED_CAST")
override fun <T> observe(preference: Preference): StateFlow<T> = states[preference]!!.asStateFlow() as StateFlow<T>
}

View file

@ -1,2 +0,0 @@
- Fix opening files from external apps
- Update dependencies

View file

@ -0,0 +1,5 @@
- Fix crash on markdown preview
- Persist preference for Lock Swiping
- Enable gestures on nav drawer when open
- Close navigation drawer on back press
- Various dependency updates

View file

@ -23,7 +23,6 @@ class FakePreferenceHelper: PreferenceHelper {
preferences[preference] = value
}
@Suppress("UNCHECKED_CAST")
override fun <T> observe(preference: Preference): StateFlow<T> =
preferenceFlow(preference) as StateFlow<T>
}

View file

@ -1,6 +1,5 @@
package com.wbrawner.simplemarkdown
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import com.wbrawner.simplemarkdown.utility.Preference
@ -24,7 +23,6 @@ import timber.log.Timber
import java.io.File
import java.net.URI
@OptIn(ExperimentalCoroutinesApi::class)
class MarkdownViewModelTest {
private lateinit var fileHelper: FakeFileHelper
private lateinit var preferenceHelper: FakePreferenceHelper
@ -53,9 +51,9 @@ class MarkdownViewModelTest {
@Test
fun testMarkdownUpdate() = runTest {
assertEquals("".asTextFieldValue(), viewModel.state.value.markdown)
assertEquals("", viewModel.state.value.markdown)
viewModel.updateMarkdown("Updated content")
assertEquals("Updated content".asTextFieldValue(), viewModel.state.value.markdown)
assertEquals("Updated content", viewModel.state.value.markdown)
}
@Test
@ -69,11 +67,11 @@ class MarkdownViewModelTest {
val uri = URI.create("file:///home/user/Untitled.md")
preferenceHelper[Preference.AUTOSAVE_URI] = uri.toString()
viewModel = viewModelFactory.create(MarkdownViewModel::class.java, CreationExtras.Empty)
viewModel.load(null)
viewModelScope.advanceUntilIdle()
assertEquals(uri, fileHelper.openedUris.firstOrNull())
val (fileName, contents) = fileHelper.file
assertEquals(fileName, viewModel.state.value.fileName)
assertEquals(contents.asTextFieldValue(), viewModel.state.value.markdown)
assertEquals(contents, viewModel.state.value.markdown)
}
@Test
@ -83,7 +81,7 @@ class MarkdownViewModelTest {
assertEquals(uri, fileHelper.openedUris.firstOrNull())
val (fileName, contents) = fileHelper.file
assertEquals(fileName, viewModel.state.value.fileName)
assertEquals(contents.asTextFieldValue(), viewModel.state.value.markdown)
assertEquals(contents, viewModel.state.value.markdown)
}
@Test
@ -127,7 +125,7 @@ class MarkdownViewModelTest {
val uri = URI.create("file:///home/user/Saved.md")
val testMarkdown = "# Test"
viewModel.updateMarkdown(testMarkdown)
assertEquals(testMarkdown.asTextFieldValue(), viewModel.state.value.markdown)
assertEquals(testMarkdown, viewModel.state.value.markdown)
assertTrue(viewModel.save(uri))
assertEquals("Saved.md", viewModel.state.value.fileName)
assertEquals(uri, fileHelper.savedData.last().uri)
@ -140,7 +138,7 @@ class MarkdownViewModelTest {
val uri = URI.create("file:///home/user/Untitled.md")
val testMarkdown = "# Test"
viewModel.updateMarkdown(testMarkdown)
assertEquals(testMarkdown.asTextFieldValue(), viewModel.state.value.markdown)
assertEquals(testMarkdown, viewModel.state.value.markdown)
fileHelper.errorOnSave = true
assertNull(viewModel.state.value.alert)
assertFalse(viewModel.save(uri))
@ -160,7 +158,7 @@ class MarkdownViewModelTest {
assertNull(viewModel.state.value.alert)
with(viewModel.state.value) {
assertEquals("New.md", fileName)
assertEquals("".asTextFieldValue(), markdown)
assertEquals("", markdown)
assertNull(path)
assertNull(saveCallback)
assertNull(alert)
@ -182,7 +180,7 @@ class MarkdownViewModelTest {
requireNotNull(onClick)
onClick.invoke()
}
assertEquals(viewModel.state.value, EditorState())
assertEquals(viewModel.state.value, EditorState(reloadToggle = 0.inv()))
}
@Test
@ -201,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
@ -218,7 +216,7 @@ class MarkdownViewModelTest {
assertNull(viewModel.state.value.alert)
with(viewModel.state.value) {
assertEquals("Unsaved.md", fileName)
assertEquals("".asTextFieldValue(), markdown)
assertEquals("", markdown)
assertNull(path)
assertNull(saveCallback)
assertNull(alert)
@ -304,6 +302,4 @@ class MarkdownViewModelTest {
assertFalse(preferenceHelper.preferences[Preference.LOCK_SWIPING] as Boolean)
assertFalse(viewModel.state.value.lockSwiping)
}
private fun String.asTextFieldValue() = TextFieldValue(this)
}

View file

@ -2,37 +2,25 @@ package com.wbrawner.releasehelper
import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.internal.provider.Providers
import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.Property
import org.gradle.api.provider.ProviderFactory
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import javax.inject.Inject
private const val CHANGELOG_PATH = "src/play/play/release-notes/en-US/production.txt"
private const val CHANGELOG_PATH = "src/play/play/release-notes/en-US/default.txt"
abstract class ChangelogTask @Inject constructor(
objectFactory: ObjectFactory,
providers: ProviderFactory,
) : DefaultTask() {
abstract class ChangelogTask @Inject constructor(objectFactory: ObjectFactory) : DefaultTask() {
@get:OutputFile
val changelogFile: RegularFileProperty = objectFactory.fileProperty()
@get:Input
@Suppress("UnstableApiUsage")
val latestTag: String = providers.exec {
commandLine("git" , "describe", "--tags", "--abbrev=0")
}.standardOutput.asText.get()
init {
changelogFile.set(project.layout.projectDirectory.file(CHANGELOG_PATH))
}
@TaskAction
fun execute() {
val changelog = "git log --format=\"%B\" ${latestTag.trim()}..".execute()
val latestTag = "git describe --tags --abbrev=0".execute()
val changelog = "git log --format=\"%B\" ${latestTag.first().trim()}..".execute()
logger.info("Latest tag: $latestTag")
logger.info("Changelog: ${changelog.joinToString("\n")}")
changelogFile.get().asFile.writer().use { writer ->

View file

@ -41,18 +41,14 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "11"
jvmTarget = "1.8"
}
lint {
disable += listOf(
"AndroidGradlePluginVersion",
"GradleDependency",
"ObsoleteLintCustomCheck"
)
disable += listOf("AndroidGradlePluginVersion", "GradleDependency")
warningsAsErrors = true
}
}

View file

@ -27,18 +27,14 @@ android {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "11"
jvmTarget = "1.8"
}
lint {
disable += listOf(
"AndroidGradlePluginVersion",
"GradleDependency",
"ObsoleteLintCustomCheck"
)
disable += listOf("AndroidGradlePluginVersion", "GradleDependency")
warningsAsErrors = true
}
}

View file

@ -1,40 +1,43 @@
[versions]
acra = "5.12.0"
activityKtx = "1.9.3"
animationCore = "1.7.5"
acra = "5.11.3"
activityKtx = "1.9.1"
animationCore = "1.6.8"
appcompat = "1.7.0"
billing = "7.1.1"
billing = "7.0.0"
browser = "1.8.0"
commonMarkVersion = "0.24.0"
composeBom = "2024.11.00"
commonMarkVersion = "0.22.0"
composeBom = "2024.08.00"
core = "1.6.1"
coreKtx = "1.15.0"
coreKtx = "1.13.1"
coreSplashscreen = "1.0.1"
coroutines = "1.9.0"
dependencyAnalysis = "2.5.0"
coroutines = "1.8.1"
dependencyAnalysis = "1.33.0"
espressoVersion = "3.6.1"
fladle = "0.17.5"
googleServices = "4.4.2"
firebaseCrashlyticsGradle = "3.0.2"
androidGradlePlugin = "8.7.2"
androidGradlePlugin = "8.5.2"
hamcrestCore = "1.3"
junit = "4.13.2"
kotlin = "2.0.21"
lifecycleViewmodelKtx = "2.8.7"
kotlin = "2.0.20"
lifecycleViewmodelKtx = "2.8.4"
material = "1.12.0"
material3WindowSizeClassAndroid = "1.3.1"
materialIconsCore = "1.7.5"
material3WindowSizeClassAndroid = "1.2.1"
materialIconsCore = "1.6.8"
maxSdk = "35"
minSdk = "23"
monitor = "1.7.2"
navigation = "2.8.4"
orchestrator = "1.5.1"
play = "2.0.2"
navigationCommon = "2.7.7"
navigationRuntimeKtx = "2.7.7"
navigationVersion = "2.7.7"
orchestrator = "1.5.0"
play = "2.0.1"
preferenceKtx = "1.2.1"
robolectric = "4.14.1"
robolectric = "4.13"
runner = "1.6.2"
syllableCounter = "4.1.0"
timber = "5.0.1"
tripletPlay = "3.12.1"
tripletPlay = "3.10.1"
[libraries]
acra-advanced-scheduler = { module = "ch.acra:acra-advanced-scheduler", version.ref = "acra" }
@ -62,9 +65,9 @@ androidx-material-icons-extended = { module = "androidx.compose.material:materia
androidx-material3 = { module = "androidx.compose.material3:material3" }
androidx-material3-windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class-android", version.ref = "material3WindowSizeClassAndroid" }
androidx-monitor = { module = "androidx.test:monitor", version.ref = "monitor" }
androidx-navigation-common = { module = "androidx.navigation:navigation-common", version.ref = "navigation" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
androidx-navigation-runtime-ktx = { module = "androidx.navigation:navigation-runtime-ktx", version.ref = "navigation" }
androidx-navigation-common = { module = "androidx.navigation:navigation-common", version.ref = "navigationCommon" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationVersion" }
androidx-navigation-runtime-ktx = { module = "androidx.navigation:navigation-runtime-ktx", version.ref = "navigationRuntimeKtx" }
androidx-orchestrator = { module = "androidx.test:orchestrator", version.ref = "orchestrator" }
androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" }
androidx-runner = { module = "androidx.test:runner", version.ref = "runner" }
@ -89,6 +92,7 @@ commonmark-ext-yaml-front-matter = { module = "org.commonmark:commonmark-ext-yam
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
foundation = { module = "androidx.compose.foundation:foundation" }
foundation-layout = { module = "androidx.compose.foundation:foundation-layout" }
hamcrest-core = { module = "org.hamcrest:hamcrest-core", version.ref = "hamcrestCore" }
junit = { module = "junit:junit", version.ref = "junit" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View file

@ -2,4 +2,3 @@ storePassword=
keyPassword=
keyAlias=
storeFile=
publishCredentialsFile=

View file

@ -24,21 +24,17 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
buildFeatures {
compose = true
}
kotlinOptions {
jvmTarget = "11"
jvmTarget = "1.8"
}
lint {
disable += listOf(
"AndroidGradlePluginVersion",
"GradleDependency",
"ObsoleteLintCustomCheck"
)
disable += listOf("AndroidGradlePluginVersion", "GradleDependency")
warningsAsErrors = true
}
}