Skip to content

Let the document follow the app into night mode again - #596

Merged
andiwand merged 2 commits into
mainfrom
fix/dark-documents
Aug 18, 2026
Merged

Let the document follow the app into night mode again#596
andiwand merged 2 commits into
mainfrom
fix/dark-documents

Conversation

@andiwand

@andiwand andiwand commented Aug 17, 2026

Copy link
Copy Markdown
Member

From user feedback: "Kein Dark Mode mehr wie vorher?"

The redesign (#547) replaced PageView.toggleDarkMode(enabled) with disableDarkening(), called on every page. The shell kept following night mode; only the document stayed white. In night mode the app was a dark frame around a white page.

The change

setDarkeningAllowed(allowed), called once per PageView in DocumentFragment.initializePageView(). It is on for every format, pdf included — the pre-redesign code excluded pdf, but rather than guess which formats invert badly we can look at a spread of real documents in the simulator and cut it back from that one line.

Darkening only ever does anything while the app itself is in night mode: at targetSdk 33+ the WebView darkens algorithmically and only once the app theme reports itself dark.

PrintingManager takes its onFinished callback back, so printing can turn darkening off and put it back once the job ends — the print framework reads the page long after print() returns, so it cannot be restored inline.

Tests

DarkModeTests puts the app in night mode and checks three things, weakest to strongest:

  1. the flag this app kept
  2. the setting the WebView was actually given (WebSettingsCompat.isAlgorithmicDarkeningAllowed)
  3. what is drawn — a screenshot of the middle of the screen, asserting mean luminance is below half

The third is the one that matters: a WebView that ignored the setting would pass the first two. It has to be a screenshot rather than pageView.draw() into a bitmap — the WebView renders on the compositor, so drawing it into a bitmap of our own hands back an empty layer whatever the page looks like. That version passed against the broken behaviour, which is how it was caught.

All three fail against the old behaviour, the screenshot one with the page stayed light - mean luminance 255.

Verified

spotlessCheck, testProDebugUnitTest, lintProDebug, assembleDebug, and the full instrumented suite (72 tests) green on a Pixel 9 Pro emulator.

On a short landscape AVD (ODR_Screenshots, 1216x535) MainActivityTests.testPasswordProtectedODT fails — it does so 3/3 on main as well, so it is that AVD and not this branch.

🤖 Generated with Claude Code

The redesign replaced `PageView.toggleDarkMode(enabled)` with
`disableDarkening()`, called on every page. The shell kept following night
mode and the document stayed white, which readers noticed and wrote in about.

Darkening is allowed for every format, pdf included, until we have looked at
enough of them to say which ones invert badly. `DocumentFragment` is the one
place that is decided.

`PrintingManager` takes its `onFinished` callback back, so printing can turn
darkening off and put it back once the job ends - the print framework reads
the page long after `print()` returns.

`DarkModeTests` puts the app in night mode and asserts three things: the flag,
the setting the webview was actually given, and the mean luminance of the
screen. All three fail against the old behaviour, the screenshot one with
"the page stayed light - mean luminance 255".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouAQNfktsp9THcceennEX

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c27efa4fdb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread app/src/main/java/app/opendocument/droid/background/PrintingManager.kt Outdated
Comment thread app/src/main/java/app/opendocument/droid/ui/activity/MainActivity.kt Outdated
`FORCE_DARK_AUTO` is the platform's smart dark, which an app declaring a dark
theme is deliberately left out of - so on a webview old enough to have only that
api the page never darkened, which is what `theDrawnPageIsDark` was failing on at
API 29 and 30. It is `FORCE_DARK_ON` now, asked only when the app itself reports
night mode, which is the part AUTO cannot work out for us. Where the webview has
neither api - API 26's is too old - the screenshot test skips rather than asserts
a page nothing could have darkened.

From the review of #596:

- printing waited for the print *job* to reach a terminal state, so a queued or
  blocked job left the document light for as long as the printer stayed off. The
  framework lets go of the adapter long before that, so `PrintDocumentAdapter
  .onFinish` is what restores it now, with the job poll left as the backstop for
  the adapter that is dropped without a last call. Whichever comes first wins,
  once.
- `MainActivity` snapshotted `isDarkeningAllowed` around the print, so a second
  print started while the first was still spooling snapshotted the suspended
  value and one of the two restores went the wrong way. `PageView` counts the
  suspensions instead and only darkens again once the last reader is done, and
  the page it is given back to is looked up again rather than captured.

`DarkModeTests.printingGivesTheDarkeningBack` holds the counting. All 73
instrumented tests pass on an API 29 emulator, where three of them failed before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4Tbjr2eTpVkBxDfdFnkgF
@andiwand
andiwand enabled auto-merge (squash) August 18, 2026 06:09
@andiwand
andiwand merged commit ebfac84 into main Aug 18, 2026
12 of 13 checks passed
@andiwand
andiwand deleted the fix/dark-documents branch August 18, 2026 06:19
andiwand added a commit that referenced this pull request Aug 18, 2026
The engine moves from 6.7.1 to 6.8.0, and nothing here has to move with it. The
format table gains a `color_scheme` capability per row and nothing else - no type,
extension or mime type changes - so `SupportedDocumentTypes` derives the same two
sets and `STRICT_CATCH`'s generated intent-filters still match them, which
`SupportedFormatsTest` confirms. `FileTypeCapabilities` and `HtmlConfig` gain a
field each, both additive, and `CoreLoader` names the ones it sets.

6.8.0's headline is the one thing this app already answers itself. The core can
now render a file in the dark, on `HtmlConfig.colorScheme`, which defaults to
`LIGHT` and writes nothing at all in that case - no `color-scheme` declaration
reaches the page, so the algorithmic darkening `PageView` turns on keeps working
exactly as it did in #596. Handing that job to the core instead is worth doing,
since it would let a document's own colours give way rather than be inverted, and
it is a change of its own rather than part of taking the version.

What reaches a user is text this time. A megabyte of it took 38 seconds to lay
out, because sizing the line numbers laid the page out once per line, and now
takes under a second. Prose is no longer read as a csv: a separator every field
follows with a space, in fields long enough to be sentences, is punctuation, while
`a, b, c` with short values is still a table. And text reflowed to the viewport is
inset 3mm from the screen edge rather than starting at its first pixel.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
andiwand added a commit that referenced this pull request Aug 18, 2026
Two readers wrote in about the same screen from opposite ends: one wanted the
document to follow the phone into night mode, the other wanted the margins off
without going back to the landing screen for them. Both answers were somewhere
else - one in the system settings, one behind a fold on another screen - so the
document's own buttons carry them now.

Three rows, each saying what tapping it does rather than what it is called:

- **Night mode** puts the app in it through `AppCompatDelegate.setLocalNightMode`
  rather than the default mode, so reading at night no longer means turning the
  whole phone dark first. It recreates the activity the way a rotation does and
  survives it the same way: the loader is a ViewModel, the fragment saves the
  document, and the page view its own state. `NightModeSetting` remembers the
  choice - a local mode does not outlive the process - and stores no override at
  all once the answer agrees with the system again, or the app would sit in night
  mode through a morning the phone had long left.

- **Darkening** is per kind of document rather than one answer for everything.
  A text document inverts into something that still reads; a scanned page or a
  photograph inverts into something nobody wrote. So `DocumentDarkening` defaults
  documents to dark and pdfs and images to light, and the button edits that for
  every document of the kind - it is never *this* pdf that inverts badly, it is
  pdfs. Google Docs forgets the same choice on reopen and Word remembers it for
  everything at once; per kind is the middle, and the row names the kind it will
  remember so the memory is not invisible. It applies live: darkening is a webview
  setting, not something the page was translated with.

  This reverses `aPdfIsDarkenedToo` from #596, which included every format
  deliberately, "until we have looked at enough of them to say which ones invert
  badly". Acrobat is the one that has looked: its night mode is opt-in per
  document. Presentations and drawings stay with documents, because that much is
  still a guess.

  The kind comes from the core's own table - `fileTypeByMimetype`, then the file
  type itself, never a prefix. `documentTypeByFileType` cannot answer it:
  application/pdf reports TEXT, exactly like an odt.

- **The margins** are odrcore's `textDocumentMargin`, decided while translating,
  so the button renders the document again through the reload that edit mode
  already uses - the copy in the cache, which is not read or re-listed a second
  time. The landing switch stays: it writes the same preference, so the two can
  never disagree.

Fullscreen moves up beside them, as the third row about what the page looks like
rather than what can be done to it.

`DarkModeTests` gains the switch darkening a day-mode app (the screen is measured,
not the flag) and the per-kind answer surviving a reopen; `MainActivityTests` that
the margin row renders the document again rather than only flipping a preference.
All 76 pass on an API 36 emulator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNSUAUN1QX2QyFNw4rAmV2
andiwand added a commit that referenced this pull request Aug 18, 2026
* Answer how a document is displayed over the document itself

Two readers wrote in about the same screen from opposite ends: one wanted the
document to follow the phone into night mode, the other wanted the margins off
without going back to the landing screen for them. Both answers were somewhere
else - one in the system settings, one behind a fold on another screen - so the
document's own buttons carry them now.

Three rows, each saying what tapping it does rather than what it is called:

- **Night mode** puts the app in it through `AppCompatDelegate.setLocalNightMode`
  rather than the default mode, so reading at night no longer means turning the
  whole phone dark first. It recreates the activity the way a rotation does and
  survives it the same way: the loader is a ViewModel, the fragment saves the
  document, and the page view its own state. `NightModeSetting` remembers the
  choice - a local mode does not outlive the process - and stores no override at
  all once the answer agrees with the system again, or the app would sit in night
  mode through a morning the phone had long left.

- **Darkening** is per kind of document rather than one answer for everything.
  A text document inverts into something that still reads; a scanned page or a
  photograph inverts into something nobody wrote. So `DocumentDarkening` defaults
  documents to dark and pdfs and images to light, and the button edits that for
  every document of the kind - it is never *this* pdf that inverts badly, it is
  pdfs. Google Docs forgets the same choice on reopen and Word remembers it for
  everything at once; per kind is the middle, and the row names the kind it will
  remember so the memory is not invisible. It applies live: darkening is a webview
  setting, not something the page was translated with.

  This reverses `aPdfIsDarkenedToo` from #596, which included every format
  deliberately, "until we have looked at enough of them to say which ones invert
  badly". Acrobat is the one that has looked: its night mode is opt-in per
  document. Presentations and drawings stay with documents, because that much is
  still a guess.

  The kind comes from the core's own table - `fileTypeByMimetype`, then the file
  type itself, never a prefix. `documentTypeByFileType` cannot answer it:
  application/pdf reports TEXT, exactly like an odt.

- **The margins** are odrcore's `textDocumentMargin`, decided while translating,
  so the button renders the document again through the reload that edit mode
  already uses - the copy in the cache, which is not read or re-listed a second
  time. The landing switch stays: it writes the same preference, so the two can
  never disagree.

Fullscreen moves up beside them, as the third row about what the page looks like
rather than what can be done to it.

`DarkModeTests` gains the switch darkening a day-mode app (the screen is measured,
not the flag) and the per-kind answer surviving a reopen; `MainActivityTests` that
the margin row renders the document again rather than only flipping a preference.
All 76 pass on an API 36 emulator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Let the core say what darkens, and keep the reader's place

Three answers the review asked for, and one the new engine can give.

odrcore 6.8.0 renders a file dark itself, so `CoreLoader` translates every page
with `HtmlColorScheme.SYSTEM`: a format that has a dark of its own now carries
both, each behind the media query the webview answers, and the colours the file
authored give way instead of being inverted. Nothing about the button changes -
`PageView.setDarkeningAllowed` still picks between the two at display time, so
darkening is still a webview setting and still renders nothing again.

That makes `capabilitiesByFileType(...).colorScheme` the answer to what should
darken, which `DocumentDarkening` had been guessing at. It defaults to that
rather than to a list, and the guesses are gone: an image darkens now, because
the core puts a dark ground under the photograph rather than into it, while a
pdf and the media views have no dark of their own and are left as they were,
offered but off. `Kind` is only what an override is remembered for.

The margin button is offered where it does something. odrcore applies
`textDocumentMargin` to a text document and nothing else, so on a pdf, an image
or a spreadsheet the row translated the document again to show exactly the same
page - and answered for the next text document opened while it was at it. Four
types call themselves text and one of them is pdf, which is fixed pages laid out
by a frontend the margin never reaches, so `PaginationSetting.affects` excludes
it by name.

And the margins reload keeps the reader where they were. It is the one thing
that throws the page away and translates it again, and it came back at the top
of the first tab; `DocumentFragment` now carries the tab and how far down it
over to the document that arrives. A fraction rather than the offset, because
the margins are exactly what changed the height it would have been measured
against, and applied once the layout stops growing rather than when the load
reports itself finished - a long document is still being laid out then.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Darken a file the core could not name, as before

`darkensByDefault` asked the core about a file type and refused when there was
none, which is a mime type the core does not know - and that file is being shown
as text or as the html fallback, both of which have a dark of their own. It is
also the kind `kindOf` gives it. The list this replaced said DOCUMENT and so
darkened it; this now says the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Say what the webview was, when a page will not go dark

These tests fail on one api level at a time, on emulators whose webview is far
older than any developer machine's, and "the page stayed light - mean luminance
252" does not say which of the two darkening apis was in play, or whether the
page view was allowed to darken at all. Both luminance assertions now say.

Nothing about the app changes. `theSwitchDarkensADayModeApp` fails on api 29 and
nowhere else, it fails the same before and after this branch's darkening
changes, and the first guess at why - that the restored page was rendered before
darkening reached it - was wrong: moving the call ahead of `restore` changed the
reading not at all. So this asks the failing run instead of guessing again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Invert on the old webview, rather than stand aside for a theme it cannot match

The diagnosis the last commit added says what api 29 is: webview 74, no
algorithmic darkening, force dark instead - and the app doing its part, the page
view allowed to darken and force dark switched on, over a page that stayed
light.

Force dark defaults to standing aside for a page carrying a dark theme of its
own, and since `HtmlColorScheme.SYSTEM` every page carries one. A webview this
old answers prefers-color-scheme by the system alone and never matches it for a
forced dark, so what it stands aside for is nothing at all. Asking for
`DARK_STRATEGY_USER_AGENT_DARKENING_ONLY` says invert it anyway, which on that
branch is the only dark there is; the newer branch is untouched and still gets
the page's own dark, which is the better one.

This is not the whole of api 29: main fails `theDrawnPageIsDark` there too, at
the same luminance and without any of this branch's changes, so that webview was
already not darkening reliably. What this fixes is the part this branch would
otherwise have added to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Skip the pixel tests on a webview that cannot darken, and wait for the text

Two levels were red for reasons of their own.

Api 29 ships webview 74, which reports `FORCE_DARK` supported, takes the setting,
hands it back, and draws the page exactly as light as before - force dark is only
implemented from 76. So `canDarken` asks the version as well as the api, and the
two tests that read pixels skip there rather than fail for a webview that was
never going to darken. What the app does is still asserted on api 29, through
`darkeningSetting`: allowed, and the setting it was given. A version that cannot
be read counts as capable, since a skip taken by mistake is coverage quietly
lost.

`aMegabyteOfTextOpensAndIsSearchable` waited for the element count to stop
changing and then searched, and found nothing. A megabyte is parsed in bursts, so
two equal readings can both land inside one pause: the page had laid out, which
is what that wait asserts, and was still filling. It now waits for the text as
well - every line carries the needle once, so they are all in the page or it is
not all there yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Hold the reading position the margin switch used to lose

Polled with a timeout and reported with what it actually read, the way the edit
mode tests do it: the page has to be laid out again before there is anywhere to
put anyone back to, so there is nothing to assert straight after the reload.

A fraction on both sides. The margins are exactly what changes the height, so
the same place in the text is a different offset once the page has been laid out
again - hence a tolerance rather than an equality. It scrolls to the middle
rather than the end, since the last screenful is one position however far past
it the page is scrolled, and a test that stopped there would pass without
anything being restored at all. `style-various-1.docx` because it is long enough
to scroll, and a text document, which is the only kind the margin button is
offered on.

With the restore taken back out it fails with "the reader was put back at 0.0 of
the page, not around 0.5", which is what it is for. `verticalScrollableHeight`
is public now because the test cannot reach the protected webview call it was
spelled out of, and it was written three times in here anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Say the same thing about the display buttons in fewer words

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Trim the test comments and the note in CLAUDE.md to what they have to say

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Do not let a page that was navigated away from fail the one that replaced it

`testDOCXEditMode` and `testODTEditMode` have been failing an api level at a time
all week, always with "webview did not answer in 10000ms document=no result" -
and `no result` is the tell: `lastDocument` is only null once something called
`unload`, so the document had not merely failed to become editable, it had been
given up on.

Logcat from a failing run says what did it. A page that never commits leaves a
reload scheduled 2.5s out - the workaround for a webview reporting progress 100
over a blank page - and nothing cancelled it when the next page was asked for.
The test before had deliberately loaded a page that 404s, so its retry fired
inside the next test, went back to a server that had gone with the document that
owned it, and got a 404. `onReceivedHttpError` reported that against whatever
document was on screen by then, which was the docx being opened for edit mode.

So `loadUrl` cancels a retry the page before it left waiting, the way `destroy`
already does when the whole view is replaced. And `failPage` answers only for
the page it was last asked to load: a request made for a document already closed
can still be answered long after, and the document on screen is not the one that
failed.

Nothing is loosened. `LandingTests.aDocumentThatFailsToOpenComesBackToTheList`
holds the case that matters - a document whose page really is a 404 - and the
whole instrumented suite, 80 tests, passes locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Check the retry against the page still wanted, not only the flag

Cancelling on `loadUrl` clears the retries queued until then, and not the one
queued after: page A can finish - and schedule its retry - once B has already
been asked for. `wasCommitCalled` is about whichever page is being waited on, so
if B has not committed within the 2.5s, A's retry reads B's flag, believes it is
about itself, and loads A back over B. Which is the navigation this set out to
stop, arrived at from the other side.

So the retry asks whether the page it names is still the page wanted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant