Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,30 @@ Both are needed: the first keeps `isRenderedByCore` off a `.bin`, the second sto
bar appearing over a page that cannot draw.
`LandingTests.aDocumentThatFailsToOpenComesBackToTheList` holds this.

### How the document is displayed is answered over the document, not in the settings

Three of the buttons in `DocumentActions` are about what the page looks like rather than what can
be done to it, and each remembers what it was last told:

- **Night mode** is the app's, through `AppCompatDelegate.setLocalNightMode` rather than the
default one, so a phone that stays light all day can still be read at night. `NightModeSetting`
stores no override at all once the choice agrees with the system again, or the app would sit in
night mode through a morning the phone had long left.
- **Darkening** defaults to `capabilitiesByFileType(...).colorScheme` - whether the format has a
dark of its own - and is overridden per kind of document, not per file. `CoreLoader` translates
every page with `HtmlColorScheme.SYSTEM` so both schemes ride behind `prefers-color-scheme`, and
`PageView.setDarkeningAllowed` picks between them at display time, which is why the button
renders nothing again. Do not put a list of formats back: it was a guess that presentations and
images invert badly, and the core answers both.
- **The margins** are odrcore's `textDocumentMargin`, decided while translating, so the button
renders the document again through `DocumentLoader.reload` - the copy in the cache, not the file.
`PaginationSetting.affects` gates it on a *text* document: everything else would be translated
again to look the same. `DocumentFragment` carries the tab and how far down it the reader was
over to the document that comes back - as a fraction, the margins having changed the height.

Do not move these into a settings screen. `PaginationSetting` keeps its landing row because it
already had one and both write the same preference; the other two never get one.

### Editability comes from the core, never from a mime type

`Document.isEditable()`/`isSavable()` decides whether `DocumentFragment` offers the Edit
Expand Down
193 changes: 186 additions & 7 deletions app/src/androidTest/java/app/opendocument/droid/test/DarkModeTests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

package app.opendocument.droid.test

import android.content.Context
import android.content.res.Configuration
import android.content.res.Resources
import android.net.Uri
import android.os.SystemClock
import androidx.appcompat.app.AppCompatDelegate
Expand All @@ -11,10 +14,16 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry
import androidx.test.runner.lifecycle.Stage
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import app.opendocument.droid.background.DocumentDarkening
import app.opendocument.droid.background.NightModeSetting
import app.opendocument.droid.ui.activity.DocumentFragment
import app.opendocument.droid.ui.activity.MainActivity
import app.opendocument.droid.ui.widget.DocumentActions
import app.opendocument.droid.ui.widget.PageView
import java.io.File
import java.io.FileOutputStream
Expand All @@ -29,10 +38,12 @@ import org.junit.Test
import org.junit.runner.RunWith

/**
* The document follows the app into night mode.
* The document follows the app into night mode where it reads better for it, and the switches over
* it are what say otherwise.
*
* A webview darkens a page algorithmically and only while the app theme reports itself dark, so
* every test here puts the app in night mode first - in day mode nothing below would fail.
* [theSwitchDarkensADayModeApp] is the exception, and undoes it.
*/
@LargeTest
@RunWith(AndroidJUnit4::class)
Expand All @@ -49,24 +60,61 @@ class DarkModeTests {
@After
fun leaveNightMode() {
setNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)

// both switches keep their answer on disk, where the next test would find it
NightModeSetting.setNight(targetContext(), systemIsNight())

for (kind in DocumentDarkening.Kind.entries) {
DocumentDarkening.clear(targetContext(), kind)
}

// switching night mode recreates the activity, leaving one behind the rule knows nothing
// of - and it would still be up when the next test launches its own
val resumed = resumedMainActivity()
if (resumed != null && resumed !== mainActivityActivityTestRule.activity) {
onMainThread { resumed.finish() }
}
}

@Test
fun aDocumentIsDarkened() {
assertDarkened(openPageView("test.odt"))
}

/** Every format, pdf included - see [PageView.setDarkeningAllowed]. */
/**
* A pdf does not: a scanned page inverts into something nobody wrote. See [DocumentDarkening].
*/
@Test
fun aPdfIsNotDarkened() {
val pageView = openPageView("dummy.pdf")

Assert.assertFalse("the pdf was allowed to darken", pageView.isDarkeningAllowed)
assertNotDarkened(pageView)
}

