diff --git a/mobile-app/src/components/FolderLocationPicker.tsx b/mobile-app/src/components/FolderLocationPicker.tsx
new file mode 100644
index 0000000..8247dbc
--- /dev/null
+++ b/mobile-app/src/components/FolderLocationPicker.tsx
@@ -0,0 +1,158 @@
+import React from 'react';
+import { Platform, StyleSheet, Text, View } from 'react-native';
+import { Focusable } from './Focusable';
+import { colors } from './ui';
+
+// The three storage destinations a folder can live in. Unified so the picker
+// looks the same in Add-folder and Accept-folder flows:
+// app — inside the app sandbox (foldersRoot), no permissions, Files-app visible
+// device — a real path like DCIM/Downloads (Android: All Files Access; iOS: Files)
+// saf — a content:// tree (Android only): cloud providers, SD card
+export type LocationKind = 'app' | 'device' | 'saf';
+
+interface Props {
+ /** Which destination currently holds the chosen path, or null when none. */
+ selected: LocationKind | null;
+ /** Human-readable path/name shown on the selected card. */
+ chosenLabel: string;
+ /** Android: whether MANAGE_EXTERNAL_STORAGE is granted (gates device browsing). */
+ hasAllFilesAccess: boolean;
+ /** App-storage card stays disabled until the daemon reports a folders root. */
+ appDisabled?: boolean;
+ onPickApp: () => void;
+ onPickDevice: () => void;
+ onPickSaf: () => void;
+ onRequestAllFilesAccess: () => void;
+}
+
+export function FolderLocationPicker({
+ selected,
+ chosenLabel,
+ hasAllFilesAccess,
+ appDisabled,
+ onPickApp,
+ onPickDevice,
+ onPickSaf,
+ onRequestAllFilesAccess,
+}: Props) {
+ const isAndroid = Platform.OS === 'android';
+ // On Android the device browser needs All Files Access; without it the card
+ // becomes a "grant" prompt instead of opening the (empty) browser.
+ const deviceLocked = isAndroid && !hasAllFilesAccess;
+
+ return (
+
+
+
+
+
+ {isAndroid && (
+
+ )}
+
+ );
+}
+
+interface CardProps {
+ on: boolean;
+ title: string;
+ hint: string;
+ chosen?: string;
+ disabled?: boolean;
+ showChevron?: boolean;
+ actionLabel?: string;
+ onPress: () => void;
+}
+
+function Card({ on, title, hint, chosen, disabled, showChevron, actionLabel, onPress }: CardProps) {
+ return (
+ !disabled && onPress()}
+ disabled={disabled}
+ activeOpacity={0.7}
+ >
+
+ {on && }
+
+
+ {title}
+ {chosen ? (
+
+ {chosen}
+
+ ) : (
+ {hint}
+ )}
+
+ {actionLabel ? (
+ {actionLabel}
+ ) : showChevron ? (
+ ›
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { gap: 8 },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ backgroundColor: colors.card,
+ borderRadius: 10,
+ borderWidth: 1,
+ borderColor: colors.border,
+ padding: 12,
+ gap: 12,
+ },
+ rowOn: { borderColor: colors.accent },
+ rowDisabled: { opacity: 0.45 },
+ radio: {
+ width: 20,
+ height: 20,
+ borderRadius: 10,
+ borderWidth: 1.5,
+ borderColor: colors.border,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: 1,
+ },
+ radioOn: { borderColor: colors.accent },
+ radioInner: { width: 10, height: 10, borderRadius: 5, backgroundColor: colors.accent },
+ text: { flex: 1 },
+ label: { color: colors.text, fontSize: 14, fontWeight: '600' },
+ labelOn: { color: colors.accent },
+ hint: { color: colors.textDim, fontSize: 11, lineHeight: 15, marginTop: 2 },
+ chosen: { color: colors.text, fontSize: 12, lineHeight: 16, marginTop: 2 },
+ chevron: { color: colors.textDim, fontSize: 22, marginTop: -2 },
+ action: { color: colors.accent, fontSize: 12, fontWeight: '600', marginTop: 2 },
+});
diff --git a/mobile-app/src/screens/AcceptFolderModal.tsx b/mobile-app/src/screens/AcceptFolderModal.tsx
index 50dd9b9..6548abc 100644
--- a/mobile-app/src/screens/AcceptFolderModal.tsx
+++ b/mobile-app/src/screens/AcceptFolderModal.tsx
@@ -1,13 +1,16 @@
import React, { useEffect, useMemo, useState } from 'react';
-import { StyleSheet, Text, View } from 'react-native';
+import { AppState, Platform, StyleSheet, Text, View } from 'react-native';
import { Focusable } from '../components/Focusable';
import { FormModal } from '../components/FormModal';
import { colors } from '../components/ui';
import { useSyncthing, useSyncthingClient } from '../daemon/SyncthingContext';
import type { DeviceConfig, FolderConfig, PendingFolderOffer } from '../api/types';
import { isAbortError } from '../api/syncthing';
+import GoBridge from '../GoServerBridgeJSI';
import { FolderPicker } from './FolderPicker';
+import { AndroidLocalBrowser } from './AndroidLocalBrowser';
import { FolderTypePicker } from '../components/FolderTypePicker';
+import { FolderLocationPicker, type LocationKind } from '../components/FolderLocationPicker';
import {
filesystemTypeForExternal,
pickExternalFolderWithICloudWarning,
@@ -34,6 +37,19 @@ export function AcceptFolderModal({ visible, offer, onClose, onAccepted }: Props
const [isExternal, setIsExternal] = useState(false);
const [externalDisplayName, setExternalDisplayName] = useState('');
const [pickerOpen, setPickerOpen] = useState(false);
+ const [localBrowserOpen, setLocalBrowserOpen] = useState(false);
+ // When MANAGE_EXTERNAL_STORAGE is granted the primary picker is the
+ // full-screen native browser (POSIX); SAF stays as the alternate route for
+ // cloud / SD-card. Mirrors AddFolderModal so accepting a shared folder has
+ // the same location choices as adding one.
+ const [hasAllFilesAccess, setHasAllFilesAccess] = useState(() => {
+ if (Platform.OS !== 'android') return true;
+ try {
+ return GoBridge.hasAllFilesAccess();
+ } catch {
+ return false;
+ }
+ });
const [allDevices, setAllDevices] = useState([]);
const [extraPeers, setExtraPeers] = useState>(new Set());
const [folderType, setFolderType] = useState('sendreceive');
@@ -46,6 +62,7 @@ export function AcceptFolderModal({ visible, offer, onClose, onAccepted }: Props
setPath('');
setIsExternal(false);
setExternalDisplayName('');
+ setLocalBrowserOpen(false);
setExtraPeers(new Set());
// encrypted slot from the peer => receiveencrypted, else two-way
setFolderType(offer?.receiveEncrypted ? 'receiveencrypted' : 'sendreceive');
@@ -55,6 +72,24 @@ export function AcceptFolderModal({ visible, offer, onClose, onAccepted }: Props
client.devices().then(setAllDevices).catch(e => setError(String(e)));
}, [visible, offer, client]);
+ // Re-check All Files Access on foreground — the user grants it in system
+ // settings and returns via app resume.
+ useEffect(() => {
+ if (Platform.OS !== 'android') return;
+ const refresh = () => {
+ try {
+ setHasAllFilesAccess(GoBridge.hasAllFilesAccess());
+ } catch {
+ // leave previous value
+ }
+ };
+ if (visible) refresh();
+ const sub = AppState.addEventListener('change', state => {
+ if (state === 'active') refresh();
+ });
+ return () => sub.remove();
+ }, [visible]);
+
const pickerRoot = info?.foldersRoot ?? '';
const pickExternal = () => {
@@ -70,6 +105,32 @@ export function AcceptFolderModal({ visible, offer, onClose, onAccepted }: Props
}
};
+ // Device card: full-screen native browser on Android (All Files Access is
+ // already confirmed by the card), document picker on iOS.
+ const pickDevice = () => {
+ if (Platform.OS === 'android') {
+ setLocalBrowserOpen(true);
+ return;
+ }
+ pickExternal();
+ };
+
+ const onLocalBrowserPick = (chosen: string) => {
+ setLocalBrowserOpen(false);
+ setPath(chosen);
+ setIsExternal(true);
+ const name = chosen.split('/').filter(Boolean).pop() || 'Device folder';
+ setExternalDisplayName(name);
+ };
+
+ const requestAllFilesAccess = () => {
+ try {
+ GoBridge.requestAllFilesAccess();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ }
+ };
+
const displayPath = useMemo(() => {
if (!path) return '';
if (isExternal) return externalDisplayName || 'Device folder';
@@ -80,6 +141,15 @@ export function AcceptFolderModal({ visible, offer, onClose, onAccepted }: Props
return path;
}, [path, pickerRoot, isExternal, externalDisplayName]);
+ // Which destination card is currently active (drives the unified picker UI).
+ const locationKind: LocationKind | null = !path
+ ? null
+ : !isExternal
+ ? 'app'
+ : path.startsWith('content://')
+ ? 'saf'
+ : 'device';
+
const otherPeers = useMemo(
() =>
allDevices.filter(
@@ -184,7 +254,7 @@ export function AcceptFolderModal({ visible, offer, onClose, onAccepted }: Props
return (
<>
Where to store it
-
-
- {displayPath || 'Pick a folder on this device…'}
-
- ›
-
-
- {isExternal
- ? 'Files from the peer will sync directly with this device folder.'
- : path
- ? 'Files from the peer will be stored in this app-managed directory.'
- : 'Pick any folder on your device, or tap below to use app storage.'}
-
- {isExternal ? (
- {
- setIsExternal(false);
- setPath('');
- setExternalDisplayName('');
- }}
- >
- Use app storage instead
-
- ) : (
- setPickerOpen(true)}
- disabled={!pickerRoot}
- >
- Use app storage instead
-
- )}
+ setPickerOpen(true)}
+ onPickDevice={pickDevice}
+ onPickSaf={pickExternal}
+ onRequestAllFilesAccess={requestAllFilesAccess}
+ />
Folder kind
@@ -292,6 +336,14 @@ export function AcceptFolderModal({ visible, offer, onClose, onAccepted }: Props
setPickerOpen(false);
}}
/>
+
+ {Platform.OS === 'android' && (
+ setLocalBrowserOpen(false)}
+ onPick={onLocalBrowserPick}
+ />
+ )}
>
);
}
@@ -359,23 +411,6 @@ const styles = StyleSheet.create({
textTransform: 'uppercase',
marginBottom: 8,
},
- pickerBtn: {
- backgroundColor: colors.card,
- borderRadius: 10,
- paddingHorizontal: 14,
- paddingVertical: 14,
- borderWidth: 1,
- borderColor: colors.border,
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- gap: 8,
- },
- pickerBtnEmpty: { borderStyle: 'dashed' },
- pickerBtnText: { color: colors.text, fontSize: 14, flex: 1, fontFamily: 'Menlo' },
- pickerBtnTextEmpty: { color: colors.textDim, fontStyle: 'italic', fontFamily: undefined },
- pickerArrow: { color: colors.textDim, fontSize: 20 },
- hint: { color: colors.textDim, fontSize: 11, marginTop: 6 },
peer: {
flexDirection: 'row',
alignItems: 'center',
@@ -401,16 +436,6 @@ const styles = StyleSheet.create({
},
checkboxOn: { borderColor: colors.accent, backgroundColor: colors.accent },
checkmark: { color: '#fff', fontSize: 14, fontWeight: '700' },
- safBtn: {
- marginTop: 4,
- marginBottom: 12,
- paddingVertical: 8,
- },
- safBtnText: {
- color: colors.accent,
- fontSize: 13,
- fontWeight: '500',
- },
error: { color: colors.error, fontSize: 13, marginTop: 12 },
presetRow: { flexDirection: 'row', gap: 10 },
presetChip: {
diff --git a/mobile-app/src/screens/AddFolderModal.tsx b/mobile-app/src/screens/AddFolderModal.tsx
index d35ce5a..9d831f8 100644
--- a/mobile-app/src/screens/AddFolderModal.tsx
+++ b/mobile-app/src/screens/AddFolderModal.tsx
@@ -11,6 +11,7 @@ import { isAbortError } from '../api/syncthing';
import { FolderPicker } from './FolderPicker';
import { AndroidLocalBrowser } from './AndroidLocalBrowser';
import { FolderTypePicker } from '../components/FolderTypePicker';
+import { FolderLocationPicker, type LocationKind } from '../components/FolderLocationPicker';
import {
filesystemTypeForExternal,
pickExternalFolderWithICloudWarning,
@@ -133,6 +134,15 @@ export function AddFolderModal({ visible, onClose, onAdded }: Props) {
const idValid = FOLDER_ID_RE.test(effectiveId);
const canSubmit = path.length > 0 && idValid;
+ // Which destination card is currently active (drives the unified picker UI).
+ const locationKind: LocationKind | null = !path
+ ? null
+ : !isExternal
+ ? 'app'
+ : path.startsWith('content://')
+ ? 'saf'
+ : 'device';
+
const pickExternal = () => {
try {
pickExternalFolderWithICloudWarning(folder => {
@@ -146,12 +156,10 @@ export function AddFolderModal({ visible, onClose, onAdded }: Props) {
}
};
- // Primary picker: full-screen native browser when MANAGE granted (covers
- // Download root and any other path Android's SAF picker refuses), SAF
- // otherwise. The SAF route is still exposed below as an alternate so cloud
- // and SD-card folders remain reachable.
- const pickPrimary = () => {
- if (Platform.OS === 'android' && hasAllFilesAccess) {
+ // Device card: full-screen native browser on Android (All Files Access is
+ // already confirmed by the card before this runs), document picker on iOS.
+ const pickDevice = () => {
+ if (Platform.OS === 'android') {
setLocalBrowserOpen(true);
return;
}
@@ -304,59 +312,16 @@ export function AddFolderModal({ visible, onClose, onAdded }: Props) {
submitDisabled={!canSubmit}
>
Location
-
-
- {displayPath || 'Pick a folder on this device…'}
-
- ›
-
-
- {isExternal
- ? 'Syncing directly with this device folder.'
- : path
- ? 'This folder lives inside the app sandbox and appears in the Files app.'
- : 'Pick any folder on your device, or tap below to use app storage.'}
-
- {Platform.OS === 'android' && !hasAllFilesAccess && !path && (
-
- Unlock Downloads, Pictures, and more
-
- Android's system folder picker hides Downloads and other shared folders. Grant All
- Files Access to browse them directly.
-
-
- Grant All Files Access
-
-
- )}
- {Platform.OS === 'android' && hasAllFilesAccess && !isExternal && (
-
- Pick a cloud or SD-card folder (SAF)
-
- )}
- {isExternal ? (
- {
- setIsExternal(false);
- setPath('');
- setExternalDisplayName('');
- }}
- >
- Use app storage instead
-
- ) : (
- setPickerOpen(true)}
- disabled={!pickerRoot}
- >
- Use app storage instead
-
- )}
+ setPickerOpen(true)}
+ onPickDevice={pickDevice}
+ onPickSaf={pickExternal}
+ onRequestAllFilesAccess={requestAllFilesAccess}
+ />
{path && (
@@ -543,23 +508,6 @@ const styles = StyleSheet.create({
textTransform: 'uppercase',
marginBottom: 8,
},
- pickerBtn: {
- backgroundColor: colors.card,
- borderRadius: 10,
- paddingHorizontal: 14,
- paddingVertical: 14,
- borderWidth: 1,
- borderColor: colors.border,
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- gap: 8,
- },
- pickerBtnEmpty: { borderStyle: 'dashed' },
- pickerBtnText: { color: colors.text, fontSize: 14, flex: 1, fontFamily: 'Menlo' },
- pickerBtnTextEmpty: { color: colors.textDim, fontStyle: 'italic', fontFamily: undefined },
- pickerArrow: { color: colors.textDim, fontSize: 20 },
- hint: { color: colors.textDim, fontSize: 11, marginTop: 6, marginBottom: 16 },
summary: {
backgroundColor: colors.card,
borderRadius: 10,
@@ -604,49 +552,6 @@ const styles = StyleSheet.create({
advancedToggle: { marginTop: 18, paddingVertical: 8 },
advancedToggleText: { color: colors.textDim, fontSize: 13, fontWeight: '500' },
advanced: { marginTop: 8 },
- safBtn: {
- marginTop: 4,
- marginBottom: 12,
- paddingVertical: 8,
- },
- safBtnText: {
- color: colors.accent,
- fontSize: 13,
- fontWeight: '500',
- },
- permBanner: {
- backgroundColor: colors.card,
- borderRadius: 10,
- borderWidth: 1,
- borderColor: colors.border,
- padding: 12,
- marginTop: 4,
- marginBottom: 12,
- gap: 8,
- },
- permBannerTitle: {
- color: colors.text,
- fontSize: 13,
- fontWeight: '600',
- },
- permBannerText: {
- color: colors.textDim,
- fontSize: 12,
- lineHeight: 17,
- },
- permBannerBtn: {
- alignSelf: 'flex-start',
- paddingVertical: 6,
- paddingHorizontal: 12,
- borderRadius: 6,
- borderWidth: 1,
- borderColor: colors.accent,
- },
- permBannerBtnText: {
- color: colors.accent,
- fontSize: 12,
- fontWeight: '600',
- },
error: { color: colors.error, fontSize: 13, marginTop: 8 },
presetRow: { flexDirection: 'row', gap: 10 },
presetChip: {