Skip to content

Commit 5f5a53a

Browse files
authored
fix(android): reopen the startup project after the app process restarts (#1948) (#1949)
* 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/default.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@
2424
"identifier": "fs:allow-remove",
2525
"allow": [{ "path": "$TEMP/geolibre-gdb-*.geojson" }]
2626
},
27+
{
28+
"comment": "Restorable copies of the startup project (GeoLibre#1948). On Android a project picked from device storage is a content:// URI whose read grant dies with the process, so the startup restore has nothing to reopen; GeoLibre keeps its own copy here instead. Only a scope entry is needed, no new command permission: `mkdir` is already granted by `fs:default` (its `create-app-specific-dirs` set), and `read_text_file`/`write_text_file` by the entries above. It must be `fs:scope` rather than a path on those entries because 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` (the app's other writes are to paths a dialog just added to the runtime scope) — without this the copies fail with \"forbidden path\". Two entries because the directory itself is the mkdir target and its files are the read/write targets. See lib/startup-project-snapshot.ts.",
29+
"identifier": "fs:scope",
30+
"allow": [
31+
{ "path": "$APPLOCALDATA/startup-projects" },
32+
{ "path": "$APPLOCALDATA/startup-projects/*" }
33+
]
34+
},
2735
{
2836
"identifier": "opener:allow-open-url",
2937
"allow": [{ "url": "http://*" }, { "url": "https://*" }]

apps/geolibre-desktop/src/components/layout/SettingsDialog.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ import { THEME_SCHEMES, normalizeHexColor, type ThemeScheme } from "../../lib/th
9696
import { IS_MAS_BUILD } from "../../lib/build-flags";
9797
import { resolveShareHost, shareHostLabel } from "../../lib/share-geolibre";
9898
import { IS_STORE_BUILD, type UpdateNotificationLevel } from "../../lib/updates";
99-
import { openProjectFile } from "../../lib/tauri-io";
99+
import { ensureStartupProjectSnapshot, openProjectFile } from "../../lib/tauri-io";
100100
import {
101101
DATA_SOURCE_CATALOG,
102102
DATA_SOURCE_SECTION_LABEL_KEYS,
@@ -1189,6 +1189,13 @@ export function SettingsDialog({
11891189
updates: draftDesktopSettings.updates,
11901190
startup: draftDesktopSettings.startup,
11911191
});
1192+
// On Android the project behind the preference just saved is reachable only
1193+
// until this process ends, so keep a copy the next launch can open
1194+
// (GeoLibre#1948). A no-op on every other platform.
1195+
void ensureStartupProjectSnapshot(
1196+
draftDesktopSettings.startup,
1197+
useAppStore.getState().recentProjects,
1198+
);
11921199
// The dockable panels are the one layout row nothing renders from the store:
11931200
// the registry owns what is on screen, so move it to match what was just
11941201
// saved (a no-op for a panel already there, so an untouched Save cannot

apps/geolibre-desktop/src/hooks/useProjectFileActions.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ import {
3333
RecentProjectGoneError,
3434
saveProjectFile,
3535
saveProjectFileToPath,
36+
saveStartupProjectSnapshot,
3637
saveTextFileWithFallback,
3738
} from "../lib/tauri-io";
39+
import { useDesktopSettingsStore } from "./useDesktopSettings";
3840
import { buildProjectHtml } from "../lib/html-export";
3941
import { ensureHtmlFileName, ensureProjectFileName } from "../lib/file-names";
4042
import { mergeStringLists } from "../lib/string-lists";
@@ -51,6 +53,7 @@ import {
5153
saveChoicesForProject,
5254
type ProjectSaveChoices,
5355
} from "../lib/project-save-choices";
56+
import { startupSettingsAfterForcedSaveAs } from "../lib/startup-project";
5457
import { resolveProjectXyzLayers } from "../lib/xyz-url";
5558
import {
5659
importQgisProject,
@@ -338,13 +341,27 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
338341
settleSaveNamePrompt,
339342
]);
340343

344+
// On Android a project lives behind a `content://` URI whose read grant dies
345+
// with the process, so the startup restore has nothing to reopen on the next
346+
// launch (GeoLibre#1948). Keep a copy in the app's own storage whenever the
347+
// startup preference points at the project being opened or saved. Fire and
348+
// forget: a failed copy is logged inside and must not fail the open or save.
349+
const rememberStartupProjectSnapshot = (path: string, text: string) => {
350+
void saveStartupProjectSnapshot(
351+
path,
352+
text,
353+
useDesktopSettingsStore.getState().desktopSettings.startup,
354+
);
355+
};
356+
341357
const handleOpenFromFile = async () => {
342358
const result = await openProjectFile();
343359
if (result) {
344360
try {
345361
loadProject(await resolveProjectXyzLayers(result.project), result.path, {
346362
rememberRecent: isTauri(),
347363
});
364+
rememberStartupProjectSnapshot(result.path, result.text);
348365
} catch (error) {
349366
console.error("Failed to open project", error);
350367
setActionError(
@@ -685,6 +702,13 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
685702
const project = await resolveProjectXyzLayers(result.project, controller.signal);
686703
if (controller.signal.aborted) return null;
687704
loadProject(project, result.path);
705+
// `loadProject` moves this path to the front of the recent list, so in
706+
// "last" mode it is now the project the next launch will reopen. Without
707+
// this, reopening an older project from the recent list would leave the
708+
// copy on disk holding whichever project was opened through the picker
709+
// last, and the next cold start would find no copy matching the path it
710+
// resolves (GeoLibre#1948 review).
711+
rememberStartupProjectSnapshot(result.path, result.text);
688712
return null;
689713
} catch (error) {
690714
if (controller.signal.aborted) return null;
@@ -1107,6 +1131,23 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
11071131
name: project.name,
11081132
openedAt: new Date().toISOString(),
11091133
});
1134+
// An ordinary Save that landed somewhere else is Android refusing to write
1135+
// the picked document and the save dialog creating a new one in its place
1136+
// (GeoLibre#1833). Move a startup preference pinned to the old document
1137+
// across, or it keeps naming one nothing can open again. Before the copy
1138+
// below, so that copy lands in the slot the moved preference resolves to.
1139+
const startupSettings = useDesktopSettingsStore.getState().desktopSettings;
1140+
const movedStartup = options?.saveAs
1141+
? null
1142+
: startupSettingsAfterForcedSaveAs(startupSettings.startup, existingLocalPath, path);
1143+
if (movedStartup) {
1144+
useDesktopSettingsStore
1145+
.getState()
1146+
.setDesktopSettings({ ...startupSettings, startup: movedStartup });
1147+
}
1148+
// Refresh the restorable copy so a startup restore reopens what was just
1149+
// saved rather than the state the project was opened in.
1150+
rememberStartupProjectSnapshot(path, contentToSave);
11101151
markSaved();
11111152
recordExplicitProjectSave();
11121153
return true;

0 commit comments

Comments
 (0)