Skip to content

Commit b336e0c

Browse files
committed
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 d610a3b commit b336e0c

4 files changed

Lines changed: 122 additions & 2 deletions

File tree

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
saveChoicesForProject,
5454
type ProjectSaveChoices,
5555
} from "../lib/project-save-choices";
56+
import { startupSettingsAfterForcedSaveAs } from "../lib/startup-project";
5657
import { resolveProjectXyzLayers } from "../lib/xyz-url";
5758
import {
5859
importQgisProject,
@@ -1130,6 +1131,20 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
11301131
name: project.name,
11311132
openedAt: new Date().toISOString(),
11321133
});
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+
}
11331148
// Refresh the restorable copy so a startup restore reopens what was just
11341149
// saved rather than the state the project was opened in.
11351150
rememberStartupProjectSnapshot(path, contentToSave);

apps/geolibre-desktop/src/lib/startup-project.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,39 @@ export function startupProjectPath(
3838
// plus the failure banner on any launch where that host is unreachable.
3939
return recentProjects.find((entry) => !isRemotePath(entry.path))?.path ?? null;
4040
}
41+
42+
/**
43+
* Follow a pinned startup project to the new path an ordinary Save landed on,
44+
* or null when the preference should stay as it is.
45+
*
46+
* This only ever happens on Android. A project opened through the document
47+
* picker carries a read-only `content://` grant, so the first in-place Save is
48+
* refused and falls back to the save dialog (GeoLibre#1833), and the document
49+
* the dialog *creates* has a different URI -- on the emulator, saving over
50+
* `General_Project.geolibre.json` yields a URI ending
51+
* `General_Project.geolibre.json (1)`. The user asked to save the project they
52+
* pinned, so the preference has to follow it there; left pinned to the old URI
53+
* it would name a document nothing can open again, and restore the copy taken
54+
* when it was pinned for as long as the preference lasts.
55+
*
56+
* Narrow on purpose. It applies only when a plain Save changed the path by
57+
* itself, which is the signature of that forced fallback: an explicit Save As is
58+
* the user deliberately writing a *different* file, and must not silently
59+
* re-point a preference at it. On desktop a plain Save never changes the path,
60+
* so this never fires there.
61+
*
62+
* @param settings - The current startup preference.
63+
* @param previousPath - The path the project was saved from, if any.
64+
* @param newPath - The path the save actually landed on.
65+
* @returns The updated preference, or null to leave it unchanged.
66+
*/
67+
export function startupSettingsAfterForcedSaveAs(
68+
settings: StartupSettings,
69+
previousPath: string | null,
70+
newPath: string,
71+
): StartupSettings | null {
72+
if (settings.mode !== "specific") return null;
73+
if (!previousPath || previousPath === newPath) return null;
74+
if (settings.projectPath !== previousPath) return null;
75+
return { ...settings, projectPath: newPath };
76+
}

docs/user-guide/settings.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ Two deliberate limits are worth knowing:
8989
- **A URL always wins.** Launching with a project or `?data=` parameter in the URL skips the startup restore entirely, and so does opening your own project before the restore finishes.
9090

9191
!!! note "Android reopens its own copy"
92-
Android identifies a project picked from device storage by a temporary reference that stops working once the app's process ends — which is exactly when the startup restore runs. So on Android GeoLibre keeps a copy of the startup project in its own private storage and reopens that copy, refreshing it every time you open or save the project. Practically this means a project edited in another app after you last saved it in GeoLibre reopens as GeoLibre last saw it; open it again from **File → Open** to pick the newer contents back up.
92+
Android identifies a project picked from device storage by a temporary reference that stops working once the app's process ends — which is exactly when the startup restore runs. So on Android GeoLibre keeps a copy of the startup project in its own private storage and reopens that copy, refreshing it every time you open or save the project. Two consequences worth knowing: a project edited in another app after you last saved it in GeoLibre reopens as GeoLibre last saw it (open it again from **File → Open** to pick the newer contents back up), and a project deleted from the device still reopens from GeoLibre's copy rather than dropping out of the startup preference, because Android reports a deleted file and an expired reference the same way.
93+
94+
Saving a project you opened from device storage asks you where to save it, once — Android does not grant write access to a file you only picked to read. If your startup project is that project, the preference follows it to the file that save creates, so it keeps opening the copy you are actually working in.
9395

9496
## Updates
9597

tests/startup-project-settings.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import {
44
DEFAULT_STARTUP_SETTINGS,
55
normalizeDesktopSettings,
66
} from "../apps/geolibre-desktop/src/hooks/useDesktopSettings";
7-
import { startupProjectPath } from "../apps/geolibre-desktop/src/lib/startup-project";
7+
import {
8+
startupProjectPath,
9+
startupSettingsAfterForcedSaveAs,
10+
} from "../apps/geolibre-desktop/src/lib/startup-project";
811

912
describe("startup project settings", () => {
1013
it("defaults to the normal untitled workspace", () => {
@@ -118,3 +121,67 @@ describe("startupProjectPath", () => {
118121
);
119122
});
120123
});
124+
125+
describe("startupSettingsAfterForcedSaveAs", () => {
126+
// Saving a project opened through the Android document picker is refused in
127+
// place and falls back to the save dialog, which creates a new document: on
128+
// the emulator, saving over General_Project.geolibre.json lands on a URI
129+
// ending "General_Project.geolibre.json (1)".
130+
const PICKED =
131+
"content://com.android.externalstorage.documents/document/primary%3AGeneral.geolibre.json";
132+
const CREATED = `${PICKED}%20(1)`;
133+
134+
it("follows a pinned project to the document the save actually created", () => {
135+
assert.deepEqual(
136+
startupSettingsAfterForcedSaveAs(
137+
{ mode: "specific", projectPath: PICKED, projectName: "General" },
138+
PICKED,
139+
CREATED,
140+
),
141+
{ mode: "specific", projectPath: CREATED, projectName: "General" },
142+
);
143+
});
144+
145+
it("leaves a preference pinned to some other project alone", () => {
146+
assert.equal(
147+
startupSettingsAfterForcedSaveAs(
148+
{ mode: "specific", projectPath: "/tmp/pinned.geolibre.json", projectName: "Pinned" },
149+
PICKED,
150+
CREATED,
151+
),
152+
null,
153+
);
154+
});
155+
156+
it("does nothing for the modes that resolve a path of their own", () => {
157+
assert.equal(
158+
startupSettingsAfterForcedSaveAs(
159+
{ mode: "last", projectPath: null, projectName: null },
160+
PICKED,
161+
CREATED,
162+
),
163+
null,
164+
);
165+
assert.equal(startupSettingsAfterForcedSaveAs(DEFAULT_STARTUP_SETTINGS, PICKED, CREATED), null);
166+
});
167+
168+
it("does nothing when the save stayed where it was, or had nowhere to start", () => {
169+
// The desktop case: a plain Save writes the file it opened, every time.
170+
assert.equal(
171+
startupSettingsAfterForcedSaveAs(
172+
{ mode: "specific", projectPath: PICKED, projectName: "General" },
173+
PICKED,
174+
PICKED,
175+
),
176+
null,
177+
);
178+
assert.equal(
179+
startupSettingsAfterForcedSaveAs(
180+
{ mode: "specific", projectPath: PICKED, projectName: "General" },
181+
null,
182+
CREATED,
183+
),
184+
null,
185+
);
186+
});
187+
});

0 commit comments

Comments
 (0)