/** What the button says is kept for every document of that kind, not for the file it was on. */
@Test
fun aPdfIsDarkenedToo() {
assertDarkened(openPageView("dummy.pdf"))
fun theDocumentSwitchIsRememberedForTheKind() {
// after openPageView, which is what launches it
val pageView = openPageView("dummy.pdf")
val activity = mainActivityActivityTestRule.activity

Assert.assertFalse("the pdf started out darkened", pageView.isDarkeningAllowed)

onMainThread { activity.onDocumentAction(DocumentActions.ACTION_DOCUMENT_DARKENING) }

// no reload: darkening is a webview setting, not something the page was translated with
assertDarkened(pageView)

assertDarkened(reopenPageView(activity, "dummy.pdf"))
}

/** What is drawn, not only the flag: a webview ignoring the setting passes the flag check. */
@Test
fun theDrawnPageIsDark() {
Assume.assumeTrue(
"this webview has no darkening api at all - nothing the app sets could reach it",
"this webview cannot darken a page - nothing the app sets could reach it. " +
"${darkeningDiagnosis()}",
canDarken(),
)

Expand All @@ -79,7 +127,53 @@ class DarkModeTests {
var luminance = WHITE
val darkened = waitFor(60000) { meanLuminance().also { luminance = it } < DARK_LUMINANCE }

Assert.assertTrue("the page stayed light - mean luminance $luminance", darkened)
Assert.assertTrue(
"the page stayed light - mean luminance $luminance; ${darkeningDiagnosis()}",
darkened,
)
}

/**
* The switch over the document, for a phone that stays in day mode all night - the only test
* here that starts in day mode, since that is what it switches out of.
*/
@Test
fun theSwitchDarkensADayModeApp() {
Assume.assumeTrue(
"this webview cannot darken a page - nothing the app sets could reach it. " +
"${darkeningDiagnosis()}",
canDarken(),
)
Assume.assumeFalse(
"the device itself is in night mode - there is no day mode to switch out of",
systemIsNight(),
)

// the switch is the only thing that should be putting this app in night mode
setNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)

openPageView("test.odt")

onMainThread {
mainActivityActivityTestRule.activity.onDocumentAction(
DocumentActions.ACTION_NIGHT_MODE
)
}

// 60s and the last reading kept, for the reason theDrawnPageIsDark gives - and an
// activity recreation happens before it
var luminance = WHITE
val darkened = waitFor(60000) { meanLuminance().also { luminance = it } < DARK_LUMINANCE }

Assert.assertTrue(
"the page stayed light - mean luminance $luminance; ${darkeningDiagnosis()}",
darkened,
)
Assert.assertEquals(
"the switch was not remembered",
AppCompatDelegate.MODE_NIGHT_YES,
NightModeSetting.mode(targetContext()),
)
}

/** Printing holds the page light, and only the last job still reading it gives it back. */
Expand Down Expand Up @@ -135,9 +229,50 @@ class DarkModeTests {
return darkening.get()
}

/**
* What the webview is and what it was given, for a failure message: these fail on one api level
* at a time, and "the page stayed light" does not say which darkening api was even in play.
*/
private fun darkeningDiagnosis(): String {
val algorithmic = WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)
val force = WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)

val webView =
try {
WebViewCompat.getCurrentWebViewPackage(targetContext())?.versionName ?: "none"
} catch (t: Throwable) {
"unknown (${t.javaClass.simpleName})"
}

val pageView = resumedMainActivity()?.let { waitForFragment(it)?.pageView }

return "webview $webView, algorithmicDarkening=$algorithmic, forceDark=$force, " +
"night=${NightModeSetting.isNight(targetContext())}, " +
"allowed=${pageView?.isDarkeningAllowed}, setting=${pageView?.let(::darkeningSetting)}"
}

