Compare commits

...

6 commits

Author SHA1 Message Date
01978548c6
Add LocalOnlyException
Some checks are pending
Build & Test / Validate (push) Waiting to run
Build & Test / Run Unit Tests (push) Blocked by required conditions
Build & Test / Run UI Tests (push) Blocked by required conditions
Build & Test / Validate (pull_request) Successful in 24s
Build & Test / Run Unit Tests (pull_request) Successful in 9m48s
Build & Test / Run UI Tests (pull_request) Successful in 9m48s
This is intended to enable local logging of some exceptions for debugging purposes without overrunning the remote crash reporter with issues that can't really be actioned upon
2024-08-22 21:01:53 -06:00
b42b949bdb Fix crash on markdown preview
Some checks are pending
Build & Test / Validate (push) Waiting to run
Build & Test / Run Unit Tests (push) Blocked by required conditions
Build & Test / Run UI Tests (push) Blocked by required conditions
2024-08-23 03:00:09 +00:00
040777b99a Rewrite UI tests to use robots 2024-08-23 03:00:09 +00:00
307e7642b9 Fix StrictMode disk access violations
Some checks are pending
Build & Test / Validate (push) Waiting to run
Build & Test / Run Unit Tests (push) Blocked by required conditions
Build & Test / Run UI Tests (push) Blocked by required conditions
2024-08-23 02:59:14 +00:00
617035c424
fixup! Switch to ARM emulator for FTL and bump version for tests
Some checks failed
Build & Test / Validate (pull_request) Successful in 17s
Build & Test / Run Unit Tests (pull_request) Successful in 9m12s
Build & Test / Run UI Tests (pull_request) Failing after 8m2s
Build & Test / Validate (push) Waiting to run
Build & Test / Run Unit Tests (push) Blocked by required conditions
Build & Test / Run UI Tests (push) Blocked by required conditions
2024-08-22 20:38:42 -06:00
9ce5e129a6
Switch to ARM emulator for FTL and bump version for tests
Some checks failed
Build & Test / Validate (pull_request) Successful in 21s
Build & Test / Run Unit Tests (pull_request) Successful in 8m59s
Build & Test / Run UI Tests (pull_request) Failing after 8m15s
2024-08-22 13:44:37 -06:00
16 changed files with 369 additions and 204 deletions

View file

@ -181,7 +181,7 @@ fladle {
environmentVariables.put("clearPackageData", "true")
testTimeout.set("7m")
devices.add(
mapOf("model" to "NexusLowRes", "version" to "29")
mapOf("model" to "Pixel2.arm", "version" to "33")
)
projectId.set("simplemarkdown")
}

View file

