Compare commits

..

No commits in common. "main" and "persist-lock-swiping" have entirely different histories.

35 changed files with 513 additions and 768 deletions

View file

@ -2,6 +2,8 @@ name: Build & Test
on:
pull_request:
push:
branches: [ main ]
jobs:
validate:
@ -15,7 +17,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 +32,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()
@ -45,7 +47,7 @@ jobs:
runs-on: ubuntu-latest
name: Run UI Tests
needs:
- unit_tests
- validate
steps:
- uses: actions/checkout@v4
- name: set up JDK
@ -55,10 +57,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 +69,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 = 41
versionName = "0.8.16"
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)
@ -188,7 +181,7 @@ fladle {
environmentVariables.put("clearPackageData", "true")
testTimeout.set("7m")
devices.add(
mapOf("model" to "Pixel2.arm", "version" to "33")
mapOf("model" to "NexusLowRes", "version" to "29")
)
projectId.set("simplemarkdown")
}

View file

@ -1,27 +0,0 @@
package com.wbrawner.simplemarkdown
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import com.wbrawner.simplemarkdown.robot.onMainScreen
import org.junit.Rule
import org.junit.Test
class HelpTest {
@get:Rule
val composeRule = createEmptyComposeRule()
@Test
fun openHelpPageTest() {
ActivityScenario.launch(MainActivity::class.java)
onMainScreen(composeRule) {
checkTitleEquals("Untitled.md")
checkMarkdownEquals("")
openDrawer()
} onNavigationDrawer {
openHelpPage()
} onHelpScreen {
checkTitleEquals("Help")
verifyH1("Headings/Titles")
}
}
}

View file

@ -5,17 +5,43 @@ import android.app.Instrumentation
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.webkit.WebView
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
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
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.performTextReplacement
import androidx.compose.ui.test.printToLog
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 com.wbrawner.simplemarkdown.robot.onMainScreen
import kotlinx.coroutines.test.runTest
import org.hamcrest.CoreMatchers.containsString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
@ -53,13 +79,13 @@ class MarkdownTests {
@Test
fun editAndPreviewMarkdownTest() {
ActivityScenario.launch(MainActivity::class.java)
onMainScreen(composeRule) {
typeMarkdown("# Header test")
checkMarkdownEquals("# Header test")
openPreview()
} onPreview {
verifyH1("Header test")
}
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
@ -72,139 +98,105 @@ class MarkdownTests {
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
ActivityScenario.launch(MainActivity::class.java)
onMainScreen(composeRule) {
openMenu()
clickOpenMenuItem()
checkMarkdownEquals(markdownText)
openMenu()
clickNewMenuItem()
verifyDialogIsNotShown()
checkMarkdownEquals("")
}
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."
onMainScreen(composeRule) {
typeMarkdown(markdownText)
openMenu()
clickNewMenuItem()
verifyDialogIsShown("Would you like to save your changes?")
discardChanges()
checkMarkdownEquals("")
}
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)
onMainScreen(composeRule) {
checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
typeMarkdown(markdownText)
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
})
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
openMenu()
clickSaveMenuItem()
awaitIdle()
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
checkTitleEquals("temp.md")
}
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)
onMainScreen(composeRule) {
checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
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)
openMenu()
clickSaveMenuItem()
awaitIdle()
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
checkTitleEquals("temp.md")
}
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)
onMainScreen(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)
openMenu()
clickOpenMenuItem()
awaitIdle()
checkMarkdownEquals(markdownText)
checkTitleEquals("temp.md")
}
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)
onMainScreen(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)
openMenu()
clickOpenMenuItem()
awaitIdle()
checkMarkdownEquals(markdownText)
checkTitleEquals("temp.md")
}
}
@Test
fun launchWithContentUriTest() = runTest {
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 fileUri = FileProvider.getUriForFile(
getApplicationContext(),
"${BuildConfig.APPLICATION_ID}.fileprovider",
file
)
ActivityScenario.launch<MainActivity>(
Intent(
Intent.ACTION_VIEW,
fileUri,
getInstrumentation().targetContext,
MainActivity::class.java
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = FileProvider.getUriForFile(
getApplicationContext(),
"${BuildConfig.APPLICATION_ID}.fileprovider",
file
)
)
onMainScreen(composeRule) {
awaitIdle()
checkMarkdownEquals(markdownText)
checkTitleEquals("temp.md")
}
})
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."
@ -214,41 +206,84 @@ class MarkdownTests {
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
ActivityScenario.launch(MainActivity::class.java)
onMainScreen(composeRule) {
checkTitleEquals("Untitled.md")
openMenu()
clickOpenMenuItem()
awaitIdle()
verifyTextIsShown("Successfully loaded temp.md")
checkMarkdownEquals(markdownText)
checkTitleEquals("temp.md")
val additionalText = "# More info\n\nThis is some additional text"
typeMarkdown(additionalText)
openMenu()
clickSaveMenuItem()
awaitIdle()
verifyTextIsShown("Successfully saved temp.md")
assertEquals(additionalText, file.inputStream().reader().use(Reader::readText))
checkTitleEquals("temp.md")
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)))
.waitUntilIsDisplayed()
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()).waitUntil {
assert(markdownMatcher)
}
}
@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")
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)))).waitUntilIsDisplayed()
private fun ComposeTestRule.verifyDialogIsNotShown() =
onNode(isDialog()).waitUntilIsNotDisplayed()
private fun ComposeTestRule.discardChanges() = onNodeWithText("No").performClick()
private fun ComposeTestRule.verifyTextIsShown(text: String) =
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()
}
}

