Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions backend/mobile_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions backend/wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
110 changes: 110 additions & 0 deletions mobile-app/src/services/PhotoBackup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,78 @@ async function saveBackedUpIds(ids: Set<string>): Promise<void> {
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/<docId>; 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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -252,6 +361,7 @@ export async function runBackup(
}

await saveBackedUpIds(backedUp);

progress.phase = 'done';
onProgress({ ...progress });
return progress;
Expand Down