@ -0,0 +1,27 @@
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,27 +5,7 @@ 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
@ -34,14 +14,9 @@ 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
@ -79,13 +54,13 @@ class MarkdownTests {
@Test
fun editAndPreviewMarkdownTest() {
ActivityScenario.launch(MainActivity::class.java)
composeRule.typeMarkdown("# Header test")
composeRule.checkMarkdownEquals("# Header test")
composeRule.openPreview()
onWebView(isAssignableFrom(WebView::class.java))
.forceJavascriptEnabled()
.withElement(findElement(Locator.TAG_NAME, "h1"))
.check(webMatches(getText(), containsString("Header test")))
onMainScreen(composeRule) {
typeMarkdown("# Header test")
checkMarkdownEquals("# Header test")
openPreview()
} onPreview {
verifyH1("Header test")
}
}
@Test
@ -98,102 +73,113 @@ class MarkdownTests {
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
ActivityScenario.launch(MainActivity::class.java)
composeRule.openMenu()
composeRule.clickOpenMenuItem()
composeRule.checkMarkdownEquals(markdownText)
composeRule.openMenu()
composeRule.clickNewMenuItem()
composeRule.verifyDialogIsNotShown()
composeRule.checkMarkdownEquals("")
onMainScreen(composeRule) {
openMenu()
clickOpenMenuItem()
checkMarkdownEquals(markdownText)
openMenu()
clickNewMenuItem()
verifyDialogIsNotShown()
checkMarkdownEquals("")
}
}
@Test
fun editThenNewMarkdownTest() {
ActivityScenario.launch(MainActivity::class.java)
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
composeRule.typeMarkdown(markdownText)
composeRule.openMenu()
composeRule.clickNewMenuItem()
composeRule.onNode(isDialog()).printToLog("TestDebugging")
composeRule.verifyDialogIsShown("Would you like to save your changes?")
composeRule.discardChanges()
composeRule.checkMarkdownEquals("")
onMainScreen(composeRule) {
typeMarkdown(markdownText)
openMenu()
clickNewMenuItem()
verifyDialogIsShown("Would you like to save your changes?")
discardChanges()
checkMarkdownEquals("")
}
}
@Test
fun saveMarkdownWithFileUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
composeRule.typeMarkdown(markdownText)
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
})
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
composeRule.openMenu()
composeRule.clickSaveMenuItem()
composeRule.awaitIdle()
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
composeRule.checkTitleEquals("temp.md")
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")
}
}
@Test
fun saveMarkdownWithContentUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
composeRule.typeMarkdown(markdownText)
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = FileProvider.getUriForFile(
getApplicationContext(),
"${BuildConfig.APPLICATION_ID}.fileprovider",
file
)
})
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
composeRule.openMenu()
composeRule.clickSaveMenuItem()
composeRule.awaitIdle()
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
composeRule.checkTitleEquals("temp.md")
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")
}
}
@Test
fun loadMarkdownWithFileUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
composeRule.openMenu()
composeRule.clickOpenMenuItem()
composeRule.awaitIdle()
composeRule.checkMarkdownEquals(markdownText)
composeRule.checkTitleEquals("temp.md")
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")
}
}
@Test
fun loadMarkdownWithContentUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = FileProvider.getUriForFile(
getApplicationContext(),
"${BuildConfig.APPLICATION_ID}.fileprovider",
file
)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
composeRule.openMenu()
composeRule.clickOpenMenuItem()
composeRule.awaitIdle()
composeRule.checkMarkdownEquals(markdownText)
composeRule.checkTitleEquals("temp.md")
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")
}
}
@ -206,84 +192,22 @@ class MarkdownTests {
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md")
composeRule.openMenu()
composeRule.clickOpenMenuItem()
composeRule.awaitIdle()
composeRule.verifyTextIsShown("Successfully loaded temp.md")
composeRule.checkMarkdownEquals(markdownText)
composeRule.checkTitleEquals("temp.md")
val additionalText = "# More info\n\nThis is some additional text"
composeRule.typeMarkdown(additionalText)
composeRule.openMenu()
composeRule.clickSaveMenuItem()
composeRule.awaitIdle()
composeRule.verifyTextIsShown("Successfully saved temp.md")
assertEquals(additionalText, file.inputStream().reader().use(Reader::readText))
composeRule.checkTitleEquals("temp.md")
}
private fun ComposeTestRule.checkTitleEquals(title: String) =
onNode(hasAnySibling(hasContentDescription("Main Menu")).and(hasText(title)))
.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
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")
}
onNode(hasSetTextAction()).waitUntil {
assert(markdownMatcher)
}
}
private fun ComposeTestRule.openPreview() = onNodeWithText("Preview").performClick()
private fun ComposeTestRule.openMenu() =
onNodeWithContentDescription("Editor Actions").performClick()
private fun ComposeTestRule.clickOpenMenuItem() = onNodeWithText("Open").performClick()
private fun ComposeTestRule.clickNewMenuItem() = onNodeWithText("New").performClick()
private fun ComposeTestRule.clickSaveMenuItem() = onNodeWithText("Save").performClick()
private fun ComposeTestRule.verifyDialogIsShown(text: String) =
onNode(isDialog().and(hasAnyDescendant(hasText(text)))).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

@ -0,0 +1,29 @@
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