View file

@ -1,29 +0,0 @@
package com.wbrawner.simplemarkdown
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
private const val ASSERTION_TIMEOUT = 5_000L
fun SemanticsNodeInteraction.waitUntilIsDisplayed() = waitUntil {
assertIsDisplayed()
}
fun SemanticsNodeInteraction.waitUntilIsNotDisplayed() = waitUntil {
assertIsNotDisplayed()
}
fun <T> SemanticsNodeInteraction.waitUntil(assertion: SemanticsNodeInteraction.() -> T): T {
val start = System.currentTimeMillis()
lateinit var assertionError: AssertionError
while (System.currentTimeMillis() - start < ASSERTION_TIMEOUT) {
try {
return assertion()
} catch (e: AssertionError) {
assertionError = e
Thread.sleep(10)
}
}
throw assertionError
}

View file

@ -1,80 +0,0 @@
package com.wbrawner.simplemarkdown.robot
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.hasAnyDescendant
import androidx.compose.ui.test.hasClickAction
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.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextReplacement
import com.wbrawner.simplemarkdown.waitUntil
import com.wbrawner.simplemarkdown.waitUntilIsDisplayed
import com.wbrawner.simplemarkdown.waitUntilIsNotDisplayed
import kotlinx.coroutines.CoroutineScope
fun onMainScreen(composeRule: ComposeTestRule, block: MainScreenRobot.() -> Unit) =
MainScreenRobot(composeRule).apply(block)
@Suppress("UnusedReceiverParameter") // Used to avoid import ambiguity for tests
suspend fun CoroutineScope.onMainScreen(
composeRule: ComposeTestRule,
block: suspend MainScreenRobot.() -> Unit
): MainScreenRobot {
val mainScreenRobot = MainScreenRobot(composeRule)
block.invoke(mainScreenRobot)
return mainScreenRobot
}
class MainScreenRobot(private val composeRule: ComposeTestRule) :
TopAppBarRobot by ComposeTopAppBarRobot(composeRule) {
fun typeMarkdown(markdown: String) = composeRule.onNode(hasSetTextAction())
.performTextReplacement(markdown)
fun checkMarkdownEquals(markdown: String) {
val markdownMatcher = SemanticsMatcher("Markdown = [$markdown]") {
it.config.getOrNull(SemanticsProperties.EditableText)?.text == markdown
}
composeRule.onNode(hasSetTextAction()).waitUntil {
assert(markdownMatcher)
}
}
fun openPreview() = composeRule.onNodeWithText("Preview").performClick()
fun openMenu() = composeRule.onNodeWithContentDescription("Editor Actions").performClick()
fun clickOpenMenuItem() = composeRule.onNodeWithText("Open").performClick()
fun clickNewMenuItem() = composeRule.onNodeWithText("New").performClick()
fun clickSaveMenuItem() = composeRule.onNodeWithText("Save").performClick()
fun verifyDialogIsShown(text: String) =
composeRule.onNode(isDialog().and(hasAnyDescendant(hasText(text)))).waitUntilIsDisplayed()
fun verifyDialogIsNotShown() = composeRule.onNode(isDialog()).waitUntilIsNotDisplayed()
fun discardChanges() = composeRule.onNodeWithText("No").performClick()
fun verifyTextIsShown(text: String) = composeRule.onNodeWithText(text).waitUntilIsDisplayed()
fun openDrawer() = composeRule.onNode(hasClickAction() and hasContentDescription("Main Menu"))
.waitUntilIsDisplayed()
.performClick()
suspend fun awaitIdle() = composeRule.awaitIdle()
infix fun onPreview(block: WebViewRobot.() -> Unit) = EspressoWebViewRobot().apply(block)
infix fun onNavigationDrawer(block: NavigationDrawerRobot.() -> Unit): NavigationDrawerRobot =
NavigationDrawerRobot(composeRule).apply(block = block)
}

