Commit 5f5a53a
authored
* fix(android): reopen the startup project after the app process restarts (#1948)
On Android, both startup modes fell back to the default workspace on every cold
start and showed "The startup project is unavailable."
Two things were in the way. `openRecentProjectFile` sends the stored path to the
`read_project_file` Tauri command, whose guard requires a filesystem path, so a
`content://` URI was refused before anything was read. Routing content URIs
through `tauri-plugin-fs` instead (it resolves them via Android's
ContentResolver) gets past that, but only until the process ends:
`tauri-plugin-dialog`'s `open()` launches `ACTION_GET_CONTENT`, whose read grant
is tied to the activity that received it and cannot be renewed from inside the
app - a persistable grant needs `ACTION_OPEN_DOCUMENT` plus
`takePersistableUriPermission`, neither of which the plugin issues. The startup
restore runs exactly once per cold start, which is exactly when that grant is
gone, so the feature could never work there. This is the read-side half of the
problem #1833 fixed for saving.
So GeoLibre keeps its own copy. `lib/startup-project-snapshot.ts` writes the
project text into the app's private data directory whenever the startup
preference points at the project being opened or saved, and
`openRecentProjectFile` falls back to that copy when the original URI can no
longer be read. Two fixed slots ("specific" and "last") rather than one file per
project: the preference can only ever restore two projects, and a fixed pair
needs no pruning - which would need an `fs:allow-remove` scope the app
deliberately does not grant outside its own temp files.
Committing the preference in Settings also copies the project right then, via
`ensureStartupProjectSnapshot`. That is the path the report describes - open a
project, then ask for it back on the next launch - and nothing else re-reads the
project in between, so without it the preference would be saved with no copy
behind it.
Everything is gated on the path being a content URI, so desktop keeps re-reading
the real file and never doubles a project on disk. A copy over 25 MB is skipped
(the ceiling already applied to a project fetched by URL) rather than duplicating
hundreds of megabytes of embedded vector data on a phone. The source path must
match exactly, so a copy can never stand in for a different project, and a
`RecentProjectGoneError` still means gone - a deleted file is not resurrected
from a stale copy. Every failure is logged and swallowed: the copy is a fallback
for a later launch and must never fail the open or save that triggered it.
The `fs:scope` entry in the capability is required, not incidental. An fs
permission's scope applies only to the commands that permission grants, so
`fs:default`'s app-directory scope reaches `read_text_file` but not `mkdir` or
`write_text_file`; without the entry every copy fails with "forbidden path"
(caught on the emulator, not by inspection).
Verified on an Android 16 emulator with the reporter's own steps and a project at
the path from the report, /sdcard/Documents/json/General_Project.geolibre.json:
open it through the document picker, set the startup preference, force-stop, cold
start. Before the change the log shows "Could not restore the startup project ...
requires that you obtain access using ACTION_OPEN_DOCUMENT"; after it, both
"Reopen the last project" and "Open a specific project" come back with the
project, its layer, and its camera, no banner, and the log shows the fallback
taking over from the dead URI.
Fixes #1948
* Address review feedback
- Refresh the restorable copy when a project is reopened from Open Recent.
`loadProject` moves that path to the front of the recent list, so in "last"
mode it becomes the project the next launch resolves to -- but the copy on
disk still held whichever project was opened through the picker last, so its
`sourcePath` no longer matched and the cold start fell back to the
unavailable-project banner. Open A, open B, reopen A from the recent list was
enough to hit it. `openRecentProjectFile` now returns the raw text alongside
the parsed project so the caller can keep the copy in step without re-reading
a URI whose grant may already be gone.
- Measure the snapshot size limit in UTF-8 bytes rather than `text.length`.
The string is written as UTF-8, so a project of three-byte characters (CJK
layer names, accented attribute values in embedded GeoJSON) could be three
times the limit and still pass. `exceedsStartupSnapshotLimit` bounds the byte
count by the code-unit count first and only encodes when that is inconclusive,
so the oversized projects the guard exists to reject are still rejected
without allocating a second copy of them on a phone. Covered by a boundary
test over ASCII, three-byte, and surrogate-pair text.
- State in the capability comment that `mkdir` is already granted by
`fs:default` (its `create-app-specific-dirs` set), so the missing
`fs:allow-mkdir` next to the other explicit command permissions is deliberate
rather than an oversight, and that without the scope entry the copies fail
with "forbidden path".
- Drop the claim that `MAX_STARTUP_SNAPSHOT_BYTES` "matches" the ceiling on a
project fetched by URL. The two share a number today but bound different
things -- a download buffered into memory versus a file kept on disk -- and
neither has to move when the other does, so the wording now says that instead
of implying an invariant nothing enforces.
* Address CodeRabbit review feedback
- Serialize snapshot writes per slot. Callers fire these off without awaiting
them, alongside opening or saving a project, so two projects can race for the
same slot and whichever write landed last would win it. The recent list is
updated synchronously as each project opens, so a slow first write finishing
last would leave the slot holding a project the preference no longer resolves
to, and the next cold start would find no copy matching the path it asks for.
Chaining per slot makes the last write started the one that wins, which is the
one the preference agrees with. The stored link swallows rejections so one
failed copy cannot strand every later one behind it, while the caller still
sees the real result. Covered by a test where the first write is delayed past
the second.
* Address Claude review feedback
- Consult the stored copy before classifying a failed read as "the project is
gone". On a real filesystem "no such file" means exactly that and the recent
entry can be dropped; on a dead Android SAF grant it means nothing reliable,
because providers differ in how they report one - the emulator's
ExternalStorageProvider raises a SecurityException, but Drive, Downloads and
some OEM file managers are known to report a revoked URI as a
FileNotFoundException. Reaching `RecentProjectGoneError` on that makes
`useStartupProject` forget the entry and reset a "specific" preference to the
default, so a provider whose wording happened to match the missing-file regex
would silently wipe the user's chosen startup project on exactly the failure
the copy exists to survive. A copy for that exact path now wins; the "gone"
classification is only reached when there is nothing to restore, so a genuinely
deleted desktop project still drops out of the recent list as before.
- Break the tie when both slots hold the same project by taking the newest
`savedAt` rather than whichever slot is looked at first. Running in "specific"
mode on a project and later switching to "last" leaves a copy in each slot and
only refreshes the active one, so the fixed iteration order could return the
older copy while a fresher one sat on disk.
- Use one write queue for both slots instead of one each. The two slots share an
index and each write reads it whole and stores it back after its own file
write, so a "last" copy and a "specific" copy in flight together could both
read the index before either stored it, and the one finishing last would drop
the other's entry - leaving a good copy on disk that nothing points at. Copies
are small and rare enough that serializing them costs nothing. Covered by a
test with the two slots' writes overlapping.
* Address CodeRabbit review feedback
- Try an older copy when the newest one's file has gone missing. Picking the
newest matching entry and giving up if its read fails could report the project
as unavailable while a readable copy of the same project sat in the other
slot. The matches are already sorted, so this is just reading down the list
until one succeeds. Covered by a test where only the older slot's file
survives.
* Address Claude review feedback
- Move a pinned startup project to the document an ordinary Save landed on.
Confirmed on the emulator: saving a project opened through the document picker
is refused in place and falls back to the save dialog, and the document that
dialog creates has a different URI - saving over
`General_Project.geolibre.json` yields one ending
`General_Project.geolibre.json (1)`. A "specific" preference pinned to the
original therefore stopped matching after the very first save, so its copy was
never refreshed again and every later launch restored the project as it looked
when it was pinned, from a URI nothing could open. `startupSettingsAfterForcedSaveAs`
follows the preference across, before the copy is written so that copy lands in
the slot the moved preference resolves to.
Narrow on purpose: it applies only when a plain Save changed the path by
itself, which is the signature of that forced fallback. An explicit Save As is
the user deliberately writing a different file and must not silently re-point
a preference at it, and on desktop a plain Save never changes the path, so this
never fires there.
- Document the two Android consequences in the user guide: a project deleted
from the device still reopens from GeoLibre's copy (Android reports a deleted
file and an expired reference the same way, and treating it as deleted would
wipe the user's startup preference), and saving a project opened from device
storage asks where to save it once, with the preference following it there.
Verified on an Android 16 emulator: pin a project as the startup project, open
it, Save (the dialog appears and creates the "(1)" document), and the preference
and its copy both move to it; force-stop and cold start reopens the saved
project with no banner. Also confirmed Save after a snapshot-based restore still
falls back to the save dialog rather than surfacing a raw error - the expired
grant reports "Permission Denial", which `isUriWritePermissionError` matches.
1 parent 2b1025e commit 5f5a53a
10 files changed
Lines changed: 1063 additions & 8 deletions
File tree
- apps/geolibre-desktop
- src-tauri/capabilities
- src
- components/layout
- hooks
- lib
- docs/user-guide
- tests
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
27 | 35 | | |
28 | 36 | | |
29 | 37 | | |
| |||
Lines changed: 8 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
96 | 96 | | |
97 | 97 | | |
98 | 98 | | |
99 | | - | |
| 99 | + | |
100 | 100 | | |
101 | 101 | | |
102 | 102 | | |
| |||
1189 | 1189 | | |
1190 | 1190 | | |
1191 | 1191 | | |
| 1192 | + | |
| 1193 | + | |
| 1194 | + | |
| 1195 | + | |
| 1196 | + | |
| 1197 | + | |
| 1198 | + | |
1192 | 1199 | | |
1193 | 1200 | | |
1194 | 1201 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
| 36 | + | |
36 | 37 | | |
37 | 38 | | |
| 39 | + | |
38 | 40 | | |
39 | 41 | | |
40 | 42 | | |
| |||
51 | 53 | | |
52 | 54 | | |
53 | 55 | | |
| 56 | + | |
54 | 57 | | |
55 | 58 | | |
56 | 59 | | |
| |||
338 | 341 | | |
339 | 342 | | |
340 | 343 | | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
341 | 357 | | |
342 | 358 | | |
343 | 359 | | |
344 | 360 | | |
345 | 361 | | |
346 | 362 | | |
347 | 363 | | |
| 364 | + | |
348 | 365 | | |
349 | 366 | | |
350 | 367 | | |
| |||
685 | 702 | | |
686 | 703 | | |
687 | 704 | | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
688 | 712 | | |
689 | 713 | | |
690 | 714 | | |
| |||
1107 | 1131 | | |
1108 | 1132 | | |
1109 | 1133 | | |
| 1134 | + | |
| 1135 | + | |
| 1136 | + | |
| 1137 | + | |
| 1138 | + | |
| 1139 | + | |
| 1140 | + | |
| 1141 | + | |
| 1142 | + | |
| 1143 | + | |
| 1144 | + | |
| 1145 | + | |
| 1146 | + | |
| 1147 | + | |
| 1148 | + | |
| 1149 | + | |
| 1150 | + | |
1110 | 1151 | | |
1111 | 1152 | | |
1112 | 1153 | | |
| |||
0 commit comments