/**
* Whether this webview can darken a page at all, which is not the same as its saying it can.
*
* The api 29 image ships webview 74, which reports `FORCE_DARK` supported, hands the setting
* straight back and draws the page as light as it was - force dark only arrived in 76. What the
* app does is still asserted through [darkeningSetting]; only the two tests that read pixels
* skip. An unreadable version counts as capable: a skip taken by mistake is coverage lost.
*/
private fun canDarken() =
WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING) ||
WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)
(WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK) &&
webViewMajorVersion() >= FORCE_DARK_MIN_WEBVIEW)

private fun webViewMajorVersion(): Int =
try {
WebViewCompat.getCurrentWebViewPackage(targetContext())
?.versionName
?.substringBefore('.')
?.toIntOrNull() ?: Int.MAX_VALUE
} catch (t: Throwable) {
Int.MAX_VALUE
}

/**
* What the middle of the screen draws, averaged - 0 is black and 255 white.
Expand Down Expand Up @@ -189,6 +324,24 @@ class DarkModeTests {
return checkNotNull(fragment.pageView) { "no page view" }
}

/** The same file again in the activity already up: a page view that was told nothing yet. */
private fun reopenPageView(activity: MainActivity, name: String): PageView {
val fragment = checkNotNull(waitForFragment(activity)) { "no document fragment" }
val before = fragment.lastDocument

val uri = uriOf(extract(name))
onMainThread { activity.loadUri(uri) }

// not the uri, which is the one it already had: what says this load landed is a document
// that is not the one from the load before
Assert.assertTrue(
"$name never loaded again",
waitFor(30000) { fragment.lastDocument != null && fragment.lastDocument !== before },
)

return checkNotNull(fragment.pageView) { "no page view" }
}

private fun waitForFragment(activity: MainActivity): DocumentFragment? {
var fragment: DocumentFragment? = null
waitFor(30000) {
Expand Down Expand Up @@ -220,6 +373,29 @@ class DarkModeTests {
onMainThread { AppCompatDelegate.setDefaultNightMode(mode) }
}

/** The device's own answer, which an activity carrying a local mode no longer gives. */
private fun systemIsNight(): Boolean =
Resources.getSystem().configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES

private fun targetContext(): Context =
InstrumentationRegistry.getInstrumentation().targetContext

/** Whatever is on screen, which after a night mode switch is not what the rule launched. */
private fun resumedMainActivity(): MainActivity? {
val current = AtomicReference<MainActivity>()
onMainThread {
for (candidate in
ActivityLifecycleMonitorRegistry.getInstance()
.getActivitiesInStage(Stage.RESUMED)) {
if (candidate is MainActivity) {
current.set(candidate)
}
}
}
return current.get()
}

private fun uriOf(file: File): Uri {
val appCtx = InstrumentationRegistry.getInstrumentation().targetContext

Expand All @@ -240,6 +416,9 @@ class DarkModeTests {

private companion object {
/** Below this the page is dark rather than the white a document is authored on. */
/** Force dark landed in this one; 74, which api 29 ships, takes the setting and lies. */
private const val FORCE_DARK_MIN_WEBVIEW = 76

private const val DARK_LUMINANCE = 128

private const val WHITE = 255
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ class LargeTextTests {
val elements = awaitStableDom(pageView)
Assert.assertTrue("the page never laid out", elements > 1)

// and then the text itself. A megabyte is parsed in bursts, so the element count can stop
// changing inside a pause rather than at the end, and the search would run against a page
// still filling
var needles = -1
Assert.assertTrue(
"the text never finished arriving - $NEEDLE is in the page $needles times, not $lines",
waitFor(TIMEOUT_MS) { needlesInDom(pageView).also { needles = it } == lines },
)

val matches = findAll(pageView, NEEDLE)
val elapsed = SystemClock.elapsedRealtime() - start

Expand Down Expand Up @@ -90,6 +99,14 @@ class LargeTextTests {
return previous
}

/** Every line carries the needle once, so the page is all there when they all are. */
private fun needlesInDom(pageView: PageView): Int =
evaluateJavascript(
pageView,
"(document.body.textContent.match(/$NEEDLE/g) || []).length",
)
?.toIntOrNull() ?: -1

private fun elementCount(pageView: PageView): Int =
evaluateJavascript(pageView, "document.getElementsByTagName('*').length")?.toIntOrNull()
?: -1
Expand Down
Loading
Loading