feat(sync): device-to-device backup of favorites & state
Some checks are pending
Backend tests / test (push) Waiting to run

No-accounts app, so favorites/saved-searches/hidden-list/watch-progress live
per-device (keyed by X-Device-Id). Users asked how to move them to a new phone.

Backend: POST /me/import/{source_device} copies all device-scoped tables from a
source device to the caller (dedup via NOT EXISTS, new UUIDs for id-PK tables like
saved_searches). Copy not move, so the old phone keeps everything; idempotent. The
device_id is a random UUID so knowing it is the authorization (fine for a keyless app).

Mobile: Settings -> "Backup & sync" shows this device's sync code (copy button) and a
Restore field to paste the other device's code; invalidates favorites queries after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-07-14 22:41:35 +03:00
parent 92adaccf1b
commit 2cfb0bc12d
4 changed files with 166 additions and 1 deletions

View file

@ -41,6 +41,60 @@ class AdoptLegacyOut(BaseModel):
moved: dict[str, int]
class ImportOut(BaseModel):
device_id: str
imported: dict[str, int]
# Tabele do przeniesienia przy transferze między urządzeniami + kolumna-encji do dedupu.
# saved_searches ma własne `id` PK (nie (device_id,encja)), więc dedup po `query`.
_COPY_TABLES: list[tuple[str, str]] = _TABLES + [("saved_searches", "query")]
@router.post("/import/{source_device}", response_model=ImportOut)
def import_from_device(
source_device: str,
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> ImportOut:
"""Backup/transfer stanu (ulubione/blacklisty/progres/zapisane wyszukiwania) z innego
urządzenia na wołające. `source_device` to device_id starego telefonu (pełni rolę kodu
transferu jest losowym UUID, więc jego znajomość == autoryzacja; instancja bez kont).
KOPIUJE (nie przenosi): stary telefon zachowuje swoje. Dedup: pomija encje które
urządzenie już ma. Idempotentne. `id`-PK (saved_searches) dostaje nowy gen_random_uuid()."""
imported: dict[str, int] = {}
source = (source_device or "").strip()[:64]
if not source or source == device_id or source == LEGACY_DEVICE:
return ImportOut(device_id=device_id, imported={})
for table, dedup_col in _COPY_TABLES:
cols = [
r[0]
for r in session.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = :t ORDER BY ordinal_position"
).bindparams(t=table)
).all()
]
if not cols:
continue
select_exprs = ", ".join(
":dev" if c == "device_id" else ("gen_random_uuid()" if c == "id" else f"src.{c}")
for c in cols
)
sql = (
f"INSERT INTO {table} ({', '.join(cols)}) "
f"SELECT {select_exprs} FROM {table} src "
f"WHERE src.device_id = :src AND NOT EXISTS ("
f" SELECT 1 FROM {table} t WHERE t.device_id = :dev AND t.{dedup_col} = src.{dedup_col})"
)
res = session.execute(text(sql).bindparams(dev=device_id, src=source))
imported[table] = res.rowcount or 0
session.commit()
return ImportOut(device_id=device_id, imported=imported)
@router.post("/adopt-legacy", response_model=AdoptLegacyOut)
def adopt_legacy(
session: Annotated[Session, Depends(get_session)],

View file

@ -61,6 +61,14 @@ export class GoonClient {
return this.request('/me/adopt-legacy', { method: 'POST' });
}
// Backup/transfer: skopiuj stan (ulubione/blacklisty/progres/zapisane) z innego
// urządzenia (jego device_id = kod transferu) na to. Nie rusza starego telefonu.
async importFromDevice(
sourceCode: string,
): Promise<{ device_id: string; imported: Record<string, number> }> {
return this.request(`/me/import/${encodeURIComponent(sourceCode)}`, { method: 'POST' });
}
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const auth = await this._authHeaders();
const res = await fetch(`${this.baseUrl}${path}`, {

View file

@ -16,6 +16,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-07-14b',
date: 'July 2026',
items: [
'Backup & sync: move your favorites, saved searches, hidden list and watch progress to another phone. Settings -> Backup & sync: copy this phone\'s sync code, then paste it into Restore on the new phone (your old phone keeps everything).',
],
},
{
id: '2026-07-14',
date: 'July 2026',

View file

@ -8,8 +8,13 @@ import {
StyleSheet,
Switch,
Text,
TextInput,
View,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { useQueryClient } from '@tanstack/react-query';
import { useClient } from '../ClientContext';
import { getDeviceId } from '../storage';
import {
AppLockSettings,
biometricAvailable,
@ -59,17 +64,57 @@ export function AppLockSettingsScreen() {
const [errorText, setErrorText] = useState<string | null>(null);
const { gridColumns, setGridColumns, defaultQuality, setDefaultQuality } = usePreferences();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const client = useClient();
const queryClient = useQueryClient();
const [deviceId, setDeviceId] = useState('');
const [importCode, setImportCode] = useState('');
const [importing, setImporting] = useState(false);
async function refresh() {
const [s, bio] = await Promise.all([getSettings(), biometricAvailable()]);
const [s, bio, dev] = await Promise.all([getSettings(), biometricAvailable(), getDeviceId()]);
setSettings(s);
setBioAvailable(bio);
setDeviceId(dev);
}
useEffect(() => {
refresh();
}, []);
const copyCode = async () => {
await Clipboard.setStringAsync(deviceId);
Alert.alert('Copied', 'Your sync code is on the clipboard. Paste it on your other device.');
};
const runImport = async () => {
const code = importCode.trim();
if (!code) return;
if (code === deviceId) {
Alert.alert('That is this device', 'Enter the sync code from your OTHER device.');
return;
}
setImporting(true);
try {
const res = await client.importFromDevice(code);
const total = Object.values(res.imported || {}).reduce((a, b) => a + b, 0);
// Odśwież wszystkie widoki stanu, żeby zaimportowane od razu było widać.
['favorites', 'favorites-scenes', 'favorites-movies', 'favorites-studios'].forEach((k) =>
queryClient.invalidateQueries({ queryKey: [k] }),
);
setImportCode('');
Alert.alert(
total > 0 ? 'Imported' : 'Nothing new',
total > 0
? `Added ${total} item(s) from the other device (favorites, saved searches, hidden list, watch progress).`
: 'That device had nothing you do not already have.',
);
} catch {
Alert.alert('Import failed', 'Check the code and your connection, then try again.');
} finally {
setImporting(false);
}
};
if (!settings) {
return (
<View style={styles.center}>
@ -331,6 +376,45 @@ export function AppLockSettingsScreen() {
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Backup & sync</Text>
<Text style={styles.hint}>
No accounts here, so your favorites, saved searches, hidden list and watch progress
live on this device. To move them to another phone: copy this device's sync code, then
paste it into "Restore" on the other phone. Copying, not moving, so this phone keeps
everything.
</Text>
<Pressable style={styles.row} onPress={copyCode}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Your sync code</Text>
<Text style={styles.hint} numberOfLines={1}>
{deviceId || '…'}
</Text>
</View>
<Text style={styles.linkValue}>Copy</Text>
</Pressable>
<View style={{ marginTop: 12 }}>
<Text style={styles.label}>Restore from another device</Text>
<TextInput
style={styles.codeInput}
value={importCode}
onChangeText={setImportCode}
placeholder="paste the other device's sync code"
placeholderTextColor={theme.mutedDim}
autoCapitalize="none"
autoCorrect={false}
editable={!importing}
/>
<Pressable
style={[styles.actionBtn, (importing || !importCode.trim()) && { opacity: 0.5 }]}
disabled={importing || !importCode.trim()}
onPress={runImport}
>
<Text style={styles.actionText}>{importing ? 'Importing…' : 'Import'}</Text>
</Pressable>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>About</Text>
<View style={styles.row}>
@ -420,6 +504,18 @@ const styles = StyleSheet.create({
chipActive: { backgroundColor: theme.accent, borderColor: theme.accent },
versionValue: { color: theme.fg, fontSize: 15, fontWeight: '700', fontVariant: ['tabular-nums'] },
linkValue: { color: theme.accent, fontSize: 15, fontWeight: '700' },
codeInput: {
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 8,
color: theme.fg,
fontSize: 13,
paddingHorizontal: 12,
paddingVertical: 10,
marginTop: 8,
marginBottom: 10,
},
chipText: { color: theme.muted, fontSize: 13 },
chipTextActive: { color: '#fff', fontWeight: '700' },
});