diff --git a/CLAUDE.md b/CLAUDE.md index dea03d87a36c..4c67254be4b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/app/src/androidTest/java/app/opendocument/droid/test/DarkModeTests.kt b/app/src/androidTest/java/app/opendocument/droid/test/DarkModeTests.kt index d67d9dc83f48..cbbf557d7621 100644 --- a/app/src/androidTest/java/app/opendocument/droid/test/DarkModeTests.kt +++ b/app/src/androidTest/java/app/opendocument/droid/test/DarkModeTests.kt @@ -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 @@ -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 @@ -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) @@ -49,6 +60,20 @@ 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 @@ -56,17 +81,40 @@ class DarkModeTests { 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(), ) @@ -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. */ @@ -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. @@ -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) { @@ -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() + 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 @@ -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 diff --git a/app/src/androidTest/java/app/opendocument/droid/test/LargeTextTests.kt b/app/src/androidTest/java/app/opendocument/droid/test/LargeTextTests.kt index 0b1aa915d61a..fdcda7149c9d 100644 --- a/app/src/androidTest/java/app/opendocument/droid/test/LargeTextTests.kt +++ b/app/src/androidTest/java/app/opendocument/droid/test/LargeTextTests.kt @@ -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 @@ -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 diff --git a/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt b/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt index df28313d5322..69c4e8abeaf2 100644 --- a/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt +++ b/app/src/androidTest/java/app/opendocument/droid/test/MainActivityTests.kt @@ -33,6 +33,7 @@ import androidx.test.rule.ActivityTestRule import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry import androidx.test.runner.lifecycle.Stage import app.opendocument.droid.R +import app.opendocument.droid.background.PaginationSetting import app.opendocument.droid.ui.EditActionModeCallback import app.opendocument.droid.ui.OpenFileIdling import app.opendocument.droid.ui.activity.DocumentFragment @@ -47,7 +48,9 @@ import java.net.HttpURLConnection import java.net.URL import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference +import kotlin.math.abs import org.hamcrest.Matchers.equalTo import org.junit.After import org.junit.AfterClass @@ -285,6 +288,120 @@ class MainActivityTests { Intents.intended(hasAction(Intent.ACTION_CREATE_DOCUMENT), times(1)) } + /** + * The margin switch renders the open document a second time: the margin is odrcore's, decided + * while translating, so flipping the setting alone would leave the page as it was. + */ + @Test + fun theMarginSwitchRendersTheDocumentAgain() { + val activity = mainActivityActivityTestRule.activity + val documentFragment = loadDocument(activity, requireTestFile("test.odt")) + + val before = documentFragment.lastDocument + Assert.assertNotNull(before) + + val margins = PaginationSetting.isEnabled(activity) + try { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + activity.onDocumentAction(DocumentActions.ACTION_PAGE_MARGINS) + } + + Assert.assertEquals( + "the setting did not flip", + !margins, + PaginationSetting.isEnabled(activity), + ) + Assert.assertTrue( + "the document was never rendered again", + waitFor(RELOAD_TIMEOUT_MS) { documentFragment.lastDocument !== before }, + ) + } finally { + // it outlives the test otherwise: it is a preference, not activity state + PaginationSetting.setEnabled(activity, margins) + } + } + + /** + * The button is only there where tapping it changes something: odrcore lays a text document out + * with the margins or without them, and every other view the same either way. Launches + * nothing - it is the rule the button is gated on, and the core answers it. + */ + @Test + fun theMarginSwitchIsOnlyOfferedForTextDocuments() { + Assert.assertTrue( + "a text document is what the margins are for", + PaginationSetting.affects("application/vnd.oasis.opendocument.text"), + ) + + for (mimeType in + listOf( + "application/pdf", + "image/png", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.presentation", + "text/plain", + )) { + Assert.assertFalse( + "$mimeType would have been rendered again to look exactly the same", + PaginationSetting.affects(mimeType), + ) + } + + Assert.assertFalse("nothing is not a text document", PaginationSetting.affects(null)) + } + + /** + * The margins render the document again, and a reader halfway down it should still be halfway + * down it afterwards - the reload used to hand back the top of the page. + * + * A fraction on both sides, the margins being exactly what changes the height. Polled rather + * than asserted straight after the reload, the way the edit mode tests poll. + */ + @Test + fun theMarginSwitchKeepsTheReadingPosition() { + val activity = mainActivityActivityTestRule.activity + val documentFragment = loadDocument(activity, requireTestFile("style-various-1.docx")) + val pageView = checkNotNull(documentFragment.pageView) { "no page view" } + + Assert.assertTrue( + "the document never became long enough to scroll. dom=${describeDom(pageView)}", + waitFor(EDIT_MODE_TIMEOUT_MS) { scrollableHeight(pageView) > 0 }, + ) + + // a long way down, but not the end: the last screenful is one position however far the page + // is scrolled past it, so it would pass with nothing restored at all + val before = scrollToFraction(pageView, READING_POSITION) + Assert.assertTrue( + "the page did not scroll - it read back at $before", + before > READING_POSITION / 2, + ) + + val document = documentFragment.lastDocument + val margins = PaginationSetting.isEnabled(activity) + try { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + activity.onDocumentAction(DocumentActions.ACTION_PAGE_MARGINS) + } + + Assert.assertTrue( + "the document was never rendered again", + waitFor(RELOAD_TIMEOUT_MS) { documentFragment.lastDocument !== document }, + ) + + var restored = 0f + Assert.assertTrue( + "the reader was put back at ${restored} of the page, not around $before", + waitFor(EDIT_MODE_TIMEOUT_MS) { + restored = scrollFraction(pageView) + abs(restored - before) < POSITION_TOLERANCE + }, + ) + } finally { + // it outlives the test otherwise: it is a preference, not activity state + PaginationSetting.setEnabled(activity, margins) + } + } + @Test fun testDocumentSurvivesRecreation() { val activity = mainActivityActivityTestRule.activity @@ -493,6 +610,36 @@ class MainActivityTests { ) } + /** What there is to scroll, which is nothing at all until the page has laid out. */ + private fun scrollableHeight(pageView: PageView): Int { + val height = AtomicInteger(0) + InstrumentationRegistry.getInstrumentation().runOnMainSync { + height.set(pageView.verticalScrollableHeight) + } + return height.get() + } + + private fun scrollFraction(pageView: PageView): Float { + val fraction = AtomicReference(0f) + InstrumentationRegistry.getInstrumentation().runOnMainSync { + fraction.set(pageView.verticalScrollFraction) + } + return fraction.get() + } + + /** Scrolls there and answers where it actually landed. */ + private fun scrollToFraction(pageView: PageView, fraction: Float): Float { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + pageView.scrollTo( + pageView.scrollX, + (pageView.verticalScrollableHeight * fraction).toInt(), + ) + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + + return scrollFraction(pageView) + } + private fun describeDom(pageView: PageView): String = evaluateJavascript( pageView, @@ -597,6 +744,17 @@ class MainActivityTests { private const val WINDOW_FOCUS_TIMEOUT_MS = 10000L + // a document already in the cache, translated a second time + private const val RELOAD_TIMEOUT_MS = 10000L + + /** Well down the document, and well clear of the last screenful - see the test. */ + private const val READING_POSITION = 0.5f + + /** + * The page is laid out again, so the same place in the text is near, not at, the offset. + */ + private const val POSITION_TOLERANCE = 0.15f + private const val DIALOG_TIMEOUT_MS = 10000L private val testFiles = mutableMapOf() diff --git a/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt index b71b8db6f895..08f75922ccc0 100644 --- a/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt +++ b/app/src/main/java/app/opendocument/droid/background/CoreLoader.kt @@ -8,6 +8,7 @@ import app.opendocument.core.DecodedFile import app.opendocument.core.Document import app.opendocument.core.DocumentType import app.opendocument.core.Html +import app.opendocument.core.HtmlColorScheme import app.opendocument.core.HtmlConfig import app.opendocument.core.HtmlView import app.opendocument.core.HttpServer @@ -149,6 +150,11 @@ class CoreLoader(private val context: Context) { htmlConfig.textDocumentMargin = paging htmlConfig.editable = editable + // both schemes, each behind prefers-color-scheme, rather than the one it is being read in + // now: this is decided while translating, and darkening is turned on and off over the open + // document. PageView.setDarkeningAllowed picks between them + htmlConfig.colorScheme = HtmlColorScheme.SYSTEM + val cacheDirectory = File(cachePath) cacheDirectory.deleteRecursively() cacheDirectory.mkdirs() diff --git a/app/src/main/java/app/opendocument/droid/background/DocumentDarkening.kt b/app/src/main/java/app/opendocument/droid/background/DocumentDarkening.kt new file mode 100644 index 000000000000..de74fd8f1c63 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/background/DocumentDarkening.kt @@ -0,0 +1,68 @@ +package app.opendocument.droid.background + +import android.content.Context +import app.opendocument.core.FileCategory +import app.opendocument.core.FileType +import app.opendocument.core.Odr + +/** + * Whether a document follows the app into night mode, which is not one answer for everything the + * app opens: a text document reads dark, a scanned page inverted is something nobody wrote. + * + * The core answers it - see [darkensByDefault] - and the button over the document overrides that, + * for the [Kind] rather than the file: it is never *this* pdf that inverts badly, it is pdfs. + */ +object DocumentDarkening { + + /** What an override is remembered for, each named the way the button over the document says. */ + enum class Kind { + DOCUMENT, + PDF, + IMAGE, + } + + fun kindOf(mimeType: String?): Kind = kindOf(fileTypeOf(mimeType)) + + private fun kindOf(fileType: FileType?): Kind = + when { + fileType == null -> Kind.DOCUMENT + fileType == FileType.PORTABLE_DOCUMENT_FORMAT -> Kind.PDF + Odr.fileCategoryByFileType(fileType) == FileCategory.IMAGE -> Kind.IMAGE + else -> Kind.DOCUMENT + } + + private fun fileTypeOf(mimeType: String?): FileType? = + // not lowercased: the core's table is matched exactly, capitals included ("macroEnabled") + mimeType?.let { Odr.fileTypeByMimetype(it) } + + /** + * Whether the core renders this type dark itself, which is what darkening defaults to. + * + * Where it does not - a pdf, the media views - all the webview can do is invert what it was + * handed, so that is offered but not taken for granted. + */ + private fun darkensByDefault(fileType: FileType?): Boolean = + // nothing named it, so it is shown as text or as the html fallback, and both have a dark + fileType == null || Odr.capabilitiesByFileType(fileType).colorScheme + + /** Whether what [mimeType] names darkens, the button's answer first and the core's after. */ + fun isAllowed(context: Context, mimeType: String?): Boolean { + val fileType = fileTypeOf(mimeType) + + return AppPreferences.of(context) + .getBoolean(prefKey(kindOf(fileType)), darkensByDefault(fileType)) + } + + fun setAllowed(context: Context, kind: Kind, allowed: Boolean) { + AppPreferences.of(context).edit().putBoolean(prefKey(kind), allowed).apply() + } + + /** Forgets the override, which leaves the core answering for the kind again. */ + fun clear(context: Context, kind: Kind) { + AppPreferences.of(context).edit().remove(prefKey(kind)).apply() + } + + private fun prefKey(kind: Kind) = PREF_PREFIX + kind.name.lowercase() + + private const val PREF_PREFIX = "darken_" +} diff --git a/app/src/main/java/app/opendocument/droid/background/NightModeSetting.kt b/app/src/main/java/app/opendocument/droid/background/NightModeSetting.kt new file mode 100644 index 000000000000..354c577a0225 --- /dev/null +++ b/app/src/main/java/app/opendocument/droid/background/NightModeSetting.kt @@ -0,0 +1,66 @@ +package app.opendocument.droid.background + +import android.content.Context +import android.content.res.Configuration +import android.content.res.Resources +import androidx.appcompat.app.AppCompatDelegate + +/** + * Whether the app is in night mode when the system says otherwise, which is also the switch for + * reading at night on a phone that stays light all day: a webview darkens a page only while the app + * theme reports itself dark. + * + * Handed to [AppCompatDelegate.setLocalNightMode] rather than the default mode - `MainActivity` is + * the only screen there is, and a local mode leaves the default where anything else set it. + */ +object NightModeSetting { + + private const val PREF_NIGHT_MODE = "night_mode" + + /** + * The mode the activity's delegate is put in. + * + * [AppCompatDelegate.MODE_NIGHT_UNSPECIFIED] is no override at all, unlike + * MODE_NIGHT_FOLLOW_SYSTEM, which is one and would talk over a default mode set elsewhere. + */ + fun mode(context: Context): Int = + AppPreferences.of(context).getInt(PREF_NIGHT_MODE, AppCompatDelegate.MODE_NIGHT_UNSPECIFIED) + + /** + * Remembers whether the app should be dark, and answers the mode that puts it there. + * + * Stored as no override whenever it agrees with the system: one that does can never be got rid + * of again, and the app would sit in night mode through a morning the phone had long left. + */ + fun setNight(context: Context, night: Boolean): Int { + val mode = + when { + night == isNightWithoutOverride() -> AppCompatDelegate.MODE_NIGHT_UNSPECIFIED + night -> AppCompatDelegate.MODE_NIGHT_YES + else -> AppCompatDelegate.MODE_NIGHT_NO + } + + AppPreferences.of(context).edit().putInt(PREF_NIGHT_MODE, mode).apply() + + return mode + } + + /** What [context] is showing right now, the override included - so ask an activity. */ + fun isNight(context: Context): Boolean = isNight(context.resources) + + /** + * What the app would show without the override, which an activity carrying one cannot say. + * `Resources.getSystem()` is the device configuration alone, so a default mode set on top of it + * - the instrumented tests set one - is asked for separately. + */ + private fun isNightWithoutOverride(): Boolean = + when (AppCompatDelegate.getDefaultNightMode()) { + AppCompatDelegate.MODE_NIGHT_YES -> true + AppCompatDelegate.MODE_NIGHT_NO -> false + else -> isNight(Resources.getSystem()) + } + + private fun isNight(resources: Resources): Boolean = + resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == + Configuration.UI_MODE_NIGHT_YES +} diff --git a/app/src/main/java/app/opendocument/droid/background/PaginationSetting.kt b/app/src/main/java/app/opendocument/droid/background/PaginationSetting.kt index e3293612368b..422b00d4c792 100644 --- a/app/src/main/java/app/opendocument/droid/background/PaginationSetting.kt +++ b/app/src/main/java/app/opendocument/droid/background/PaginationSetting.kt @@ -1,6 +1,9 @@ package app.opendocument.droid.background import android.content.Context +import app.opendocument.core.DocumentType +import app.opendocument.core.FileType +import app.opendocument.core.Odr /** * Whether a document keeps the side margins of a printed page, or fills the screen. @@ -11,9 +14,9 @@ import android.content.Context * is the user's answer now: the margins are what the document was written to look like, the full * width is what reads on a phone. * - * Only [CoreLoader] reads it, and only while translating, so a change reaches a document the next - * time it is opened. That is enough: the switch is on the landing screen, which is only reached by - * closing whatever was open, and reopening it translates again. + * Only [CoreLoader] reads it, and only while translating, so a change reaches a document by + * rendering it again: opening one from the landing screen does that anyway, and the button over an + * open document asks `DocumentFragment.reloadForMargins` for it. */ object PaginationSetting { @@ -25,6 +28,26 @@ object PaginationSetting { fun isEnabled(context: Context): Boolean = AppPreferences.of(context).getBoolean(PREF_PAGINATION_ENABLED, DEFAULT_ENABLED) + /** + * Whether this reaches what [mimeType] names at all. + * + * odrcore lays a *text* document out with the margins or without them and nothing else, so + * anywhere else the button would render the document again to show nothing new - and quietly + * answer for the next text document opened. + */ + fun affects(mimeType: String?): Boolean { + // not lowercased, and for the same reason as DocumentDarkening.fileTypeOf + val fileType = mimeType?.let { Odr.fileTypeByMimetype(it) } ?: return false + + // pdf calls itself text too, but is fixed pages laid out by a frontend of its own that the + // margin never reaches + if (fileType == FileType.PORTABLE_DOCUMENT_FORMAT) { + return false + } + + return Odr.documentTypeByFileType(fileType) == DocumentType.TEXT + } + fun setEnabled(context: Context, enabled: Boolean) { AppPreferences.of(context).edit().putBoolean(PREF_PAGINATION_ENABLED, enabled).apply() } diff --git a/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt b/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt index c67b19a32369..94669c36ea4f 100644 --- a/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt +++ b/app/src/main/java/app/opendocument/droid/ui/activity/DocumentFragment.kt @@ -24,11 +24,14 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import app.opendocument.droid.R +import app.opendocument.droid.background.DocumentDarkening import app.opendocument.droid.background.DocumentLoader import app.opendocument.droid.background.DocumentRequest import app.opendocument.droid.background.FileCache import app.opendocument.droid.background.IdentifiedFile import app.opendocument.droid.background.LoadedDocument +import app.opendocument.droid.background.NightModeSetting +import app.opendocument.droid.background.PaginationSetting import app.opendocument.droid.background.StreamUtil import app.opendocument.droid.background.SupportedDocumentTypes import app.opendocument.droid.background.UsageCounters @@ -76,6 +79,15 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { private var freshOpenPending = false + /** + * Where the reader was, held from [reloadForMargins] until the document comes back. Null at + * every other load: opening a document belongs at its top. + */ + private var positionToRestore: ReadingPosition? = null + + /** A tab and how far down it, which survives the document being translated again. */ + private data class ReadingPosition(val tab: Int, val scrollFraction: Float) + private lateinit var tabLayout: TabLayout private lateinit var documentLoader: DocumentLoader @@ -159,10 +171,6 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { this.pageView = pageView pageView.setDocumentFragment(this) - - // every format, pdf included. the one place that is decided, and every new PageView - // passes through here - pageView.setDarkeningAllowed(true) } catch (t: Throwable) { // crashManager is not set yet: onViewCreated has not run @@ -222,6 +230,9 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { state.lastDocument?.let { lastDocument -> crashManager.log("restoring lastDocument") + // the page view is a new one, and knows nothing of what the old one was told + applyDarkening(lastDocument.file) + restoreTabs(lastDocument) prepareActions(lastDocument) } @@ -353,6 +364,54 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { reload(lastRequest, requireLastFile()) } + /** Tells the page whether it may follow the app into night mode - see [DocumentDarkening]. */ + private fun applyDarkening(file: IdentifiedFile) { + pageView?.setDarkeningAllowed(DocumentDarkening.isAllowed(requireContext(), file.mimeType)) + } + + /** + * Flips that answer for every document of this kind, and shows it straight away: darkening is a + * webview setting, so nothing is rendered again. + */ + fun toggleDarkening() { + val document = state.lastDocument ?: return + + val kind = DocumentDarkening.kindOf(document.file.mimeType) + DocumentDarkening.setAllowed( + requireContext(), + kind, + !DocumentDarkening.isAllowed(requireContext(), document.file.mimeType), + ) + + applyDarkening(document.file) + + // the row says what tapping it does, and what it does has just changed + prepareActions(document) + } + + /** + * The document again with the margins [PaginationSetting] now says, which is decided while + * translating - so it has to be rendered a second time to be seen. + */ + fun reloadForMargins() { + if (!isAdded) { + return + } + + // the page is thrown away and translated again, so where the reader had got to is carried + // by hand - it is the same document, and they did not ask to be put back at the top + positionToRestore = + ReadingPosition( + maxOf(state.lastSelectedTab, 0), + pageView?.verticalScrollFraction ?: 0f, + ) + + // not a new document, and not one the user went and opened either + freshOpenPending = false + + reload(requireLastRequest(), requireLastFile()) + } + /** The same document again, rendered differently - see [DocumentLoader.reload]. */ private fun reload(request: DocumentRequest, file: IdentifiedFile) { beforeLoad() @@ -441,9 +500,64 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { R.drawable.ic_edit, ) - // the order they unfold in, most wanted first + // what the display rows offer is the opposite of what is on screen, so each says what + // tapping it does rather than what it is called + val night = + DocumentActions.Action( + DocumentActions.ACTION_NIGHT_MODE, + if (NightModeSetting.isNight(requireContext())) R.string.menu_day_mode + else R.string.menu_night_mode, + R.drawable.ic_lightbulb, + ) + + // only while the app is dark: below that the webview darkens nothing whatever it is + // allowed, so the row would be a switch with nothing on the other end + val darkening = + if (!NightModeSetting.isNight(requireContext())) null + else { + val kind = DocumentDarkening.kindOf(document.file.mimeType) + val darkened = DocumentDarkening.isAllowed(requireContext(), document.file.mimeType) + + DocumentActions.Action( + DocumentActions.ACTION_DOCUMENT_DARKENING, + // it is remembered for the kind, not for the file, so it says which kind + when (kind) { + DocumentDarkening.Kind.PDF -> + if (darkened) R.string.menu_pdfs_light else R.string.menu_pdfs_dark + DocumentDarkening.Kind.IMAGE -> + if (darkened) R.string.menu_images_light else R.string.menu_images_dark + DocumentDarkening.Kind.DOCUMENT -> + if (darkened) R.string.menu_documents_light + else R.string.menu_documents_dark + }, + R.drawable.ic_invert_colors, + ) + } + + // odrcore applies them to a text document and nothing else, so anywhere else the row would + // render the document again to show nothing new - see PaginationSetting.affects + val margins = + if (!PaginationSetting.affects(document.file.mimeType)) null + else + DocumentActions.Action( + DocumentActions.ACTION_PAGE_MARGINS, + if (PaginationSetting.isEnabled(requireContext())) R.string.menu_fit_to_screen + else R.string.menu_page_borders, + R.drawable.ic_menu_book, + ) + + // the order they unfold in, most wanted first - and what a reader reaches for mid-document + // is how it is displayed, not what else can be done to it val unfolding = listOfNotNull( + night, + darkening, + margins, + DocumentActions.Action( + DocumentActions.ACTION_FULLSCREEN, + R.string.menu_fullscreen, + R.drawable.ic_fullscreen, + ), edit, DocumentActions.Action( DocumentActions.ACTION_TTS, @@ -470,11 +584,6 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { R.string.action_edit_save, R.drawable.ic_save, ), - DocumentActions.Action( - DocumentActions.ACTION_FULLSCREEN, - R.string.menu_fullscreen, - R.drawable.ic_fullscreen, - ), ) actions.setActions( @@ -555,16 +664,27 @@ class DocumentFragment : Fragment(), DocumentLoader.Listener { val activity = requireActivity() val file = document.file + // before the page is put in below, so it is drawn the way it is going to stay + applyDarkening(file) + analyticsManager.setCurrentScreen(activity, file.mimeType ?: UNKNOWN_FILE_TYPE) + // clears lastSelectedTab, so what reloadForMargins put aside is read after it resetTabs() + val restored = positionToRestore + positionToRestore = null + + // always, and not only when there is something to put back: a reload that failed on the + // way here would otherwise leave its fraction waiting for the next document opened + pageView?.restoreScrollFraction(restored?.scrollFraction ?: 0f) + val titles = document.partTitles val pages = titles.size if (pages > 1) { addTabs(titles) - tabLayout.getTabAt(0)?.select() + tabLayout.getTabAt(restored?.tab?.coerceAtMost(pages - 1) ?: 0)?.select() } else if (pages == 1) { loadData(document.partUris[0].toString()) } diff --git a/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt b/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt index 63c6d3a16613..35b5e5325ed5 100644 --- a/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt +++ b/app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt @@ -24,6 +24,8 @@ import androidx.lifecycle.ViewModelProvider import app.opendocument.droid.R import app.opendocument.droid.background.CatchAllSetting import app.opendocument.droid.background.DocumentLoader +import app.opendocument.droid.background.NightModeSetting +import app.opendocument.droid.background.PaginationSetting import app.opendocument.droid.background.PersistedUriPermissions import app.opendocument.droid.background.PrintingManager import app.opendocument.droid.background.SupportedDocumentTypes @@ -155,6 +157,10 @@ class MainActivity : AppCompatActivity() { } override fun onCreate(savedInstanceState: Bundle?) { + // before super: appcompat applies a mode the moment it is told, so setting it afterwards + // recreates the activity that has just been created + delegate.localNightMode = NightModeSetting.mode(this) + super.onCreate(savedInstanceState) setContentView(R.layout.main) @@ -533,6 +539,36 @@ class MainActivity : AppCompatActivity() { updateDocumentActionsVisible() } + DocumentActions.ACTION_NIGHT_MODE -> { + val night = !NightModeSetting.isNight(this) + + analyticsManager.report( + if (night) "menu_night_mode_enter" else "menu_night_mode_leave" + ) + + // recreates the activity, the way a rotation does - and survives it the same way: + // the loader is a ViewModel, and the fragment saves the document + delegate.localNightMode = NightModeSetting.setNight(this, night) + } + + DocumentActions.ACTION_DOCUMENT_DARKENING -> { + analyticsManager.report("menu_document_darkening") + + documentFragment?.toggleDarkening() + } + + DocumentActions.ACTION_PAGE_MARGINS -> { + val margins = !PaginationSetting.isEnabled(this) + + analyticsManager.report( + if (margins) "menu_page_margins_on" else "menu_page_margins_off" + ) + + PaginationSetting.setEnabled(this, margins) + + documentFragment?.reloadForMargins() + } + DocumentActions.ACTION_PRINT -> { analyticsManager.report("menu_print") diff --git a/app/src/main/java/app/opendocument/droid/ui/widget/DocumentActions.kt b/app/src/main/java/app/opendocument/droid/ui/widget/DocumentActions.kt index 7540a682165f..517984162918 100644 --- a/app/src/main/java/app/opendocument/droid/ui/widget/DocumentActions.kt +++ b/app/src/main/java/app/opendocument/droid/ui/widget/DocumentActions.kt @@ -214,6 +214,9 @@ class DocumentActions(context: Context, attributeSet: AttributeSet?) : const val ACTION_OPEN_WITH: Int = 6 const val ACTION_SAVE: Int = 7 const val ACTION_FULLSCREEN: Int = 8 + const val ACTION_NIGHT_MODE: Int = 9 + const val ACTION_PAGE_MARGINS: Int = 10 + const val ACTION_DOCUMENT_DARKENING: Int = 11 private const val ANIMATION_MILLIS = 150L diff --git a/app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt b/app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt index 3a459b9e77b5..bc2a1ec80cf7 100644 --- a/app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt +++ b/app/src/main/java/app/opendocument/droid/ui/widget/PageView.kt @@ -53,6 +53,9 @@ constructor(context: Context, attributeSet: AttributeSet?) : private var wasCommitCalled = false + /** What [loadUrl] was last given: the only page whose failure is this document's. */ + private var loadedUrl: String? = null + private var isBridgeAttached = false init { @@ -75,9 +78,16 @@ constructor(context: Context, attributeSet: AttributeSet?) : override fun onPageFinished(view: WebView, url: String) { super.onPageFinished(view, url) + restorePendingScroll(0) + buggyWebViewHandler.postDelayed( { - if (!wasCommitCalled) { + // [url] and not whatever is loaded now: this callback can arrive after + // another page was asked for, which cancels the retries queued until + // then but not the one queued here. wasCommitCalled is about the page + // being waited on, so on its own it would answer for that other page + // and put this one back over it + if (!wasCommitCalled && url == loadedUrl) { crashManager.log(RuntimeException("commit was not called")) loadUrl(url) @@ -157,6 +167,72 @@ constructor(context: Context, attributeSet: AttributeSet?) : } } + /** + * Where the page sits, as a fraction of what there is to scroll. + * + * A fraction and not the offset: the one thing that reloads a document in place is a change to + * how it is laid out, which changes the height an offset would mean anything against. + */ + val verticalScrollFraction: Float + get() { + val scrollable = verticalScrollableHeight + + return if (scrollable <= 0) 0f + else (computeVerticalScrollOffset().toFloat() / scrollable).coerceIn(0f, 1f) + } + + /** How far the page can be scrolled: its height less the screenful already showing. */ + val verticalScrollableHeight: Int + get() = computeVerticalScrollRange() - computeVerticalScrollExtent() + + private var scrollFractionToRestore: Float? = null + + /** The height the last attempt at restoring measured, to see whether it is still growing. */ + private var lastScrollableHeight = -1 + + private val scrollRestoreHandler = Handler(Looper.getMainLooper()) + + /** + * Puts the next page loaded back to [fraction] of its height. + * + * Not applied here: the page is still being laid out when the load reports itself finished. + */ + fun restoreScrollFraction(fraction: Float) { + scrollFractionToRestore = fraction.takeIf { it > 0f } + lastScrollableHeight = -1 + } + + /** + * Waits for a height that has stopped growing and scrolls to it - a long document goes on being + * laid out, and the first height it reports lands near the top of where the reader was. Gives + * up after [SCROLL_RESTORE_ATTEMPTS], leaving the page where it is. + */ + private fun restorePendingScroll(attempt: Int) { + val fraction = scrollFractionToRestore ?: return + + val scrollable = verticalScrollableHeight + + if ( + (scrollable <= 0 || scrollable != lastScrollableHeight) && + attempt < SCROLL_RESTORE_ATTEMPTS + ) { + lastScrollableHeight = scrollable + + scrollRestoreHandler.postDelayed( + { restorePendingScroll(attempt + 1) }, + SCROLL_RESTORE_INTERVAL_MS, + ) + + return + } + + scrollFractionToRestore = null + + if (scrollable > 0) { + scrollTo(scrollX, (fraction * scrollable).toInt()) + } + } + /** What [setDarkeningAllowed] was last set to, whether or not printing has it suspended. */ var isDarkeningAllowed = false private set @@ -173,7 +249,7 @@ constructor(context: Context, attributeSet: AttributeSet?) : * app is in it: the webview darkens a page algorithmically, and at targetSdk 33 and up only * once the app theme reports itself as dark. * - * [DocumentFragment] decides which documents get it. + * [DocumentFragment] decides which documents get it, from `DocumentDarkening`. */ fun setDarkeningAllowed(allowed: Boolean) { isDarkeningAllowed = allowed @@ -211,6 +287,16 @@ constructor(context: Context, attributeSet: AttributeSet?) : if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, darken) } else if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { + // invert rather than stand aside for the page's own dark theme, which is the default. + // Every page carries one now, but a webview old enough for this branch answers + // prefers-color-scheme by the system alone, so standing aside leaves the page light + if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK_STRATEGY)) { + WebSettingsCompat.setForceDarkStrategy( + settings, + WebSettingsCompat.DARK_STRATEGY_USER_AGENT_DARKENING_ONLY, + ) + } + // ON rather than AUTO on the pre-webkit-1.6 api: AUTO is the platform's smart dark, // which an app declaring a dark theme is deliberately left out of, so it never fires // here. Asking the app whether it is in night mode is what AUTO cannot do for us @@ -233,6 +319,14 @@ constructor(context: Context, attributeSet: AttributeSet?) : // the third party viewers an ONLINE result loads here. takes effect on the next load if (!url.startsWith(JAVASCRIPT_SCHEME)) { attachBridge(isOwnContent(url)) + + // a page that never committed left a retry waiting in onPageFinished. Now that another + // page has been asked for, that retry would load the old one back over it - and the + // document it belonged to has taken its server with it, so what it would find there is + // a 404 this page is then given up on for + buggyWebViewHandler.removeCallbacksAndMessages(null) + + loadedUrl = url } super.loadUrl(url) @@ -256,6 +350,13 @@ constructor(context: Context, attributeSet: AttributeSet?) : return } + // and only the page being shown. A request made for a document already closed can still be + // answered here, long after the page moved on, and the document on screen is not the one + // that failed + if (loadedUrl != null && url.toString() != loadedUrl) { + return + } + documentFragment.onPageFailed() } @@ -368,5 +469,10 @@ constructor(context: Context, attributeSet: AttributeSet?) : /** Where CoreLoader publishes a translated document. */ const val LOCAL_SERVER_URL_PREFIX = "http://localhost:" + + /** Two seconds of them, which a megabyte of text lays out well inside of. */ + const val SCROLL_RESTORE_ATTEMPTS = 20 + + const val SCROLL_RESTORE_INTERVAL_MS = 100L } } diff --git a/app/src/main/res/drawable/ic_invert_colors.xml b/app/src/main/res/drawable/ic_invert_colors.xml new file mode 100644 index 000000000000..8dacd7802254 --- /dev/null +++ b/app/src/main/res/drawable/ic_invert_colors.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_lightbulb.xml b/app/src/main/res/drawable/ic_lightbulb.xml new file mode 100644 index 000000000000..4686ca832ec1 --- /dev/null +++ b/app/src/main/res/drawable/ic_lightbulb.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu_book.xml b/app/src/main/res/drawable/ic_menu_book.xml new file mode 100644 index 000000000000..2788fde0d67b --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_book.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8722823f7a90..bc1c7ffd8740 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,6 +38,16 @@ Go Pro Get Pro Fullscreen mode + Night mode + Day mode + Darken documents + Keep documents light + Darken PDFs + Keep PDFs light + Darken images + Keep images light + Fit to screen + Show page borders Print document Text-To-Speech Couldn\'t open selected app.