From de0b4546973e3168c41589adae5034cebafa3cf7 Mon Sep 17 00:00:00 2001 From: Grigorii Horos Date: Fri, 19 Jun 2026 14:36:57 +0300 Subject: [PATCH] ui: unify folder-location picker across add/accept flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three ways to choose where a folder lives — app sandbox, a device folder (All Files Access), and a SAF tree — were each rendered with different controls, so the same decision looked unrelated in each place. Extract a single FolderLocationPicker that presents all three as uniform radio cards (SAF Android-only; the device card prompts for All Files Access when it isn't granted yet) and use it in both AddFolderModal and AcceptFolderModal. Removes the now-dead bespoke picker styles from both screens. Co-Authored-By: Claude Opus 4.8 --- .../src/components/FolderLocationPicker.tsx | 158 ++++++++++++++++++ mobile-app/src/screens/AcceptFolderModal.tsx | 155 ++++++++++------- mobile-app/src/screens/AddFolderModal.tsx | 143 +++------------- 3 files changed, 272 insertions(+), 184 deletions(-) create mode 100644 mobile-app/src/components/FolderLocationPicker.tsx 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: {