From 6c0bb5b56ba023723bd8d38e65fcc6650d8e54b5 Mon Sep 17 00:00:00 2001 From: Grigorii Horos Date: Fri, 19 Jun 2026 14:40:35 +0300 Subject: [PATCH 1/4] fix(backend): route photo-backup copies into configured folders Photo backup writes through the Go bridge's CopyFile, which sandboxes the destination to known roots. Two folder kinds were unreachable: - SAF folders, whose Path is an opaque content:// tree URI rather than a POSIX path: resolve the destination to its owning SAF tree and the path relative to it, then write via the SAF bridge. - All-Files-Access folders that live outside foldersRoot (e.g. DCIM) and aren't registered as externalRoots on Android: treat every configured folder's real filesystem path as an extra sandbox root. safTreeForPath is unexported so gomobile doesn't try to bind its three-value return (bind allows at most one value plus error). Co-Authored-By: Claude Opus 4.8 --- backend/mobile_api.go | 71 +++++++++++++++++++++++++++++++++++++++++++ backend/wrapper.go | 49 +++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) 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 { From 8179adb9754979ecaae52cb8c06720729e387404 Mon Sep 17 00:00:00 2001 From: Grigorii Horos Date: Fri, 19 Jun 2026 14:40:35 +0300 Subject: [PATCH 2/4] fix(photo-backup): skip assets already inside the backup folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the backup target overlaps the media source (the user picks DCIM, Pictures, or a nested album like DCIM/Archive), gallery assets were copied back into it, creating duplicates. The previous guard compared raw strings and missed nested assets whenever the two sides disagreed on surface form — /sdcard vs /storage/emulated/0 (symlink), trailing or doubled slashes. Canonicalize both paths before comparing (resolve the /sdcard and /storage/self/primary aliases, collapse slashes), decode content:// SAF tree URIs to their real path, and skip any asset whose source lives inside the target. A second check runs against the actual candidate URI right before the copy, since expo may fall back from localUri to asset.uri. Co-Authored-By: Claude Opus 4.8 --- mobile-app/src/services/PhotoBackup.ts | 110 +++++++++++++++++++++++++ 1 file changed, 110 insertions(+) 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; From 528cfbd6af911c7c72abed75a9705d382084a07c Mon Sep 17 00:00:00 2001 From: Grigorii Horos Date: Fri, 19 Jun 2026 14:42:15 +0300 Subject: [PATCH 3/4] feat(photo-backup): background runs, move, keep-name, by-album Builds on the manual backup with four opt-in capabilities, all surfaced on the Photo Backup settings screen: - Periodic background backup: a TaskManager task registered through expo-background-task (WorkManager on Android, BGTaskScheduler on iOS) runs the same backup on an Hourly / 6-hour / Daily cadence. The task ensures the Go daemon is up in the headless JS runtime and refreshes the folder path before running. App launch reconciles the OS registration with the saved config so an enabled job survives reinstalls / cleared registrations. - Move instead of copy: after an asset is verified present in the backup folder, delete the original from the gallery. One batched delete = at most one system consent dialog (silent with All Files Access, so it also works headless). Assets skipped by the overlap guard are never deleted. - Keep original filename (default on): when off, files are renamed to a creation-time stamp (2024-08-11_143022.HEIC) to avoid collisions. - by-album folder structure: group copies by the source album (the on-disk parent/bucket name), falling back to the folder root. Co-Authored-By: Claude Opus 4.8 --- mobile-app/App.tsx | 8 ++ mobile-app/app.json | 1 + mobile-app/index.ts | 5 + mobile-app/package.json | 2 + .../src/screens/PhotoBackupSettings.tsx | 127 +++++++++++++++++- mobile-app/src/services/PhotoBackup.ts | 101 +++++++++++++- mobile-app/src/services/photoBackupTask.ts | 108 +++++++++++++++ mobile-app/yarn.lock | 32 +++++ 8 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 mobile-app/src/services/photoBackupTask.ts diff --git a/mobile-app/App.tsx b/mobile-app/App.tsx index 46a311c..2153a59 100644 --- a/mobile-app/App.tsx +++ b/mobile-app/App.tsx @@ -53,6 +53,14 @@ function Shell() { const { width } = useWindowDimensions(); const isWide = width >= 700; + // Reconcile the periodic photo-backup job with the saved config on launch, so + // an enabled auto-backup survives reinstalls / OS-cleared registrations. + useEffect(() => { + void import('./src/services/photoBackupTask').then(m => + m.syncAutoBackupRegistration(), + ); + }, []); + const handleSetTab = useCallback((next: CoachTabKey) => setTab(next), []); const handleOnboardingDone = useCallback(() => { onboarding.complete(); diff --git a/mobile-app/app.json b/mobile-app/app.json index c45327f..0a3120d 100644 --- a/mobile-app/app.json +++ b/mobile-app/app.json @@ -23,6 +23,7 @@ }, "plugins": [ "expo-video", + "expo-background-task", [ "expo-splash-screen", { diff --git a/mobile-app/index.ts b/mobile-app/index.ts index ce8f207..2d103c1 100644 --- a/mobile-app/index.ts +++ b/mobile-app/index.ts @@ -1,5 +1,10 @@ import { registerRootComponent } from 'expo'; +// Side-effect import: registers the periodic photo-backup TaskManager task in +// both the foreground and the OS-spawned headless JS runtime. Must run at +// bundle load, before the OS may invoke the task. +import './src/services/photoBackupTask'; + import App from './App'; registerRootComponent(App); diff --git a/mobile-app/package.json b/mobile-app/package.json index 7062007..9d7ea90 100644 --- a/mobile-app/package.json +++ b/mobile-app/package.json @@ -26,6 +26,7 @@ "@react-native-async-storage/async-storage": "2.2.0", "@shopify/flash-list": "^2.3.1", "expo": "~57.0.7", + "expo-background-task": "~57.0.16", "expo-camera": "~57.0.3", "expo-constants": "~57.0.7", "expo-dev-client": "~57.0.7", @@ -35,6 +36,7 @@ "expo-sharing": "~57.0.6", "expo-splash-screen": "~57.0.4", "expo-status-bar": "~57.0.1", + "expo-task-manager": "~57.0.16", "expo-video": "~57.0.1", "react": "19.2.3", "react-native": "0.86.0", diff --git a/mobile-app/src/screens/PhotoBackupSettings.tsx b/mobile-app/src/screens/PhotoBackupSettings.tsx index f319107..a538b6b 100644 --- a/mobile-app/src/screens/PhotoBackupSettings.tsx +++ b/mobile-app/src/screens/PhotoBackupSettings.tsx @@ -22,6 +22,11 @@ import { runBackup, saveConfig, } from '../services/PhotoBackup'; +import { + DEFAULT_INTERVAL_MINUTES, + disableAutoBackup, + enableAutoBackup, +} from '../services/photoBackupTask'; interface Props { onBack: () => void; @@ -31,6 +36,7 @@ const STRUCTURES: { value: FolderStructure; label: string; example: string }[] = { value: 'flat', label: 'All in one folder', example: 'IMG_2020.HEIC' }, { value: 'byDate', label: 'By date', example: '2024-08-11/IMG_2020.HEIC' }, { value: 'byYearMonth', label: 'By year/month', example: '2024/08/IMG_2020.HEIC' }, + { value: 'byAlbum', label: 'By album', example: 'Camera/IMG_2020.HEIC' }, ]; const FILTERS: { value: MediaFilter; label: string }[] = [ @@ -39,6 +45,13 @@ const FILTERS: { value: MediaFilter; label: string }[] = [ { value: 'video', label: 'Videos only' }, ]; +// OS floor is ~15 min on Android; iOS runs opportunistically regardless. +const INTERVALS: { value: number; label: string }[] = [ + { value: 60, label: 'Hourly' }, + { value: 360, label: 'Every 6 hours' }, + { value: 1440, label: 'Daily' }, +]; + export function PhotoBackupSettings({ onBack }: Props) { const client = useSyncthingClient(); const [folders, setFolders] = useState([]); @@ -82,6 +95,7 @@ export function PhotoBackupSettings({ onBack }: Props) { { text: 'Disable', onPress: () => { + disableAutoBackup().catch(() => {}); clearConfig().catch(() => {}); setConfig(null); }, @@ -137,6 +151,43 @@ export function PhotoBackupSettings({ onBack }: Props) { cancelRef.current.cancelled = true; }; + const toggleAutoBackup = (value: boolean) => { + const interval = config?.intervalMinutes ?? DEFAULT_INTERVAL_MINUTES; + updateConfig({ autoBackup: value, intervalMinutes: interval }); + if (value) { + enableAutoBackup(interval).catch(() => {}); + } else { + disableAutoBackup().catch(() => {}); + } + }; + + const toggleMove = (value: boolean) => { + if (value) { + Alert.alert( + 'Move instead of copy?', + 'After each file is safely in the backup folder, the original is DELETED from your gallery. Without "All files access" Android asks you to confirm each batch; in background it stays a copy until granted.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Enable move', + style: 'destructive', + onPress: () => updateConfig({ deleteAfterBackup: true }), + }, + ], + ); + } else { + updateConfig({ deleteAfterBackup: false }); + } + }; + + const selectInterval = (minutes: number) => { + updateConfig({ intervalMinutes: minutes }); + if (config?.autoBackup) { + // re-register so the new cadence takes effect + enableAutoBackup(minutes).catch(() => {}); + } + }; + const selectedFolder = folders.find(f => f.id === config?.folderId); const isRunning = progress?.phase === 'scanning' || progress?.phase === 'copying'; @@ -218,6 +269,21 @@ export function PhotoBackupSettings({ onBack }: Props) { })} + + + Keep original filename + + On = keep the file's own name (IMG_2020.HEIC). Off = rename to its + capture time (2024-08-11_143022.HEIC) to avoid name clashes. + + + updateConfig({ keepOriginalName: v })} + trackColor={{ true: colors.accent, false: colors.border }} + /> + + Include {FILTERS.map(f => { @@ -237,6 +303,60 @@ export function PhotoBackupSettings({ onBack }: Props) { })} + + + Move (delete original) + + Remove each photo/video from the gallery after it's copied to the + backup folder. Off = keep a copy in both places. + + + + + + + + Automatic backup + + Run periodically in the background. The system decides the exact + timing (≥15 min on Android; opportunistic on iOS). + + + + + + {config.autoBackup && ( + <> + Frequency + + {INTERVALS.map(it => { + const active = + (config.intervalMinutes ?? DEFAULT_INTERVAL_MINUTES) === it.value; + return ( + selectInterval(it.value)} + > + + {it.label} + + {active && ✓} + + ); + })} + + + )} + {progress && ( {progress.phase === 'scanning' && ( @@ -268,7 +388,8 @@ export function PhotoBackupSettings({ onBack }: Props) { {progress.phase === 'done' && ( <> - Done. {progress.copied} copied, {progress.skipped} skipped. + Done. {progress.copied} copied, {progress.skipped} skipped + {progress.deleted ? `, ${progress.deleted} moved` : ''}. {progress.lastSkipReason ? ( Last skip: {progress.lastSkipReason} @@ -328,6 +449,10 @@ function defaultConfig(folders: FolderConfig[]): PhotoBackupConfig { folderLabel: f?.label || f?.id || '', structure: 'byDate', mediaFilter: 'all', + autoBackup: false, + intervalMinutes: DEFAULT_INTERVAL_MINUTES, + deleteAfterBackup: false, + keepOriginalName: true, }; } diff --git a/mobile-app/src/services/PhotoBackup.ts b/mobile-app/src/services/PhotoBackup.ts index 12a540a..cdcdb3b 100644 --- a/mobile-app/src/services/PhotoBackup.ts +++ b/mobile-app/src/services/PhotoBackup.ts @@ -3,7 +3,7 @@ import * as FileSystem from 'expo-file-system/legacy'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { copyFile } from '../fs/bridgeFs'; -export type FolderStructure = 'flat' | 'byDate' | 'byYearMonth'; +export type FolderStructure = 'flat' | 'byDate' | 'byYearMonth' | 'byAlbum'; export type MediaFilter = 'photo' | 'video' | 'all'; export interface PhotoBackupConfig { @@ -13,6 +13,28 @@ export interface PhotoBackupConfig { folderLabel: string; structure: FolderStructure; mediaFilter: MediaFilter; + /** Run the backup periodically in the background (WorkManager/BGTask). */ + autoBackup?: boolean; + /** + * Requested interval between background runs, in minutes. The OS enforces a + * floor (~15 min on Android, opportunistic on iOS), so shorter values are + * clamped by the scheduler. + */ + intervalMinutes?: number; + /** + * Move instead of copy: after an asset is verified present in the backup + * folder, delete the original from the device gallery. Deletion is silent + * when All Files Access is granted; otherwise Android shows a system consent + * dialog (so it only works on a foreground run, never headless). + */ + deleteAfterBackup?: boolean; + /** + * Keep each file's original name (true, default). When false, the file is + * renamed to a timestamp derived from its creation time + * (e.g. 2024-08-11_143022.HEIC), which avoids collisions between same-named + * shots from different sources. + */ + keepOriginalName?: boolean; } export interface BackupProgress { @@ -20,6 +42,8 @@ export interface BackupProgress { total: number; copied: number; skipped: number; + /** Originals removed from the gallery when "move" (deleteAfterBackup) is on. */ + deleted?: number; errorMessage?: string; lastSkipReason?: string; } @@ -132,11 +156,56 @@ function isUnder(path: string, root: string): boolean { return p === r || p.startsWith(r + '/'); } +// albumFromLocalUri returns the immediate parent folder name of an on-disk +// asset, which on Android matches the gallery's album (bucket) grouping: +// file:///storage/emulated/0/DCIM/Camera/IMG_2020.HEIC -> "Camera" +// Returns null when the path has no usable parent (e.g. iOS container URIs), +// in which case byAlbum falls back to the folder root. +function albumFromLocalUri(localUri?: string): string | null { + if (!localUri) return null; + const p = stripFileScheme(localUri); + const lastSlash = p.lastIndexOf('/'); + if (lastSlash <= 0) return null; + const dir = p.slice(0, lastSlash); + const parentSlash = dir.lastIndexOf('/'); + const name = parentSlash >= 0 ? dir.slice(parentSlash + 1) : dir; + return name || null; +} + +function basename(p: string): string { + const clean = p.replace(/\/+$/, ''); + const idx = clean.lastIndexOf('/'); + return idx >= 0 ? clean.slice(idx + 1) : clean; +} + +// fileNameFor picks the destination filename. The original on-disk name (taken +// from localUri when available, else asset.filename) is the truest source. +// When keepOriginalName is off, the file is renamed to a creation-time stamp +// (2024-08-11_143022.HEIC), preserving the extension, to dodge collisions. +function fileNameFor( + asset: MediaLibrary.Asset, + localUri: string | undefined, + keepOriginalName: boolean, +): string { + const original = (localUri ? basename(stripFileScheme(localUri)) : '') || asset.filename; + if (keepOriginalName) return original; + + const raw = asset.creationTime; + const date = new Date(raw < 1e12 ? raw * 1000 : raw); + const pad = (n: number) => String(n).padStart(2, '0'); + const stamp = + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + + `_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; + const ext = original.match(/\.[^./]+$/)?.[0] ?? ''; + return `${stamp}${ext}`; +} + function destPath( asset: MediaLibrary.Asset, structure: FolderStructure, + album: string | null, + name: string, ): string { - const name = asset.filename; // creationTime is milliseconds on iOS, seconds on some Android versions const raw = asset.creationTime; const date = new Date(raw < 1e12 ? raw * 1000 : raw); @@ -153,6 +222,8 @@ function destPath( const m = String(date.getMonth() + 1).padStart(2, '0'); return `${y}/${m}/${name}`; } + case 'byAlbum': + return album ? `${album}/${name}` : name; case 'flat': default: return name; @@ -249,6 +320,12 @@ export async function runBackup( // no overlap is possible and the guard is a no-op. const targetRealPath = resolveTargetRealPath(folderPath); + // Assets verified present in the backup folder this run (freshly copied or + // already on disk). When "move" is on, these originals get deleted at the end. + // Overlap-skipped assets (source already inside the target) are deliberately + // excluded — deleting them would remove the backup itself. + const deletable: MediaLibrary.Asset[] = []; + for (const asset of toBackUp) { if (signal?.cancelled) break; @@ -278,7 +355,8 @@ export async function runBackup( continue; } - const rel = destPath(asset, config.structure); + const fileName = fileNameFor(asset, info.localUri, config.keepOriginalName ?? true); + const rel = destPath(asset, config.structure, albumFromLocalUri(info.localUri), fileName); const targetUri = `${folderUri}/${rel}`; const lastSlash = targetUri.lastIndexOf('/'); @@ -294,6 +372,7 @@ export async function runBackup( const exists = await FileSystem.getInfoAsync(targetUri); if (exists.exists) { backedUp.add(asset.id); + deletable.push(asset); progress.skipped++; progress.lastSkipReason = `"${asset.filename}" already on disk`; onProgress({ ...progress }); @@ -343,6 +422,7 @@ export async function runBackup( if (copied) { backedUp.add(asset.id); + deletable.push(asset); progress.copied++; } else { progress.skipped++; @@ -362,6 +442,21 @@ export async function runBackup( await saveBackedUpIds(backedUp); + // Move mode: delete originals that are now safely in the backup folder. One + // batched call = at most one system consent dialog (and none at all when All + // Files Access is granted, so it also works on a headless background run). + if (config.deleteAfterBackup && deletable.length > 0 && !signal?.cancelled) { + try { + await MediaLibrary.deleteAssetsAsync(deletable); + progress.deleted = deletable.length; + } catch (e) { + // Consent denied or no Activity (headless without All Files Access). + // Originals stay; the copy already succeeded, so this is non-fatal. + progress.lastSkipReason = `delete skipped: ${e instanceof Error ? e.message : String(e)}`; + } + onProgress({ ...progress }); + } + progress.phase = 'done'; onProgress({ ...progress }); return progress; diff --git a/mobile-app/src/services/photoBackupTask.ts b/mobile-app/src/services/photoBackupTask.ts new file mode 100644 index 0000000..ce09104 --- /dev/null +++ b/mobile-app/src/services/photoBackupTask.ts @@ -0,0 +1,108 @@ +import * as BackgroundTask from 'expo-background-task'; +import * as TaskManager from 'expo-task-manager'; +import GoBridge from '../GoServerBridgeJSI'; +import { SyncthingClient } from '../api/syncthing'; +import { loadConfig, runBackup } from './PhotoBackup'; + +// Name of the WorkManager (Android) / BGTask (iOS) job. Must stay stable across +// releases or the OS treats a renamed task as a new, unregistered one. +export const PHOTO_BACKUP_TASK = 'photo-backup-periodic'; + +// Default cadence. The OS enforces a floor (~15 min on Android, opportunistic on +// iOS); anything lower is effectively clamped by the scheduler. +export const DEFAULT_INTERVAL_MINUTES = 60; +export const MIN_INTERVAL_MINUTES = 15; + +// ensureDaemon mirrors readNativeInfo() from SyncthingContext: startServer() is +// idempotent and returns the port (starting the daemon if the foreground +// service isn't already holding it alive). copyFile in the Go bridge needs the +// daemon's config loaded to resolve sandbox roots / SAF trees, so the daemon +// must be up before runBackup copies anything. +function ensureDaemon(): SyncthingClient | null { + try { + const port = GoBridge.startServer(); + if (port <= 0) return null; + return new SyncthingClient({ + apiKey: GoBridge.getApiKey(), + guiAddress: GoBridge.getGuiAddress(), + }); + } catch { + return null; + } +} + +// Define the task at module scope so it is registered in BOTH the foreground JS +// runtime and the headless runtime the OS spins up to run the job. Importing +// this file for its side effect (see index.ts) is what wires that up. +TaskManager.defineTask(PHOTO_BACKUP_TASK, async () => { + try { + const config = await loadConfig(); + if (!config || !config.enabled || !config.autoBackup) { + return BackgroundTask.BackgroundTaskResult.Success; + } + + const client = ensureDaemon(); + + // Refresh the live folder path from the daemon when reachable; otherwise + // fall back to the stored path inside runBackup. + const refreshFolderPath = client + ? async (): Promise => { + try { + const folders = await client.folders(); + return folders.find(f => f.id === config.folderId)?.path ?? null; + } catch { + return null; + } + } + : undefined; + + await runBackup(config, () => {}, undefined, refreshFolderPath); + return BackgroundTask.BackgroundTaskResult.Success; + } catch { + return BackgroundTask.BackgroundTaskResult.Failed; + } +}); + +// enableAutoBackup (re)registers the periodic job. Calling it again with a new +// interval updates the existing registration. +export async function enableAutoBackup(intervalMinutes: number): Promise { + const minutes = Math.max(MIN_INTERVAL_MINUTES, Math.round(intervalMinutes)); + await BackgroundTask.registerTaskAsync(PHOTO_BACKUP_TASK, { + minimumInterval: minutes, + }); +} + +export async function disableAutoBackup(): Promise { + try { + const registered = await TaskManager.isTaskRegisteredAsync(PHOTO_BACKUP_TASK); + if (registered) { + await BackgroundTask.unregisterTaskAsync(PHOTO_BACKUP_TASK); + } + } catch { + // already gone / never registered + } +} + +// isAutoBackupAvailable reports whether the OS currently permits background +// tasks (false when the user disabled Background App Refresh / battery saver +// restricts the app). +export async function isAutoBackupAvailable(): Promise { + try { + const status = await BackgroundTask.getStatusAsync(); + return status === BackgroundTask.BackgroundTaskStatus.Available; + } catch { + return false; + } +} + +// syncAutoBackupRegistration aligns the OS registration with the saved config. +// Call on app start so a config that says autoBackup:true survives reinstalls / +// OS-cleared registrations, and one that says false leaves nothing scheduled. +export async function syncAutoBackupRegistration(): Promise { + const config = await loadConfig(); + if (config?.enabled && config.autoBackup) { + await enableAutoBackup(config.intervalMinutes ?? DEFAULT_INTERVAL_MINUTES); + } else { + await disableAutoBackup(); + } +} diff --git a/mobile-app/yarn.lock b/mobile-app/yarn.lock index 02d8702..a511867 100644 --- a/mobile-app/yarn.lock +++ b/mobile-app/yarn.lock @@ -5030,6 +5030,17 @@ __metadata: languageName: node linkType: hard +"expo-background-task@npm:~57.0.16": + version: 57.0.21 + resolution: "expo-background-task@npm:57.0.21" + dependencies: + expo-task-manager: "npm:~57.0.21" + peerDependencies: + expo: "*" + checksum: 10c0/31aac5a50757c23e6aa2ec35f68cfe7b28bb3a037bd1420e35aa2c018e6e09661c445c22661f369a1b1f964ce58dddaaee245d4a1bb96d39cf61ce801bdc591d + languageName: node + linkType: hard + "expo-camera@npm:~57.0.3": version: 57.0.3 resolution: "expo-camera@npm:57.0.3" @@ -5287,6 +5298,18 @@ __metadata: languageName: node linkType: hard +"expo-task-manager@npm:~57.0.16, expo-task-manager@npm:~57.0.21": + version: 57.0.21 + resolution: "expo-task-manager@npm:57.0.21" + dependencies: + unimodules-app-loader: "npm:~57.0.2" + peerDependencies: + expo: "*" + react-native: "*" + checksum: 10c0/76f2efb534e88bb37069317d920f6dc0aecbed504ce97f82d5dfa5a7c7bc8488b6fd3eb268e44c53b8986043a71cb802a48e9fb17c886e89105e0cfbf95f2510 + languageName: node + linkType: hard + "expo-updates-interface@npm:~57.0.1": version: 57.0.1 resolution: "expo-updates-interface@npm:57.0.1" @@ -9734,6 +9757,7 @@ __metadata: eslint-config-expo: "npm:^57.0.0" eslint-plugin-unused-imports: "npm:^4.4.1" expo: "npm:~57.0.7" + expo-background-task: "npm:~57.0.16" expo-camera: "npm:~57.0.3" expo-constants: "npm:~57.0.7" expo-dev-client: "npm:~57.0.7" @@ -9743,6 +9767,7 @@ __metadata: expo-sharing: "npm:~57.0.6" expo-splash-screen: "npm:~57.0.4" expo-status-bar: "npm:~57.0.1" + expo-task-manager: "npm:~57.0.16" expo-video: "npm:~57.0.1" jest: "npm:^30.3.0" prettier: "npm:^3.8.3" @@ -10124,6 +10149,13 @@ __metadata: languageName: node linkType: hard +"unimodules-app-loader@npm:~57.0.2": + version: 57.0.2 + resolution: "unimodules-app-loader@npm:57.0.2" + checksum: 10c0/67abf8e778b4360e43d787289359c6771c30f084ce3693d57ac4943ecf53c711ba66254bc4cbf44015b5a39994b40795598b26257ffb4aff747dda8e7813765d + languageName: node + linkType: hard + "unique-filename@npm:^4.0.0": version: 4.0.0 resolution: "unique-filename@npm:4.0.0" From edcce36e70ba68988e45ad6e70434be912ff5205 Mon Sep 17 00:00:00 2001 From: Grigorii Horos Date: Sun, 18 Oct 2026 01:25:24 +0300 Subject: [PATCH 4/4] feat(photo-backup): pick albums to back up, overwrite option With the "By album" structure, settings now list the device albums with a thumbnail and item count; only the checked albums are backed up (none checked = all albums). Albums whose files already live inside a synced folder are hidden, and runBackup skips such assets for every structure, since Syncthing already carries them to the other devices. New "Overwrite existing files" switch: when a file already exists at the target path, copy over it instead of skipping. Off by default, keeping the previous behaviour. Co-Authored-By: Claude Opus 5.5 --- .../src/screens/PhotoBackupSettings.tsx | 129 +++++++++++++++++- mobile-app/src/services/PhotoBackup.ts | 82 ++++++++--- mobile-app/src/services/photoBackupTask.ts | 5 +- 3 files changed, 196 insertions(+), 20 deletions(-) diff --git a/mobile-app/src/screens/PhotoBackupSettings.tsx b/mobile-app/src/screens/PhotoBackupSettings.tsx index a538b6b..074fdf1 100644 --- a/mobile-app/src/screens/PhotoBackupSettings.tsx +++ b/mobile-app/src/screens/PhotoBackupSettings.tsx @@ -1,7 +1,10 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; +import * as MediaLibrary from 'expo-media-library/legacy'; import { ActivityIndicator, Alert, + Image, + Platform, ScrollView, StyleSheet, Switch, @@ -18,6 +21,7 @@ import { type MediaFilter, type PhotoBackupConfig, clearConfig, + isInFolders, loadConfig, runBackup, saveConfig, @@ -59,6 +63,9 @@ export function PhotoBackupSettings({ onBack }: Props) { const [loading, setLoading] = useState(true); const [progress, setProgress] = useState(null); const cancelRef = useRef({ cancelled: false }); + const [albums, setAlbums] = useState<{ album: MediaLibrary.Album; preview?: string }[] | null>( + null, + ); useEffect(() => { (async () => { @@ -72,6 +79,38 @@ export function PhotoBackupSettings({ onBack }: Props) { })(); }, [client]); + const byAlbum = config?.structure === 'byAlbum'; + useEffect(() => { + if (!byAlbum || albums || loading) return; + (async () => { + const { status } = await MediaLibrary.requestPermissionsAsync(); + if (status !== 'granted') return setAlbums([]); + const list = await MediaLibrary.getAlbumsAsync().catch(() => [] as MediaLibrary.Album[]); + const folderPaths = folders.map(f => f.path); + const rows = await Promise.all( + list.map(async album => { + // The newest asset doubles as the thumbnail and tells us where the + // album lives on disk (Android albums are single directories). + const first = await MediaLibrary.getAssetsAsync({ + album, + first: 1, + sortBy: [[MediaLibrary.SortBy.creationTime, false]], + }).catch(() => null); + const asset = first?.assets[0]; + if (!asset) return { album }; + const info = await MediaLibrary.getAssetInfoAsync(asset).catch(() => null); + if (info?.localUri && isInFolders(info.localUri, folderPaths)) return null; + return { album, preview: thumbUri(asset) }; + }), + ); + setAlbums( + rows + .filter(r => r != null) + .sort((a, b) => a.album.title.localeCompare(b.album.title)), + ); + })(); + }, [byAlbum, albums, loading, folders]); + const enabled = config?.enabled ?? false; const updateConfig = useCallback( @@ -137,7 +176,13 @@ export function PhotoBackupSettings({ onBack }: Props) { } }; - const result = await runBackup(config, setProgress, cancelRef.current, refreshPath); + const result = await runBackup( + config, + setProgress, + cancelRef.current, + refreshPath, + folders.map(f => f.path), + ); if (result.phase === 'done' && result.copied > 0) { try { await client.scanFolder(config.folderId); @@ -269,6 +314,57 @@ export function PhotoBackupSettings({ onBack }: Props) { })} + {byAlbum && ( + <> + Albums + + Pick the albums to back up. None selected = all albums. Albums inside + folders you already sync are hidden and skipped. + + {albums === null ? ( + + ) : albums.length === 0 ? ( + No albums found (check photo permission). + ) : ( + + {albums.map(({ album: a, preview }) => { + const selected = config.albumIds?.includes(a.id) ?? false; + return ( + + updateConfig({ + albumIds: selected + ? config.albumIds!.filter(id => id !== a.id) + : [...(config.albumIds ?? []), a.id], + }) + } + > + {preview ? ( + + ) : ( + + )} + + + {a.title} + + {a.assetCount} items + + {selected && ✓} + + ); + })} + + )} + + )} + Keep original filename @@ -318,6 +414,21 @@ export function PhotoBackupSettings({ onBack }: Props) { /> + + + Overwrite existing files + + On = copy again over a file with the same name in the backup folder. Off = + keep the existing file and skip the photo. + + + updateConfig({ overwriteExisting: v })} + trackColor={{ true: colors.accent, false: colors.border }} + /> + + Automatic backup @@ -440,6 +551,15 @@ function Header({ onBack }: { onBack: () => void }) { ); } +// On Android asset.uri is a raw file path that the image loader may not be +// allowed to read; the MediaStore content URI works under the media permission +// and also yields thumbnails for videos. +function thumbUri(asset: MediaLibrary.Asset): string { + if (Platform.OS !== 'android') return asset.uri; + const kind = asset.mediaType === MediaLibrary.MediaType.video ? 'video' : 'images'; + return `content://media/external/${kind}/media/${asset.id}`; +} + function defaultConfig(folders: FolderConfig[]): PhotoBackupConfig { const f = folders[0]; return { @@ -507,6 +627,13 @@ const styles = StyleSheet.create({ optionRowActive: {}, optionText: { color: colors.text, fontSize: 14, flex: 1 }, optionTextActive: { fontWeight: '600' }, + albumThumb: { + width: 44, + height: 44, + borderRadius: 6, + marginRight: 12, + backgroundColor: colors.border, + }, optionExample: { color: colors.textDim, fontSize: 11, fontFamily: 'Menlo', marginTop: 2 }, check: { color: colors.accent, fontSize: 16, fontWeight: '700' }, progressCard: { diff --git a/mobile-app/src/services/PhotoBackup.ts b/mobile-app/src/services/PhotoBackup.ts index cdcdb3b..fdb778c 100644 --- a/mobile-app/src/services/PhotoBackup.ts +++ b/mobile-app/src/services/PhotoBackup.ts @@ -35,6 +35,17 @@ export interface PhotoBackupConfig { * shots from different sources. */ keepOriginalName?: boolean; + /** + * When a file already exists at the target path: overwrite it (true) or skip + * it and treat the asset as backed up (false, default). Either way the asset + * counts as backed up, so move mode deletes the original. + */ + overwriteExisting?: boolean; + /** + * With the byAlbum structure: back up only these album ids. Empty or + * missing means every album. + */ + albumIds?: string[]; } export interface BackupProgress { @@ -156,6 +167,17 @@ function isUnder(path: string, root: string): boolean { return p === r || p.startsWith(r + '/'); } +// isInFolders reports whether an on-device file lies inside any of the given +// Syncthing folder paths (SAF tree URIs or plain paths). Such files are +// already synced, so photo backup ignores them. +export function isInFolders(localUri: string, folderPaths: string[]): boolean { + const src = stripFileScheme(localUri); + return folderPaths.some(f => { + const root = resolveTargetRealPath(f); + return root != null && isUnder(src, root); + }); +} + // albumFromLocalUri returns the immediate parent folder name of an on-disk // asset, which on Android matches the gallery's album (bucket) grouping: // file:///storage/emulated/0/DCIM/Camera/IMG_2020.HEIC -> "Camera" @@ -235,6 +257,7 @@ export async function runBackup( onProgress: (p: BackupProgress) => void, signal?: { cancelled: boolean }, refreshFolderPath?: () => Promise, + syncedFolderPaths: string[] = [], ): Promise { const progress: BackupProgress = { phase: 'scanning', @@ -279,25 +302,40 @@ export async function runBackup( } })(); - let hasMore = true; - let after: string | undefined; const toBackUp: MediaLibrary.Asset[] = []; - - while (hasMore) { - if (signal?.cancelled) break; - const page = await MediaLibrary.getAssetsAsync({ - mediaType, - first: 100, - after, - sortBy: [MediaLibrary.SortBy.creationTime], - }); - for (const asset of page.assets) { - if (!backedUp.has(asset.id)) { - toBackUp.push(asset); + const queued = new Set(); + const albums: (string | undefined)[] = + config.structure === 'byAlbum' && config.albumIds?.length ? config.albumIds : [undefined]; + + for (const album of albums) { + let hasMore = true; + let after: string | undefined; + while (hasMore) { + if (signal?.cancelled) break; + let page: MediaLibrary.PagedInfo; + try { + page = await MediaLibrary.getAssetsAsync({ + mediaType, + first: 100, + after, + album, + sortBy: [MediaLibrary.SortBy.creationTime], + }); + } catch (e) { + // A selected album that was deleted on the device: skip it, keep the rest. + if (album) break; + throw e; + } + for (const asset of page.assets) { + // iOS assets can live in several albums; queue each once. + if (!backedUp.has(asset.id) && !queued.has(asset.id)) { + queued.add(asset.id); + toBackUp.push(asset); + } } + hasMore = page.hasNextPage; + after = page.endCursor; } - hasMore = page.hasNextPage; - after = page.endCursor; } progress.total = toBackUp.length; @@ -340,6 +378,14 @@ export async function runBackup( onProgress({ ...progress }); continue; } + // Assets inside another synced folder already reach the other devices. + // Not marked as backed up, so they're picked up if that folder is removed. + if (srcLocalPath && isInFolders(srcLocalPath, syncedFolderPaths)) { + progress.skipped++; + progress.lastSkipReason = `"${asset.filename}" already in a synced 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 @@ -369,8 +415,8 @@ export async function runBackup( } } - const exists = await FileSystem.getInfoAsync(targetUri); - if (exists.exists) { + // Otherwise copyFile below overwrites it (it truncates). + if (!config.overwriteExisting && (await FileSystem.getInfoAsync(targetUri)).exists) { backedUp.add(asset.id); deletable.push(asset); progress.skipped++; diff --git a/mobile-app/src/services/photoBackupTask.ts b/mobile-app/src/services/photoBackupTask.ts index ce09104..6085ef4 100644 --- a/mobile-app/src/services/photoBackupTask.ts +++ b/mobile-app/src/services/photoBackupTask.ts @@ -56,7 +56,10 @@ TaskManager.defineTask(PHOTO_BACKUP_TASK, async () => { } : undefined; - await runBackup(config, () => {}, undefined, refreshFolderPath); + const syncedFolderPaths = client + ? await client.folders().then(fs => fs.map(f => f.path), () => []) + : []; + await runBackup(config, () => {}, undefined, refreshFolderPath, syncedFolderPaths); return BackgroundTask.BackgroundTaskResult.Success; } catch { return BackgroundTask.BackgroundTaskResult.Failed;