View file

@ -1,14 +0,0 @@
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)
}

View file

@ -1,16 +0,0 @@
package com.wbrawner.simplemarkdown.robot
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.performClick
import com.wbrawner.simplemarkdown.waitUntilIsDisplayed
class NavigationDrawerRobot(private val composeTestRule: ComposeTestRule) {
fun openHelpPage() = composeTestRule.onNode(hasClickAction() and hasText("Help"))
.waitUntilIsDisplayed()
.performClick()
infix fun onHelpScreen(block: MarkdownInfoScreenRobot.() -> Unit) =
MarkdownInfoScreenRobot(composeTestRule).apply(block)
}

View file

@ -1,24 +0,0 @@
package com.wbrawner.simplemarkdown.robot
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.hasAnySibling
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.ComposeTestRule
import com.wbrawner.simplemarkdown.waitUntilIsDisplayed
interface TopAppBarRobot {
fun checkTitleEquals(title: String): SemanticsNodeInteraction
}
class ComposeTopAppBarRobot(private val composeTestRule: ComposeTestRule) : TopAppBarRobot {
override fun checkTitleEquals(title: String) =
composeTestRule.onNode(
hasAnySibling(
hasContentDescription("Main Menu") or hasContentDescription(
"Back"
)
).and(hasText(title))
)
.waitUntilIsDisplayed()
}

View file

@ -1,24 +0,0 @@
package com.wbrawner.simplemarkdown.robot
import android.webkit.WebView
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
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 org.hamcrest.CoreMatchers.containsString
interface WebViewRobot {
fun verifyH1(text: String)
}
class EspressoWebViewRobot : WebViewRobot {
private fun findWebView() = onWebView(isAssignableFrom(WebView::class.java))
.forceJavascriptEnabled()
override fun verifyH1(text: String) {
findWebView().withElement(findElement(Locator.TAG_NAME, "h1"))
.check(webMatches(getText(), containsString(text)))
}
}

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

@ -38,9 +38,7 @@ class MarkdownApplication : Application() {
}
}
}
coroutineScope.launch {
Timber.plant(ErrorReporterTree.create(this@MarkdownApplication))
}
Timber.plant(ErrorReporterTree.create(this))
super.onCreate()
ReviewHelper.init(this)
fileHelper = AndroidFileHelper(this)

View file

