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
158 changes: 158 additions & 0 deletions mobile-app/src/components/FolderLocationPicker.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={styles.container}>
<Card
on={selected === 'app'}
disabled={appDisabled}
title="App storage"
hint="Inside the app sandbox · visible in the Files app"
chosen={selected === 'app' ? chosenLabel : undefined}
onPress={onPickApp}
/>

<Card
on={selected === 'device'}
title={isAndroid ? 'Folder on this device' : 'Pick a folder'}
hint={
isAndroid
? deviceLocked
? 'DCIM, Downloads, Pictures… — needs All Files Access'
: 'DCIM, Downloads, Pictures…'
: 'Any folder, via the Files app'
}
chosen={selected === 'device' ? chosenLabel : undefined}
actionLabel={deviceLocked ? 'Grant access' : undefined}
showChevron={!deviceLocked}
onPress={deviceLocked ? onRequestAllFilesAccess : onPickDevice}
/>

{isAndroid && (
<Card
on={selected === 'saf'}
title="Cloud or SD-card (SAF)"
hint="Google Drive, SD card, other providers"
chosen={selected === 'saf' ? chosenLabel : undefined}
showChevron
onPress={onPickSaf}
/>
)}
</View>
);
}

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 (
<Focusable
style={[styles.row, on && styles.rowOn, disabled && styles.rowDisabled]}
onPress={() => !disabled && onPress()}
disabled={disabled}
activeOpacity={0.7}
>
<View style={[styles.radio, on && styles.radioOn]}>
{on && <View style={styles.radioInner} />}
</View>
<View style={styles.text}>
<Text style={[styles.label, on && styles.labelOn]}>{title}</Text>
{chosen ? (
<Text style={styles.chosen} numberOfLines={2}>
{chosen}
</Text>
) : (
<Text style={styles.hint}>{hint}</Text>
)}
</View>
{actionLabel ? (
<Text style={styles.action}>{actionLabel}</Text>
) : showChevron ? (
<Text style={styles.chevron}>›</Text>
) : null}
</Focusable>
);
}

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 },
});
155 changes: 90 additions & 65 deletions mobile-app/src/screens/AcceptFolderModal.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<DeviceConfig[]>([]);
const [extraPeers, setExtraPeers] = useState<Set<string>>(new Set());
const [folderType, setFolderType] = useState<FolderConfig['type']>('sendreceive');
Expand All @@ -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');
Expand All @@ -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 = () => {
Expand All @@ -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';
Expand All @@ -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(
Expand Down Expand Up @@ -184,7 +254,7 @@ export function AcceptFolderModal({ visible, offer, onClose, onAccepted }: Props
return (
<>
<FormModal
visible={visible && !pickerOpen}
visible={visible && !pickerOpen && !localBrowserOpen}
title="Accept folder"
onCancel={cancel}
onSubmit={submit}
Expand All @@ -199,42 +269,16 @@ export function AcceptFolderModal({ visible, offer, onClose, onAccepted }: Props
</View>

<Text style={styles.sectionLabel}>Where to store it</Text>
<Focusable
style={[styles.pickerBtn, !path && styles.pickerBtnEmpty]}
onPress={pickExternal}
>
<Text style={[styles.pickerBtnText, !path && styles.pickerBtnTextEmpty]} numberOfLines={2}>
{displayPath || 'Pick a folder on this device…'}
</Text>
<Text style={styles.pickerArrow}>›</Text>
</Focusable>
<Text style={styles.hint}>
{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.'}
</Text>
{isExternal ? (
<Focusable
style={styles.safBtn}
onPress={() => {
setIsExternal(false);
setPath('');
setExternalDisplayName('');
}}
>
<Text style={styles.safBtnText}>Use app storage instead</Text>
</Focusable>
) : (
<Focusable
style={styles.safBtn}
onPress={() => setPickerOpen(true)}
disabled={!pickerRoot}
>
<Text style={styles.safBtnText}>Use app storage instead</Text>
</Focusable>
)}
<FolderLocationPicker
selected={locationKind}
chosenLabel={displayPath}
hasAllFilesAccess={hasAllFilesAccess}
appDisabled={!pickerRoot}
onPickApp={() => setPickerOpen(true)}
onPickDevice={pickDevice}
onPickSaf={pickExternal}
onRequestAllFilesAccess={requestAllFilesAccess}
/>

<Text style={[styles.sectionLabel, { marginTop: 20 }]}>Folder kind</Text>
<View style={styles.presetRow}>
Expand Down Expand Up @@ -292,6 +336,14 @@ export function AcceptFolderModal({ visible, offer, onClose, onAccepted }: Props
setPickerOpen(false);
}}
/>

{Platform.OS === 'android' && (
<AndroidLocalBrowser
visible={localBrowserOpen}
onCancel={() => setLocalBrowserOpen(false)}
onPick={onLocalBrowserPick}
/>
)}
</>
);
}
Expand Down Expand Up @@ -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',
Expand All @@ -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: {
Expand Down
Loading