Skip to content

Redesign: Material 3, a landing screen that lists your documents, and actions where the thumb is - #547

Open
andiwand wants to merge 24 commits into
mainfrom
redesign
Open

Redesign: Material 3, a landing screen that lists your documents, and actions where the thumb is#547
andiwand wants to merge 24 commits into
mainfrom
redesign

Conversation

@andiwand

@andiwand andiwand commented Jul 28, 2026

Copy link
Copy Markdown
Member

The app opened on a static welcome screen — a title and three icon rows explaining
that it can view, search and edit documents, none of it actionable — over a
Theme.AppCompat.Light with five hardcoded hex values and the OS force-dark hack
standing in for a night mode. Everything you could do with an open document lived in
the top right corner, four of it behind "More options".

This branch replaces all of that: Material 3 with real night resources, a landing
screen that is the recently opened list plus the folders you have granted access to,
and the document's actions moved down to the thumb. It collects 14 commits, 12 of which
were reviewed as their own PRs (#524#546).

Theme (#524)

Base.MainTheme is Theme.Material3.DayNight over named colour roles with a
values-night counterpart; the -v29/-v35 variants now only add what is specific to
them, so there is no full copy left to drift. The inverse and tertiary roles are set
explicitly — leave them out and snackbars fall back to material's purple baseline.
uiMode comes out of configChanges, so a dark mode toggle recreates the activity
instead of leaving already-inflated views on their day colours until the next launch.

Documents themselves stay on the background they were authored against: algorithmic
darkening starts firing on its own at targetSdk 33+ once the app theme reports itself
dark, and inverting an odt full of tables and coloured text is not a change to make
silently. ProgressDialogFragment moves off the framework ProgressDialog, which
resolves android:alertDialogTheme and would have stayed light.

Landing screen (#525, #527, #528, #530)

LandingFragment is a sibling of DocumentFragment with its state in a ViewModel, so
it survives the recreation a rotation or a dark mode toggle causes. It loads on an
executor and publishes through LiveData like the loaders do — no coroutines, nothing in
this project is written in them.

  • Recently opened documents are the screen, not the buried last row of a chooser
    dialog. Each row carries when it was last opened, and can be swiped away with an undo
    that puts it back at the index it came from.
  • Folder browsing with no storage permission: the manifest still declares nothing
    but INTERNET and stays that way. ACTION_OPEN_DOCUMENT_TREE takes a read-only grant
    the user picks once, and the landing screen lists and navigates it from then on.
    Listing goes through DocumentsContract rather than androidx's DocumentFile, which
    issues a fresh query per child the moment a name or type is read.
  • Settings — the catch-all switch, and the ad removal that used to be the landing
    screen's last remaining menu item — sit under a header in the list. The empty state is
    the first row rather than a screen of its own, so the settings stay reachable on a
    fresh install, which is exactly when somebody whose file manager refuses to hand
    documents over needs to find them.

PersistedUriPermissions grows isReferenced(), so prune() knows a document below a
granted tree is covered by that tree's grant, and folder grants join the in-flight set
that already kept a prune from reclaiming a grant nothing points at yet.

Actions where the thumb is (#526, #529, #531, #542)

  • The "Open document via:" dialog is gone — the system picker already browses Drive,
    Downloads, a usb stick and every installed file manager, and it is the ui people know.
    The <queries> entries stay; removing package visibility is the kind of change that
    breaks on one OEM and nowhere else.
  • The toolbar's open action is gone with it: the landing screen offers the same intent
    twice already, and inside a document it was the odd one out — every other action acts
    on the open document, that one threw it away.
  • DocumentActions is a view, not a menu: two buttons over the document's bottom
    right corner — search, and one that unfolds the other seven with their names next to
    them. Being a view means the actions are set when a load finishes rather than when
    something invalidates the menu, which is what the old prepareMenu-from-onCreateMenu
    rule was working around. menu_main.xml is deleted.
  • With nothing left in it, the action bar is hidden rather than themed away. It is
    still where action modes appear (find, tts and edit all use startSupportActionMode),
    and ActionBarOverlayLayout is what keeps system bar insets off the content — so
    checkShowingFlags() brings the container back for as long as a mode is up. A
    .NoActionBar theme would have swapped that decor out and double-padded the content.

Formats (#546)

odrcore v6 keeps reference html output for doc, ppt, xls and pptx too, so what
CoreLoader claims is now one list — opendocument, the whole ooxml family, the three
legacy microsoft formats and pdf — instead of a TODO: enable pptx too and a doc mime
type that let the legacy binaries through by accident. Prefixes, so templates and the
macro-enabled variants come along.

Two lists collapse into one. CoreLoader.MIME_PREFIXES was a second
SupportedDocumentTypes and is gone. And editability is asked of the core rather
than guessed from a mime type: host() keeps a document open only when
isEditable()/isSavable() says yes, so "we have a document" is the answer that
travels to the fragment — the UI's own list of exceptions had already gone wrong for
the legacy formats it was newly offering an Edit button to.

The manifest's STRICT_CATCH filter is the one declaration XML keeps out of reach, so
SupportedFormatsTest walks odrcore's FileType values and asserts the table and the
package manager agree.

Tests

New: LandingTests (252 lines, instrumented), SupportedFormatsTest,
CoreLoaderTest, RecentDocumentListTest, SupportedDocumentTypesTest, and CoreTest
coverage for .doc/.ppt/.xls/.pptx against real fixtures. Verified on device.

🤖 Generated with Claude Code

andiwand and others added 14 commits July 28, 2026 21:33
MainTheme was Theme.AppCompat.Light with five hardcoded hex values,
copied in full into values-v29 and values-v35 so that the qualified
variants had already drifted apart. Dark mode was the OS force-dark hack
turned on with android:forceDarkAllowed, not a resource set of our own.

The theme is now Theme.Material3.DayNight over named colour roles, with
a values-night counterpart. Base.MainTheme holds everything and the
qualified variants only add what is specific to them, so there is no
copy to drift. The inverse and tertiary roles are set explicitly: leave
them out and snackbars fall back to material's purple baseline.

uiMode comes out of configChanges. Swallowing it meant the activity was
never recreated on a dark mode toggle, so already-inflated views kept
their day colours until the next launch - which would have made the
whole migration look half-applied.

Documents stay on the background they were authored against. Algorithmic
darkening never fired under the old light theme, and at targetSdk 33 and
up it starts the moment the app theme reports itself as dark; inverting
an odt full of tables and coloured text is not a change to make silently.

ProgressDialogFragment moves off the framework ProgressDialog, which
resolves android:alertDialogTheme and would have stayed light.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The app opened on a static welcome screen: a title and three icon rows
explaining that it can view, search and edit documents, none of which
was actionable. The recently opened list existed but was buried as the
last row of the file manager chooser, so almost nobody found it.

The landing screen is now that list. LandingFragment is a sibling of
DocumentFragment, with its state in a ViewModel so it survives the
recreation a rotation or a dark mode toggle causes, and MainActivity
keeps only the container swap it already did.

RecyclerView and the two lifecycle artifacts already came in through
material, so declaring them costs no apk size - it only stops a material
bump from silently taking a compile dependency away. No coroutines: they
are on the classpath transitively via the play libraries, and nothing in
this project is written in them, so the list loads on an executor and
publishes through LiveData like the loaders do.

The fragment is hidden rather than stopped when a document opens, so
nothing in the lifecycle fires when one is closed - hence the explicit
setLandingVisible, without which the list would miss the document that
was just added to it.

The catch-all switch keeps its place, under a settings header. It is
exactly the setting a stuck user has to be able to find. Its logic moves
to CatchAllSetting, but applyOnLaunch stays in MainActivity.onCreate:
the upgrade re-toggle has to run even when the app is launched straight
into a document and the landing screen is never shown.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
findDocument queried every activity answering ACTION_OPEN_DOCUMENT and
raised an "Open document via:" dialog to choose between them, before the
picker it was going to launch anyway. That is a tap that buys nothing:
the system picker already browses Drive, Downloads, a usb stick and
every installed file manager, and it is the ui people know.

Its last row used to be the recently opened documents, which is now the
landing screen itself, so the dialog has nothing left that is its own.

The <queries> entries stay. They were there for queryIntentActivities,
but removing package visibility is exactly the kind of change that
breaks on one OEM and nowhere else.

MainActivityTests drove the picker through the dialog, so it loses that
step; the intent stubbing is untouched, because Espresso-Intents
intercepts at execStartActivity, below ActivityResultLauncher.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
"See the files on my phone" has no permission behind it any more.
READ_EXTERNAL_STORAGE has not reached documents since scoped storage,
READ_MEDIA_* covers only images, video and audio, and Play restricts
MANAGE_EXTERNAL_STORAGE to file managers and backup apps - a document
viewer asking for it gets the listing rejected. The manifest still
declares nothing but INTERNET, and it stays that way.

ACTION_OPEN_DOCUMENT_TREE does the same job with the user's consent
instead: they pick a folder once, we persist a read only grant, and the
landing screen lists and navigates it from then on. Read only - no write
flag is ever requested.

Listing goes through DocumentsContract rather than androidx's
DocumentFile, which issues a fresh query per child the moment a name or
type is read. One cursor covers a whole folder.

SupportedDocumentTypes decides what is worth showing. It has to guess:
MetadataLoader only knows what is supported after libmagic has run over
the cached file, and a folder listing has nothing but a name and
whatever mime type the provider volunteered - which is regularly
application/octet-stream, hence the extension fallback. It mirrors the
STRICT_CATCH intent filter and nothing keeps the two in step, so both
sides now say so, as does CLAUDE.md.

prune() learns that a document below a granted tree is covered by that
tree's grant, which it can tell from the uri alone - so nothing has to
record where a document was opened from.

The tree grant is taken through PersistedUriPermissions rather than the
resolver directly, so that it joins the in flight set the recent list's
grants already use: addFolderTree writes the tree down on the view
model's executor, and a prune in that window would find a grant nothing
points at yet and hand it straight back. Same race, same fix, one folder
instead of one document.

That decision is now isReferenced(), which takes the strings rather than
a context and so can be covered by a plain jvm test, with the release
half lifted out beside it to keep prune() down to the loop it is.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The list of mime prefixes took the whole openxmlformats-officedocument
family, but the STRICT_CATCH filter names two of them - docx and xlsx -
and CoreLoader opens no others. A provider that reports a pptx by its
standard mime type therefore had it listed as openable, and tapping it
led into the unsupported/upload path instead. The extension side of the
guess already left pptx out, so the two halves disagreed.

Both types are now spelled out, and stay prefixes rather than exact
matches so that they keep testing the same strings the same way
CoreLoader does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgJyGjRBpjAJ2724aMNnEp
The empty state was shown instead of the list, which took the settings
section with it: on a fresh install - and after the last recent document
is removed - the catch-all switch was the one thing on the landing screen
that could not be reached, and it is exactly what somebody whose file
manager refuses to hand documents over needs to find.

It is now the first row of the list rather than a screen of its own, so
whatever sits below it stays where it is. It keeps both of its buttons,
which is why the folders section is left out while it is up: the row
already offers to add one, and an empty section under it would say the
same thing twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgJyGjRBpjAJ2724aMNnEp
Two things the list was missing. Each row now carries the time it was
last opened, which is the thing that tells two similarly named documents
apart, and a recent document can be swiped off the list with an undo.

Undo puts the entry back at the index it came from rather than at the
front, so the list ends up looking exactly as it did - that is the point
of an undo, and it is why RecentDocumentList grows an insert() next to
add() instead of reusing it.

Nothing releases the uri permission on the way out. An undo needs it
back, and prune() reclaims it on the next launch anyway - which is what
reconciling against the stored lists, rather than bookkeeping every
removal, was for.

Only the recently opened documents can be swiped. A document inside a
granted folder is simply there, and the app has no business removing
anything from disk; the adapter tells the two apart by whether the row
has a timestamp on it.

A "dark documents" switch was built here too and taken back out: with
the html odrcore generates, algorithmic darkening changes nothing
visible, so the setting would have looked broken. Making it work needs a
dark stylesheet from the core, not a toggle here.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The landing screen already offers to open a document twice - the fab, and
the labelled button in the empty state - and both go straight to the
system picker since the chooser dialog went away. The toolbar icon was a
third copy of the same intent, sitting in the one place where it is
least explained: an unlabelled folder glyph next to the title.

Inside a document it was the odd one out too. Every other toolbar action
acts on the document that is open; this one threw it away and started
over, which is what back already does, and back lands on the list where
the next document is picked anyway.

The tests clicked the toolbar icon to reach the picker, so they now
click whichever landing entry point is on screen. Which of the two it is
depends on whether an earlier test already left something in the recent
list, so they match either.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The subtitle carries the last opened time, and an entry written before
that time was recorded has none - so reading "is this a recent" off it
made exactly those legacy entries unswipeable, on the one install where
it matters: an upgrade with a recent_documents.json already in place.
They would have stayed that way until each was opened again.

LandingItem.Document now carries the provenance itself, set where the
row is built and defaulted off for the folder listing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgJyGjRBpjAJ2724aMNnEp
insert() skipped the eviction add() does, so an undo could take the list
past MAX_ENTRIES: swipe one away with a full list, open another document
while the snackbar is still up, then undo, and the list comes back one
entry longer - with the extra entry holding a persisted uri permission
of its own, against a per package cap the class exists to respect.

Both paths share a cap() now, and insert() returns the same Update as
add() so the entry that fell off the end can have its grant handed back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgJyGjRBpjAJ2724aMNnEp
It was the last item in the toolbar menu on the landing screen, and with
the open action gone it was the only one - a screen whose entire content
is a list kept an overflow menu alive for a single row that belongs in
that list. It now sits under the settings header, next to the catch-all
switch, and the landing screen has no menu at all.

The row is only built when there is something to sell: never in pro,
where the purchase is implied, and not once it has been bought.
LandingFragment asks the activity at render time rather than reading it
from the state, because billing is set up by initializeProprietaryLibraries
- which runs again after the play services dialog - and is not something
the ViewModel could read off disk itself.

That takes it out of the document view too, where the overflow menu
carried it. What is left there is the snackbar that offers the purchase
on entering fullscreen, which is untouched.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toolbar menu is gone. What can be done with the open document is now
two buttons over its bottom right corner: search, which is what a reader
reaches for most, and one that unfolds the other seven with their names
next to them.

The top right corner of a modern phone is the one place a thumb holding
it cannot reach, and it is where every one of these lived - four of them
behind "More options", which says nothing about what is inside. Naming
them costs a tap that the overflow charged anyway.

DocumentActions is a view rather than a menu, so the actions are set
whenever a load finishes instead of whenever something happens to
invalidate the menu. That is the reason for the odd rule the old code
lived by: prepareMenu ran from onCreateMenu only, and a document that
finished loading does not invalidate anything, so which items were
visible depended on what else had asked the toolbar to rebuild. There is
no menu provider left in either class, and menu_main.xml is deleted.

The rows scroll: seven of them do not fit above the buttons on a phone
held sideways. They hide while an action mode is up - find, tts and edit
each bring their own controls - and while fullscreen has the screen,
which back still leaves exactly as before. Back folds them up first.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing has been left in the options menu: the landing screen carries its
own headers, the open action went away with the direct picker, the ad
removal moved into the settings section and the document's actions moved
down to the buttons by the thumb. What stayed behind is an empty strip of
colour at the top of every screen.

The bar cannot simply be dropped though - it is where the action modes
appear. find, tts and edit are all raised with startSupportActionMode(),
and appcompat puts them in the ActionBar's own container, in place of the
toolbar. So rather than moving to a .NoActionBar theme, MainActivity hides
the bar on start and lets appcompat bring the container back for as long
as a mode is up: checkShowingFlags() shows it whenever an action mode is
showing, whatever the app asked for, and takes it away again when the mode
finishes.

That also leaves the window insets alone. ActionBarOverlayLayout keeps the
system bar insets off the content and reports the toolbar's height on to
the app as an inset of its own, which is what MainActivity's listener pads
main_root with - so the find bar pushes the page down by exactly its own
height, and nothing pads anything while no mode is up. A NoActionBar theme
would swap that decor for a FitWindowsLinearLayout, which pads itself by
the system bars on top of the padding MainActivity already applies.

The fullscreen hide()/show() pair goes with it: with the bar hidden for
good there is nothing left for it to toggle, and show() would have put the
empty bar back on leaving fullscreen.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Open every format the core renders, not just half of them

odrcore v6 keeps reference html output for doc, ppt, xls and pptx next to
the ones the app already claimed, so the "TODO: enable pptx too" that sat
in CoreLoader was the only thing keeping presentations out - and the
legacy binary formats reached the core only by accident, through the doc
mime type that was listed before ppt and xls ever were.

What the loader claims is now one list: opendocument, the whole ooxml
family, the three legacy microsoft formats and pdf. Prefixes, so the
templates and the macro enabled variants come along - the latter are
spelled vnd.ms-word/vnd.ms-excel/vnd.ms-powerpoint rather than under the
openxmlformats prefix, which is why those appear without their subtypes.
Text, csv and images stay out: the core renders them too, but RawLoader
is what gives them their player, and it only gets a turn when the core
loader says no.

The manifest filter and SupportedDocumentTypes follow, so the system
offers us pptx/ppt/xls and the folder browser lists them.

Two things fall out of it. The doOoxml flag is gone - it has been passed
true since ooxml stopped being experimental and now gates nothing. And
the ".doc-files are not real documents" special case becomes a check for
all three legacy types: the core does parse them now, but declares them
neither editable nor savable, so keeping one open only buys a second
parse.

pdf changes which viewer a failed upload falls back to, so that stays
explicit: google docs still gets the pdf, the microsoft viewer keeps
everything else the core knows.

Verified on device - .pptx, .ppt, .doc and .xls all render through the
core, with CoreTest covering each of them.

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

* Ask the core what can be edited instead of guessing by mime type

Codex was right on #546: doc, ppt and xls now reach CoreLoader, and
prepareActions offered Edit for every CORE result except ODS and PDF -
so the legacy formats got a button that could only ever end in a failed
save, because host() would not keep a document open for them.

The list of exceptions was never ours to keep. odrcore answers this
itself: Document.isEditable()/isSavable() is false for the three legacy
binary formats, for ooxml spreadsheets and presentations, and for every
spreadsheet including odf - the last being the core's own TODO, the same
gap as #442 that the UI was spelling out by mime prefix. host() now
keeps the document only when the core says yes, which makes "we have a
document" the answer, and it travels to the fragment on Result.

While in there, two more copies of the same idea:

- CoreLoader.MIME_PREFIXES was a second SupportedDocumentTypes. It is
  gone; the loader defers to the table. What stays separate is the
  manifest, which XML keeps out of reach - so SupportedFormatsTest walks
  odrcore's FileType values, asks Odr.mimetypeByFileType for each, and
  asserts the table and the package manager agree. Adding a format to
  one side and forgetting the other is now a red test.

  The core cannot replace the table itself: its lookups are native, and
  know one canonical mime per format - no templates, no macroEnabled, no
  x-vnd.oasis, which is exactly what providers hand out.

- toggleDarkMode's isDarkModeSupported was dead. Nothing in the body read
  it while DocumentFragment worked out that a pdf is not worth inverting.
  Renamed to disableDarkening(), which is what it does.

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

@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: b8becded88

ℹ️ 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/ui/activity/LandingViewModel.kt Outdated
andiwand and others added 10 commits July 28, 2026 21:46
Codex on #547, all three of them real:

- Adding a folder only pruned when the add evicted an older tree, but
  pruning is also what settles the pending grant takeRead() marked. A
  tree that never got settled stayed in the pending set, so removing it
  again in the same session found its grant spoken for and kept it alive
  until the next launch - against a per package cap. It prunes after
  every add now.

- A LandingFragment recreated underneath an open document - a dark mode
  toggle, a rotation - starts out believing it is on screen, and its
  view model comes back with it. One that was inside a folder therefore
  went on eating back presses behind the document. MainActivity tells it
  it is hidden when it restores a DocumentFragment, the same way it does
  when it opens one.

- SupportedDocumentTypes takes the ooxml family in two prefixes, so the
  templates, the slideshows and the macro enabled variants all count as
  supported - but an intent-filter matches a mime type exactly, and the
  manifest named only docx, xlsx and pptx. A .dotx therefore opened from
  the folder browser while the same file offered nothing from another
  app. The eleven variants are spelled out now.

  SupportedFormatsTest could not have caught that: it walks odrcore's
  FileType values, and the core names one canonical mime per format, so
  none of these ever appear in it. They get a case of their own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgJyGjRBpjAJ2724aMNnEp
Nothing here changes what the app does. It is the sweepings of the
fourteen commits before it, found by walking the screens in the order a
user meets them.

Dead:

- LandingViewModel.releaseUnusedGrants(), which nothing ever called.
- MainActivity.editActionMode, written and nulled but never read. The
  tts one stays: onPause has to stop it.
- DocumentTreeBrowser.Child.lastModified, along with the column it made
  every folder listing ask for.
- FolderTreesUtil.FolderTree.addedAt, persisted, parsed and never
  consulted - the order the file has them in is the order they were
  added.
- The nine strings the redesign orphaned - the welcome copy, the file
  manager chooser and the two toolbar actions - still translated into
  31 languages. The ones main was already carrying are left alone;
  they are not this branch's doing.

Shared:

- RecentDocumentsUtil and FolderTreesUtil were the same json file twice
  over: open it or treat a missing one as empty, walk the array, skip
  what does not parse, write the whole thing back. That half is
  JsonFileStore now and only the fields are left to each of them.

Read better:

- prepareActions built seven actions with seven add() calls; it is one
  listOfNotNull with the edit entry as the only conditional, so the
  order they unfold in fits on a screen.
- Its last line was pageView.disableDarkening(), which is a property of
  the page and not of the buttons over it. It moves to
  initializePageView, which every PageView passes through.
- SnackbarHelper.show ended in two bare booleans at all ten call sites.
  Named, they say which is which.
- loadUri looked the document fragment up twice around a second null
  check. Same lookup, same fallback, one expression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgJyGjRBpjAJ2724aMNnEp
Three things the redesign left half done, found by walking the screens
the way somebody using them does.

**The folder browser said nothing about where it was.** The name went to
`mainActivity.title`, which was written while the toolbar still existed;
the bar has been hidden since, and an action mode shows its own title,
so the only place it still surfaced was the recents switcher. Two levels
into a tree looked exactly like one level in, with an "Up one level" row
as the sole cue. The name is a header at the top of the list now, and
the title write is gone - LandingItem.Header takes the text rather than
a string resource, because this one is not a constant.

**A recent could be removed two ways, with opposite manners.** A swipe
asked nothing and offered an undo; a long press asked for confirmation
and offered no undo, then pruned the grant straight away where the swipe
left it for the next launch. Both are the same wish. Long press does
what the swipe does now, the dialog and its two strings are gone, and
the view model is down to one removeRecentDocument. The dialog on
*folders* stays - it is the only way to hand a tree back, and it is a
grant the user gave us rather than a row in a list.

**Every navigation re-proved the whole recent list.** refresh() was both
"reload everything" and "publish what changed", so entering a folder,
leaving it or flipping the catch-all switch each queried the provider
once per recent document - up to 32 binder round trips - on top of the
folder listing. Nothing the screen does to itself can revoke a grant,
unmount a card or delete a file; only being away can. So reload() checks
and refresh() does not, and only onResume and coming back from a
document reload.

Falls out of it: removeWithUndo took a position it never used, and the
back callback was enabled from two places against two different sources
- the live view model in one and the published state in the other. One
updateFolderBack(), off the view model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgJyGjRBpjAJ2724aMNnEp
theCatchAllSettingIsOfferedBeforeAnythingWasOpened has never passed on
CI. It asserts the switch is displayed straight after launch, and CI's
emulator is 320x480 mdpi - where the empty state alone is 392dp tall, so
the settings below it are not merely off screen but never bound at all,
and no matcher can find them. Hence NoMatchingViewException rather than
a failed isDisplayed.

Reproduced on a 320x480 mdpi avd: the same test, the same exception, the
same line. Scrolling the list first, the way somebody looking for a
setting would, all nine LandingTests pass there.

Scrolling is the answer rather than a smaller empty state: the switch
being a row of the list rather than a screen of its own is what put it
within reach in the first place, and no empty state worth having fits
above a settings section on a 383dp tall screen. espresso-contrib comes
in for RecyclerViewActions - androidTest only, so nothing ships.

Not fixed here, because it is not this: MainActivityTests.testPDF and
testODT fail intermittently on one api level per run, and have since
landing/04-direct-picker - the sibling branches show the same. The
hierarchy at the failure is a landing screen with an empty state and no
document container, i.e. the app is back at the start rather than
showing a document that failed to load. That matches the play services
crash testPDF already carries a comment about. Both pass locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgJyGjRBpjAJ2724aMNnEp
225 lines to 153, without dropping a rule.

Most of what went was said twice or derivable from the tree. "one
canonical mime per format" was the point of three separate paragraphs in
the supported-types section, and the manifest half of it explained
itself twice over. The native section explained conan once under "Native
Dependencies" and again under "Core Library Integration". The dependency
list named androidx, play services, espresso and junit, which
libs.versions.toml already does, and better.

The component inventories - one line per loader, per service class, per
ui class - are now the two paragraphs that say how a document actually
gets from a uri onto the screen, which is what somebody arriving at this
codebase needs and what a list of names does not give them.

What stays is the part a reader cannot work out by looking: why the
package names differ, why the jni jar and the .so come out of the same
conan package, why there is no storage permission, why editability is
the core's answer and not a list here. Those are shorter but none of
them are gone, and the ones phrased as prohibitions still are - they are
what the file is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgJyGjRBpjAJ2724aMNnEp
testPDF and testODT have been failing on one api level per run since
landing/04-direct-picker, and the emulator logcat says why.

Sixteen milliseconds before the click, Gboard activates over our window
with a 320x616 input view on a 320x640 screen. The click is dispatched,
the ime logs onDeactivate/onActivate - it is the one that got the touch
- and nothing else happens: no picker intent, not even the analytics
line findDocument reports before launching it. The tap landed on the
keyboard, which sits above our window, and espresso reported the click
as performed because as far as it is concerned it was.

Then the assertion fails somewhere else entirely. Nothing was queued, so
the idling resource never went busy, so the next onView did not wait: it
ran 267ms later against a landing screen that had never been asked to do
anything. In the run where the same test passed, that gap is 428ms and
there is no ime in the log at all.

The keyboard is left over from an earlier test - the password dialog
puts one up, and the ime is a system service that outlives both the
activity and the flavor's process, so it re-shows on the next window of
ours that takes focus. It only matters since the picker moved to the
landing screen: the toolbar action it replaced was at the top of the
screen, and both of the entry points that replaced it are in the lower
half, which is where a keyboard is.

So closeSoftKeyboard() leads every click that a keyboard can cover -
both landing entry points, the rows, and the password dialog's buttons,
which is the same failure a screen further on.

Verified on a 320x480 avd, which reproduced it: testPasswordProtectedODT
failed there before this and passes after, and the full suite now runs
clean twice over, pro then lite, the way CI does it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgJyGjRBpjAJ2724aMNnEp
Only the recent documents could be swiped. A folder could be removed
only by long pressing it - a gesture nothing on screen mentions - and
then confirming in a dialog. So of the two lists on the landing screen,
one had an obvious way out and the other had a hidden one.

Both are swipeable now, and both offer an undo instead of asking first.
The dialog was there because a tree is a permission the user granted
rather than a row in a list, and taking it back means going through the
system picker again. An undo answers that better than a confirmation
does: it costs nothing when the swipe was meant, and it is there when it
was not. The grant is not released on removal - the same bargain the
recent documents already make - so there is something to put back until
the next launch prunes it.

Restoring puts the folder at the index it came from rather than at the
end, so an undo leaves the list as it was. FolderTreesUtil grows an
insertFolderTree() beside addFolderTree(), both on the one put() that
dedupes and caps, and the cap still applies: a folder can have been
added while the undo was on offer.

What can be removed is now on the row. LandingItem.Folder carries
`granted`, the way Document carries `recent`, so a sub directory of a
tree is not swipeable and a long press on one does nothing - it is part
of the tree above it, not something the app was given. The adapter's
isRemovableDocument becomes isRemovable and answers for both.

Covered by three tests around a seeded tree: it is listed, it can be
removed, and the undo brings it back. They drive the long press rather
than the swipe, because the gesture belongs to ItemTouchHelper while
what both paths call is ours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgJyGjRBpjAJ2724aMNnEp
260 lines across 23 files, none of them reachable.

Six were still in values/strings.xml with nothing referring to them:
back_to_documents, discard_changes, error, save_now, unsaved_changes and
yes. The other fifteen had lost their default entry years ago and lived
on only in translations, which is why every build printed "removing
resource ... without required default value" for each of them.

Most are the onboarding: intro_title_open and its six companions belong
to the IntroActivity that was gated behind a "show_intro" remote config
flag in 2021 and removed as unpopular in 2023 (bf8e410), along with the
appintro dependency and four megabytes of onboard png. The rest went the
same way - the file manager chooser dialog, the video-ad and monthly ad
removal options, the print-unavailable and leave-fullscreen croutons,
the storage permission toast from before scoped storage, and the edit
help action.

I left these alone while clearing the ones the redesign itself orphaned,
on the grounds that they were not this branch's doing. They are not, but
they are nobody else's either, and every one of them is still handed to
translators.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgJyGjRBpjAJ2724aMNnEp
The fab was hidden whenever there was neither a recent document nor a
granted folder, because the empty state offers the same thing with a
label on it. That made the one button that is always in the same place
the one button that comes and goes.

openDocumentThroughPicker() matched either entry point with isDisplayed()
for exactly that reason, and would now find both on an empty list and
throw AmbiguousViewMatcherException, so it takes the fab alone.
theFabOpensTheSystemPicker no longer seeds a document, which leaves it
tapping the fab over the empty state - the case this changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gkBduJr5WoJ6dPZ6Y3CtS
Nothing was clipping them: the column sits its 16dp above the padding
MainActivity puts on for the system bars, shadow included. But 16dp is
all that separates it from the navigation bar right below, so the lower
button reads as running off the edge of the screen. 32dp under it, and
the rows above follow the buttons up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gkBduJr5WoJ6dPZ6Y3CtS
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