@ -0,0 +1,78 @@
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
) {
block.invoke(MainScreenRobot(composeRule))
}
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

@ -0,0 +1,7 @@
package com.wbrawner.simplemarkdown.robot
import androidx.compose.ui.test.junit4.ComposeTestRule
class MarkdownInfoScreenRobot(private val composeTestRule: ComposeTestRule) :
TopAppBarRobot by ComposeTopAppBarRobot(composeTestRule),
WebViewRobot by EspressoWebViewRobot()

View file

@ -0,0 +1,16 @@
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

@ -0,0 +1,24 @@
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

@ -0,0 +1,24 @@
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

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

View file

@ -5,6 +5,7 @@ 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.utility.FileHelper
import com.wbrawner.simplemarkdown.utility.Preference
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
@ -109,7 +110,7 @@ class MarkdownViewModel(
preferenceHelper[Preference.AUTOSAVE_URI] = actualLoadPath
} ?: throw IllegalStateException("Opened file was null")
} catch (e: Exception) {
Timber.e(e, "Failed to open file at path: $actualLoadPath")
Timber.e(LocalOnlyException(e), "Failed to open file at path: $actualLoadPath")
_state.value = _state.value.copy(
alert = AlertDialogModel(
text = ParameterizedText(R.string.file_load_error),

View file

@ -1,8 +1,10 @@
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
@ -15,7 +17,6 @@ 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
@ -91,22 +92,34 @@ fun HtmlText(html: String, modifier: Modifier = Modifier) {
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
FrameLayout(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
setBackgroundColor(Color.Transparent.toArgb())
isNestedScrollingEnabled = false
settings.javaScriptEnabled = true
loadDataWithBaseURL(null, style + html, "text/html", "UTF-8", null)
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)
}
)
}
},
update = { webView ->
webView.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)
}
)
}
private const val WEBVIEW_TAG = "com.wbrawner.simplemarkdown.MarkdownText#WebView"
private fun String.wrapTag(tag: String) = "<$tag>$this</$tag>"

View file

@ -3,7 +3,6 @@ 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
@ -34,8 +33,10 @@ interface FileHelper {
}
class AndroidFileHelper(private val context: Context) : FileHelper {
override val defaultDirectory: File = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
?: context.filesDir
override val defaultDirectory: File by lazy {
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,10 +19,17 @@ interface PreferenceHelper {
}
class AndroidPreferenceHelper(context: Context, private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO)): PreferenceHelper {
private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
private val states = Preference.entries.associateWith { MutableStateFlow(get(it)) }
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)
}
}
override fun get(preference: Preference): Any? = sharedPreferences.all[preference.key]?: preference.default
override fun get(preference: Preference): Any? = states[preference]?.value
override fun set(preference: Preference, value: Any?) {
sharedPreferences.edit {

View file

@ -54,6 +54,7 @@ android {
}
dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.acra.core)
implementation(libs.acra.http)
runtimeOnly(libs.acra.limiter)

View file

@ -2,7 +2,8 @@ package com.wbrawner.simplemarkdown.core
import android.app.Application
import android.util.Log
import org.acra.ACRA
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.acra.config.httpSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
@ -13,22 +14,32 @@ 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
t?.sendSilentlyWithAcra()
if (t !is LocalOnlyException) {
t?.sendSilentlyWithAcra()
}
}
companion object {
fun create(application: Application): ErrorReporterTree {
suspend fun create(application: Application): ErrorReporterTree {
application.createErrorReporterTree()
return ErrorReporterTree()
}
}
}
private fun Application.createErrorReporterTree() {
/**
* 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) {
initAcra {
reportFormat = StringFormat.JSON
httpSender {
uri = "${BuildConfig.ACRA_URL}/report" /*best guess, you may need to adjust this*/
uri = "${BuildConfig.ACRA_URL}/report"
basicAuthLogin = BuildConfig.ACRA_USER
basicAuthPassword = BuildConfig.ACRA_PASS
httpMethod = HttpSender.Method.POST