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
8 changes: 8 additions & 0 deletions mobile-app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion mobile-app/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-video"
"expo-video",
"expo-background-task"
]
}
}
5 changes: 5 additions & 0 deletions mobile-app/index.ts
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 2 additions & 0 deletions mobile-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@
"@react-native-async-storage/async-storage": "2.2.0",
"@shopify/flash-list": "^2.3.1",
"expo": "~54.0.10",
"expo-background-task": "~1.0.10",
"expo-camera": "~17.0.10",
"expo-dev-client": "~6.0.12",
"expo-file-system": "~19.0.21",
"expo-haptics": "~15.0.8",
"expo-media-library": "~18.2.1",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.8",
"expo-task-manager": "~14.0.9",
"expo-video": "~3.0.16",
"react": "19.1.1",
"react-native": "0.82.0",
Expand Down
127 changes: 126 additions & 1 deletion mobile-app/src/screens/PhotoBackupSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import {
runBackup,
saveConfig,
} from '../services/PhotoBackup';
import {
DEFAULT_INTERVAL_MINUTES,
disableAutoBackup,
enableAutoBackup,
} from '../services/photoBackupTask';

interface Props {
onBack: () => void;
Expand All @@ -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 }[] = [
Expand All @@ -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<FolderConfig[]>([]);
Expand Down Expand Up @@ -82,6 +95,7 @@ export function PhotoBackupSettings({ onBack }: Props) {
{
text: 'Disable',
onPress: () => {
disableAutoBackup().catch(() => {});
clearConfig().catch(() => {});
setConfig(null);
},
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -218,6 +269,21 @@ export function PhotoBackupSettings({ onBack }: Props) {
})}
</View>

<View style={styles.toggleRow}>
<View style={{ flex: 1 }}>
<Text style={styles.toggleLabel}>Keep original filename</Text>
<Text style={styles.toggleHint}>
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.
</Text>
</View>
<Switch
value={config.keepOriginalName ?? true}
onValueChange={v => updateConfig({ keepOriginalName: v })}
trackColor={{ true: colors.accent, false: colors.border }}
/>
</View>

<Text style={styles.sectionLabel}>Include</Text>
<View style={styles.optionGroup}>
{FILTERS.map(f => {
Expand All @@ -237,6 +303,60 @@ export function PhotoBackupSettings({ onBack }: Props) {
})}
</View>

<View style={styles.toggleRow}>
<View style={{ flex: 1 }}>
<Text style={styles.toggleLabel}>Move (delete original)</Text>
<Text style={styles.toggleHint}>
Remove each photo/video from the gallery after it's copied to the
backup folder. Off = keep a copy in both places.
</Text>
</View>
<Switch
value={config.deleteAfterBackup ?? false}
onValueChange={toggleMove}
trackColor={{ true: colors.accent, false: colors.border }}
/>
</View>

<View style={styles.toggleRow}>
<View style={{ flex: 1 }}>
<Text style={styles.toggleLabel}>Automatic backup</Text>
<Text style={styles.toggleHint}>
Run periodically in the background. The system decides the exact
timing (≥15 min on Android; opportunistic on iOS).
</Text>
</View>
<Switch
value={config.autoBackup ?? false}
onValueChange={toggleAutoBackup}
trackColor={{ true: colors.accent, false: colors.border }}
/>
</View>

{config.autoBackup && (
<>
<Text style={styles.sectionLabel}>Frequency</Text>
<View style={styles.optionGroup}>
{INTERVALS.map(it => {
const active =
(config.intervalMinutes ?? DEFAULT_INTERVAL_MINUTES) === it.value;
return (
<Focusable
key={it.value}
style={[styles.optionRow, active && styles.optionRowActive]}
onPress={() => selectInterval(it.value)}
>
<Text style={[styles.optionText, active && styles.optionTextActive]}>
{it.label}
</Text>
{active && <Text style={styles.check}>✓</Text>}
</Focusable>
);
})}
</View>
</>
)}

{progress && (
<View style={styles.progressCard}>
{progress.phase === 'scanning' && (
Expand Down Expand Up @@ -268,7 +388,8 @@ export function PhotoBackupSettings({ onBack }: Props) {
{progress.phase === 'done' && (
<>
<Text style={styles.progressDone}>
Done. {progress.copied} copied, {progress.skipped} skipped.
Done. {progress.copied} copied, {progress.skipped} skipped
{progress.deleted ? `, ${progress.deleted} moved` : ''}.
</Text>
{progress.lastSkipReason ? (
<Text style={styles.skipReason}>Last skip: {progress.lastSkipReason}</Text>
Expand Down Expand Up @@ -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,
};
}

Expand Down
Loading