diff --git a/backend/mobile_api.go b/backend/mobile_api.go index 519cfe6..9960fd5 100644 --- a/backend/mobile_api.go +++ b/backend/mobile_api.go @@ -274,9 +274,25 @@ func inSandbox(p string) (string, error) { globalMu.Lock() roots = append(roots, externalRoots...) globalMu.Unlock() + // Configured folder paths count as roots too: the daemon already syncs + // into them, so JS file ops (photo backup) may target them. Covers + // All-Files-Access folders (e.g. DCIM) that live outside foldersRoot and + // aren't registered as externalRoots on Android. + roots = append(roots, currentFolderPaths()...) return inSandboxAtRoots(roots, p) } +// currentFolderPaths returns the POSIX Path of every configured folder that +// isn't a content:// SAF tree URI. +func currentFolderPaths() []string { + globalMu.Lock() + defer globalMu.Unlock() + if globalClient == nil { + return nil + } + return globalClient.folderFSPaths() +} + // inSandboxAt is the single-root convenience for tests. func inSandboxAt(dataDir, p string) (string, error) { return inSandboxAtRoots([]string{dataDir}, p) @@ -437,6 +453,12 @@ func (m *MobileAPI) ResolvePath(path string) string { // any readable path (needed for photo backup where the source is a // system-managed media path outside the sandbox). func (m *MobileAPI) CopyFile(src, dst string) string { + // SAF-backed folders have a content:// tree URI as their root, not a POSIX + // path, so os.Create can't write into them. Route those through the SAF + // bridge instead. (Photo backup into a SAF folder hits this path.) + if strings.HasPrefix(dst, "content://") { + return copyFileSAF(src, dst) + } absDst, err := inSandbox(dst) if err != nil { return marshalErr(err) @@ -462,6 +484,55 @@ func (m *MobileAPI) CopyFile(src, dst string) string { return string(b) } +// copyFileSAF copies src (a readable POSIX path) into a content:// destination +// inside a SAF-backed folder. dst is resolved to the owning folder's tree URI +// plus a relative path, then written via the SAF filesystem (CreateFile/OpenFd +// over JNI). src stays a plain os.Open since it's a system media path. +func copyFileSAF(src, dst string) string { + globalMu.Lock() + client := globalClient + bridge := globalSAFBridge + globalMu.Unlock() + if client == nil { + return marshalErr(errors.New("daemon not started")) + } + if bridge == nil { + return marshalErr(errors.New("SAF bridge not registered")) + } + treeURI, rel, ok := client.safTreeForPath(dst) + if !ok { + return marshalErr(errors.New("path outside sandbox")) + } + sfs, err := newSAFFilesystem(treeURI) + if err != nil { + return marshalErr(err) + } + if dir := filepath.Dir(rel); dir != "." && dir != "" { + if err := sfs.MkdirAll(dir, 0o755); err != nil { + return marshalErr(err) + } + } + in, err := os.Open(src) + if err != nil { + return marshalErr(err) + } + defer in.Close() + out, err := sfs.Create(rel) + if err != nil { + return marshalErr(err) + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + _ = sfs.Remove(rel) + return marshalErr(err) + } + if err := out.Close(); err != nil { + return marshalErr(err) + } + b, _ := json.Marshal(fsResultJSON{Path: dst}) + return string(b) +} + // RemoveDir recursively deletes a sandboxed directory. Refuses the sandbox // roots themselves so we can't wipe the whole folders dir. func (m *MobileAPI) RemoveDir(path string) string { diff --git a/backend/wrapper.go b/backend/wrapper.go index cc3e3fc..46a2fc2 100644 --- a/backend/wrapper.go +++ b/backend/wrapper.go @@ -361,6 +361,55 @@ func (c *Client) FoldersRoot() string { return filepath.Join(c.dataDir, "folders") } +// folderFSPaths returns the POSIX Path of every configured folder that uses a +// real filesystem path (not a content:// SAF tree URI). CopyFile treats these +// as extra sandbox roots so photo backup can write into a folder the daemon +// already syncs — e.g. an All-Files-Access DCIM folder outside foldersRoot. +func (c *Client) folderFSPaths() []string { + c.mu.Lock() + cfg := c.config + c.mu.Unlock() + if cfg == nil { + return nil + } + var paths []string + for _, f := range cfg.Folders() { + if f.Path == "" || strings.HasPrefix(f.Path, "content://") { + continue + } + paths = append(paths, f.Path) + } + return paths +} + +// safTreeForPath resolves a content:// destination to the tree URI of the SAF +// folder that contains it plus the path relative to that folder's root. ok is +// false when no configured SAF folder is a parent of p. Used by CopyFile so +// photo backup can write into SAF-backed folders, whose Path is an opaque +// content:// tree URI rather than a POSIX path. Unexported so gomobile doesn't +// try to bind its three-value return (bind allows at most one value + error). +func (c *Client) safTreeForPath(p string) (treeURI, rel string, ok bool) { + c.mu.Lock() + cfg := c.config + c.mu.Unlock() + if cfg == nil { + return "", "", false + } + for _, f := range cfg.Folders() { + if f.FilesystemType != config.FilesystemType(FilesystemTypeSAF) { + continue + } + root := f.Path + if p == root { + return root, "", true + } + if strings.HasPrefix(p, root+"/") { + return root, strings.TrimPrefix(p, root+"/"), true + } + } + return "", "", false +} + // SetFoldersRoot updates the base dir + sandbox allow-list. Existing folder // configs keep their stored paths; migration happens in Load. func (c *Client) SetFoldersRoot(path string) error { diff --git a/mobile-app/src/services/PhotoBackup.ts b/mobile-app/src/services/PhotoBackup.ts index e3b76cf..12a540a 100644 --- a/mobile-app/src/services/PhotoBackup.ts +++ b/mobile-app/src/services/PhotoBackup.ts @@ -60,6 +60,78 @@ async function saveBackedUpIds(ids: Set): Promise { await AsyncStorage.setItem(BACKED_UP_KEY, JSON.stringify(Array.from(ids))); } +function stripFileScheme(uri: string): string { + return uri.replace(/^file:\/\//, ''); +} + +// canonicalizePath normalizes an Android filesystem path so two paths that point +// at the same location compare equal in the overlap guard. Without this the +// guard misses nested assets when the two sides disagree on surface form: +// - /sdcard is a symlink to /storage/emulated/0, so the gallery may report +// /sdcard/DCIM/Archive/VID.mp4 while the backup folder is stored as +// /storage/emulated/0/DCIM -> startsWith() fails and the asset is copied. +// - trailing slashes and doubled slashes (file:// joins) also break startsWith. +function canonicalizePath(p: string): string { + let out = stripFileScheme(p); + // /sdcard and /storage/self/primary are both the primary external volume. + out = out.replace(/^\/sdcard(?=\/|$)/, '/storage/emulated/0'); + out = out.replace(/^\/storage\/self\/primary(?=\/|$)/, '/storage/emulated/0'); + // collapse duplicate slashes, then drop any trailing slash + out = out.replace(/\/{2,}/g, '/').replace(/\/+$/, ''); + return out; +} + +// safTreeToFsPath decodes an Android ExternalStorageProvider tree URI into the +// POSIX path it maps to, so we can tell when a source asset already lives +// inside the chosen backup folder (e.g. picking DCIM as the target): +// content://com.android.externalstorage.documents/tree/primary%3ADCIM +// -> /storage/emulated/0/DCIM +// Returns null for any other authority/scheme where there's no stable mapping +// (the SD-card UUID form is handled; opaque providers fall through to null and +// the overlap guard is simply skipped). +function safTreeToFsPath(folderPath: string): string | null { + const prefix = 'content://com.android.externalstorage.documents/tree/'; + if (!folderPath.startsWith(prefix)) return null; + let enc = folderPath.slice(prefix.length); + // Tree URIs may carry a trailing /document/; the tree root is the + // part before it. + const docIdx = enc.indexOf('/document/'); + if (docIdx >= 0) enc = enc.slice(0, docIdx); + let docId: string; + try { + docId = decodeURIComponent(enc); + } catch { + return null; + } + const colon = docId.indexOf(':'); + if (colon < 0) return null; + const vol = docId.slice(0, colon); + const rel = docId.slice(colon + 1); + const base = vol === 'primary' ? '/storage/emulated/0' : `/storage/${vol}`; + return rel ? `${base}/${rel}` : base; +} + +// resolveTargetRealPath returns the POSIX path of the backup folder so we can +// detect source assets that already live inside it. Handles both folder kinds: +// a content:// SAF tree URI (decoded) and a plain filesystem path used by +// All-Files-Access folders like /storage/emulated/0/DCIM. Returns null only +// when a content:// URI can't be mapped to a stable path. +function resolveTargetRealPath(folderPath: string): string | null { + const raw = folderPath.startsWith('content://') + ? safTreeToFsPath(folderPath) + : stripFileScheme(folderPath); + return raw == null ? null : canonicalizePath(raw); +} + +// isUnder reports whether path lies inside root (or equals it). Both sides are +// canonicalized first so symlink aliases (/sdcard) and slash noise don't cause +// a real overlap to slip through. +function isUnder(path: string, root: string): boolean { + const p = canonicalizePath(path); + const r = canonicalizePath(root); + return p === r || p.startsWith(r + '/'); +} + function destPath( asset: MediaLibrary.Asset, structure: FolderStructure, @@ -170,12 +242,28 @@ export async function runBackup( const plainPath = folderPath.replace(/^file:\/\//, ''); const folderUri = `file://${plainPath}`; + // When the backup target overlaps the media source (e.g. the user picks + // DCIM or Pictures), copying gallery assets back into it would just create + // duplicates. Resolve the target to a real path so we can skip any asset + // that already lives inside it. null = not an external-storage folder, so + // no overlap is possible and the guard is a no-op. + const targetRealPath = resolveTargetRealPath(folderPath); + for (const asset of toBackUp) { if (signal?.cancelled) break; try { const info = await MediaLibrary.getAssetInfoAsync(asset); + const srcLocalPath = info.localUri ? stripFileScheme(info.localUri) : null; + if (targetRealPath && srcLocalPath && isUnder(srcLocalPath, targetRealPath)) { + backedUp.add(asset.id); + progress.skipped++; + progress.lastSkipReason = `"${asset.filename}" already in backup folder`; + onProgress({ ...progress }); + continue; + } + // Build a list of candidate source URIs. localUri is preferred // (it's a file:// path for on-device assets). asset.uri is the // fallback and may be a content:// (Android) or ph:// (iOS) URI. @@ -218,6 +306,27 @@ export async function runBackup( let copied = false; let lastErr = ''; + // Final overlap check against the actual file we're about to copy. The + // early guard uses info.localUri, but the real source can differ (e.g. + // expo falls back to asset.uri); skipping here guarantees we never copy a + // file that already lives inside the backup folder, regardless of which + // candidate URI ends up being the source. + let overlap = false; + for (const sourceUri of candidates) { + if (!sourceUri.startsWith('file://')) continue; + if (targetRealPath && isUnder(stripFileScheme(sourceUri), targetRealPath)) { + overlap = true; + break; + } + } + if (overlap) { + backedUp.add(asset.id); + progress.skipped++; + progress.lastSkipReason = `"${asset.filename}" already in backup folder`; + onProgress({ ...progress }); + continue; + } + for (const sourceUri of candidates) { if (copied) break; // strip file:// prefix for the Go bridge which takes plain paths @@ -252,6 +361,7 @@ export async function runBackup( } await saveBackedUpIds(backedUp); + progress.phase = 'done'; onProgress({ ...progress }); return progress;