@ -1,16 +1,10 @@
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 +13,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 +23,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 +49,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 +97,25 @@ class MarkdownViewModel(
val uri = URI.create(actualLoadPath)
fileHelper.open(uri)
?.let { (name, content) ->
updateState {
copy(
path = uri,
fileName = name,
markdown = TextFieldValue(content),
initialMarkdown = content,
toast = ParameterizedText(R.string.file_loaded, arrayOf(name))
)
}
val currentState = _state.value
_state.value = currentState.copy(
path = uri,
fileName = name,
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(
alert = AlertDialogModel(
text = ParameterizedText(R.string.file_load_error),
confirmButton = AlertDialogModel.ButtonModel(
ParameterizedText(R.string.ok),
onClick = ::dismissAlert
)
)
Timber.e(e, "Failed to open file at path: $actualLoadPath")
_state.value = _state.value.copy(
alert = AlertDialogModel(
text = ParameterizedText(R.string.file_load_error),
confirmButton = AlertDialogModel.ButtonModel(ParameterizedText(R.string.ok), onClick = ::dismissAlert)
)
}
)
}
}
}
@ -144,44 +127,35 @@ 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(
fileName = name,
path = actualSavePath,
initialMarkdown = currentState.markdown.text,
toast = if (interactive) ParameterizedText(
R.string.file_saved,
arrayOf(name)
) else null
)
}
val name = fileHelper.save(actualSavePath, currentState.markdown)
_state.value = currentState.copy(
fileName = name,
path = actualSavePath,
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(
alert = AlertDialogModel(
text = ParameterizedText(R.string.file_save_error),
confirmButton = AlertDialogModel.ButtonModel(
text = ParameterizedText(R.string.ok),
onClick = ::dismissAlert
)
_state.value = _state.value.copy(
alert = AlertDialogModel(
text = ParameterizedText(R.string.file_save_error),
confirmButton = AlertDialogModel.ButtonModel(
text = ParameterizedText(R.string.ok),
onClick = ::dismissAlert
)
)
}
)
false
}
}
@ -213,7 +187,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,35 +196,33 @@ 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(
text = ParameterizedText(R.string.prompt_save_changes),
confirmButton = AlertDialogModel.ButtonModel(
text = ParameterizedText(R.string.yes),
onClick = {
_state.value = _state.value.copy(
saveCallback = {
reset(untitledFileName, false)
}
)
}
),
dismissButton = AlertDialogModel.ButtonModel(
text = ParameterizedText(R.string.no),
onClick = {
reset(untitledFileName, true)
}
)
))
}
_state.value = _state.value.copy(alert = AlertDialogModel(
text = ParameterizedText(R.string.prompt_save_changes),
confirmButton = AlertDialogModel.ButtonModel(
text = ParameterizedText(R.string.yes),
onClick = {
_state.value = _state.value.copy(
saveCallback = {
reset(untitledFileName, false)
}
)
}
),
dismissButton = AlertDialogModel.ButtonModel(
text = ParameterizedText(R.string.no),
onClick = {
reset(untitledFileName, true)
}
)
))
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 +231,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 +274,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,10 +1,8 @@
package com.wbrawner.simplemarkdown.ui
import android.annotation.SuppressLint
import android.graphics.Color.TRANSPARENT
import android.view.ViewGroup
import android.webkit.WebView
import android.widget.FrameLayout
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -17,6 +15,7 @@ import androidx.compose.runtime.LaunchedEffect
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.toArgb
import androidx.compose.ui.viewinterop.AndroidView
import com.wbrawner.simplemarkdown.BuildConfig
@ -92,34 +91,22 @@ fun HtmlText(html: String, modifier: Modifier = Modifier) {
AndroidView(
modifier = modifier,
factory = { context ->
FrameLayout(context).apply {
WebView(context).apply {
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
addView(
WebView(context).apply {
tag = WEBVIEW_TAG
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
setBackgroundColor(TRANSPARENT)
isNestedScrollingEnabled = false
settings.javaScriptEnabled = true
loadDataWithBaseURL(null, style + html, "text/html", "UTF-8", null)
}
)
setBackgroundColor(Color.Transparent.toArgb())
isNestedScrollingEnabled = false
settings.javaScriptEnabled = true
loadDataWithBaseURL(null, style + html, "text/html", "UTF-8", null)
}
},
update = { frameLayout ->
frameLayout.findViewWithTag<WebView>(WEBVIEW_TAG)
.loadDataWithBaseURL(null, style + html, "text/html", "UTF-8", null)
update = { webView ->
webView.loadDataWithBaseURL(null, style + html, "text/html", "UTF-8", null)
}
)
}
private const val WEBVIEW_TAG = "com.wbrawner.simplemarkdown.MarkdownText#WebView"
private fun String.wrapTag(tag: String) = "<$tag>$this</$tag>"

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,37 +70,57 @@ fun MarkdownTextField(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
BasicTextField(
value = markdown,
modifier = modifier.imePadding(),
onValueChange = setMarkdown,
enabled = true,
readOnly = false,
textStyle = textStyle,
cursorBrush = SolidColor(colors.cursorColor),
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
keyboardActions = KeyboardActions.Default,
interactionSource = interactionSource,
singleLine = false,
maxLines = Int.MAX_VALUE,
minLines = 1,
decorationBox = @Composable { innerTextField ->
// places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox(
value = markdown.text,
visualTransformation = VisualTransformation.None,
innerTextField = innerTextField,
placeholder = {
Text(stringResource(R.string.markdown_here))
},
singleLine = false,
enabled = true,
interactionSource = interactionSource,
colors = colors,
contentPadding = PaddingValues(8.dp)
)
},
)
Column(
modifier = modifier
.fillMaxSize()
.imePadding()
.verticalScroll(rememberScrollState())
) {
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
BasicTextField(
value = textFieldValue,
modifier = Modifier.fillMaxSize(),
onValueChange = setTextFieldAndViewModelValues,
enabled = true,
readOnly = false,
textStyle = textStyle,
cursorBrush = SolidColor(colors.cursorColor),
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
keyboardActions = KeyboardActions.Default,
interactionSource = interactionSource,
singleLine = false,
maxLines = Int.MAX_VALUE,
minLines = 1,
decorationBox = @Composable { innerTextField ->
// places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox(
value = textFieldValue.text,
visualTransformation = VisualTransformation.None,
innerTextField = innerTextField,
placeholder = {
Text(stringResource(R.string.markdown_here))
},
singleLine = false,
enabled = true,
interactionSource = interactionSource,
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
@ -22,11 +22,11 @@ suspend fun Uri.getName(context: Context): String {
if ("content" == scheme) {
withContext(Dispatchers.IO) {
context.contentResolver.query(
this@getName,
null,
null,
null,
null
this@getName,
null,
null,
null,
null
)?.use {
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
it.moveToFirst()
@ -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

@ -3,6 +3,7 @@ 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
@ -33,10 +34,8 @@ interface FileHelper {
}
class AndroidFileHelper(private val context: Context) : FileHelper {
override val defaultDirectory: File by lazy {
context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
?: context.filesDir
}
override val defaultDirectory: File = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
?: context.filesDir
override suspend fun open(source: URI): Pair<String, String>? = withContext(Dispatchers.IO) {
val uri = source.toString().toUri()

View file

@ -19,17 +19,10 @@ interface PreferenceHelper {
}
class AndroidPreferenceHelper(context: Context, private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO)): PreferenceHelper {
private val sharedPreferences by lazy {
PreferenceManager.getDefaultSharedPreferences(context)
}
private val states by lazy {
val allPrefs: Map<String, Any?> = sharedPreferences.all
Preference.entries.associateWith { preference ->
MutableStateFlow(allPrefs[preference.key] ?: preference.default)
}
}
private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
private val states = Preference.entries.associateWith { MutableStateFlow(get(it)) }
override fun get(preference: Preference): Any? = states[preference]?.value
override fun get(preference: Preference): Any? = sharedPreferences.all[preference.key]?: preference.default
override fun set(preference: Preference, value: Any?) {
sharedPreferences.edit {
@ -47,7 +40,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,2 @@
- Add support for themed icon
- Fix crashes

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

@ -1,51 +0,0 @@
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"
abstract class ChangelogTask @Inject constructor(
objectFactory: ObjectFactory,
providers: ProviderFactory,
) : 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()
logger.info("Latest tag: $latestTag")
logger.info("Changelog: ${changelog.joinToString("\n")}")
changelogFile.get().asFile.writer().use { writer ->
writer.write(
changelog.joinToString("\n") { it.trim('"') }
)
}
}
private fun String.execute(): List<String> = ProcessBuilder()
.command(this.split(" "))
.redirectOutput(ProcessBuilder.Redirect.PIPE)
.start()
.inputReader()
.readLines()
}

View file

@ -2,15 +2,46 @@ package com.wbrawner.releasehelper
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.tasks.Exec
import org.gradle.kotlin.dsl.extra
import org.gradle.kotlin.dsl.provideDelegate
import org.gradle.kotlin.dsl.register
import java.io.ByteArrayOutputStream
import java.io.File
class ReleaseHelperPlugin : Plugin<Project> {
override fun apply(target: Project) {
target.tasks.register<ChangelogTask>("changelog")
target.tasks.register("getLatestTag", Exec::class.java) {
val latestTag = ByteArrayOutputStream()
standardOutput = latestTag
commandLine("git describe --tags --abbrev=0".split(" "))
doLast {
target.project.extra["latestTag"] = latestTag.toString().trim()
logger.info("Latest tag: ${target.project.extra["latestTag"]}")
}
}
target.tasks.register("changelog") {
val changelogFile = File(target.projectDir, "src/play/play/release-notes/en-US/default.txt")
inputs.property("tag", target.provider {
target.project.extra["latestTag"]
})
outputs.file(changelogFile)
dependsOn("getLatestTag")
doLast {
val latestTag: String by target.project.extra
val changelog = ByteArrayOutputStream()
target.exec {
standardOutput = changelog
commandLine = "git log --format=\"%B\" ${latestTag.trim()}..".split(" ")
}
changelogFile.writeText(
changelog.toString()
.split("\n")
.mapNotNull { it.trim('"').ifBlank { null } }
.joinToString("\n") { "- $it" }
)
}
}
target.tasks.register("majorRelease") {
dependsOn("changelog", "getLatestTag")

View file

@ -41,24 +41,19 @@ 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
}
}
dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.acra.core)
implementation(libs.acra.http)
runtimeOnly(libs.acra.limiter)

View file

@ -2,8 +2,7 @@ package com.wbrawner.simplemarkdown.core
import android.app.Application
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.acra.ACRA
import org.acra.config.httpSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
@ -14,32 +13,22 @@ import timber.log.Timber
class ErrorReporterTree private constructor(): Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
if (priority != Log.ERROR) return
if (t !is LocalOnlyException) {
t?.sendSilentlyWithAcra()
}
t?.sendSilentlyWithAcra()
}
companion object {
suspend fun create(application: Application): ErrorReporterTree {
fun create(application: Application): ErrorReporterTree {
application.createErrorReporterTree()
return ErrorReporterTree()
}
}
}
/**
* An exception wrapper that prevents exceptions from being sent to an error reporter. Useful for
* logging things like IOExceptions that are useful to see locally but not so helpful if reported
*/
class LocalOnlyException(override val message: String?, override val cause: Throwable): Exception(message, cause) {
constructor(cause: Throwable): this(null, cause)
}
private suspend fun Application.createErrorReporterTree() = withContext(Dispatchers.IO) {
private fun Application.createErrorReporterTree() {
initAcra {
reportFormat = StringFormat.JSON
httpSender {
uri = "${BuildConfig.ACRA_URL}/report"
uri = "${BuildConfig.ACRA_URL}/report" /*best guess, you may need to adjust this*/
basicAuthLogin = BuildConfig.ACRA_USER
basicAuthPassword = BuildConfig.ACRA_PASS
httpMethod = HttpSender.Method.POST

View file

@ -10,7 +10,7 @@ android {
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
consumerProguardFiles("consumer-rules.pro")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@ -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,2 +0,0 @@
-dontwarn javax.annotation.processing.AbstractProcessor
-dontwarn javax.annotation.processing.SupportedOptions

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.06.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.10"
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"
monitor = "1.7.1"
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"
runner = "1.6.2"
robolectric = "4.13"
runner = "1.6.1"
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
}
}