feat(sync): device-to-device backup of favorites & state
Some checks are pending
Backend tests / test (push) Waiting to run
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:
parent
92adaccf1b
commit
2cfb0bc12d
4 changed files with 166 additions and 1 deletions
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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}`, {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue