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/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..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,10 +21,16 @@ import { type MediaFilter, type PhotoBackupConfig, clearConfig, + isInFolders, loadConfig, runBackup, saveConfig, } from '../services/PhotoBackup'; +import { + DEFAULT_INTERVAL_MINUTES, + disableAutoBackup, + enableAutoBackup, +} from '../services/photoBackupTask'; interface Props { onBack: () => void; @@ -31,6 +40,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 +49,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([]); @@ -46,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 () => { @@ -59,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( @@ -82,6 +134,7 @@ export function PhotoBackupSettings({ onBack }: Props) { { text: 'Disable', onPress: () => { + disableAutoBackup().catch(() => {}); clearConfig().catch(() => {}); setConfig(null); }, @@ -123,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); @@ -137,6 +196,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 +314,72 @@ 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 + + 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 +399,75 @@ 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. + + + + + + + + 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 + + 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 +499,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} @@ -319,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 { @@ -328,6 +569,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, }; } @@ -382,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 e3b76cf..fdb778c 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,39 @@ 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; + /** + * 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 { @@ -20,6 +53,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; } @@ -60,11 +95,139 @@ 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 + '/'); +} + +// 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" +// 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); @@ -81,6 +244,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; @@ -92,6 +257,7 @@ export async function runBackup( onProgress: (p: BackupProgress) => void, signal?: { cancelled: boolean }, refreshFolderPath?: () => Promise, + syncedFolderPaths: string[] = [], ): Promise { const progress: BackupProgress = { phase: 'scanning', @@ -136,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; @@ -170,12 +351,42 @@ 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); + + // 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; 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; + } + // 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 // fallback and may be a content:// (Android) or ph:// (iOS) URI. @@ -190,7 +401,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('/'); @@ -203,9 +415,10 @@ 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++; progress.lastSkipReason = `"${asset.filename}" already on disk`; onProgress({ ...progress }); @@ -218,6 +431,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 @@ -234,6 +468,7 @@ export async function runBackup( if (copied) { backedUp.add(asset.id); + deletable.push(asset); progress.copied++; } else { progress.skipped++; @@ -252,6 +487,22 @@ 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..6085ef4 --- /dev/null +++ b/mobile-app/src/services/photoBackupTask.ts @@ -0,0 +1,111 @@ +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; + + 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; + } +}); + +// 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"