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]
|
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)
|
@router.post("/adopt-legacy", response_model=AdoptLegacyOut)
|
||||||
def adopt_legacy(
|
def adopt_legacy(
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,14 @@ export class GoonClient {
|
||||||
return this.request('/me/adopt-legacy', { method: 'POST' });
|
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> {
|
private async request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const auth = await this._authHeaders();
|
const auth = await this._authHeaders();
|
||||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,13 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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',
|
id: '2026-07-14',
|
||||||
date: 'July 2026',
|
date: 'July 2026',
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,13 @@ import {
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useClient } from '../ClientContext';
|
||||||
|
import { getDeviceId } from '../storage';
|
||||||
import {
|
import {
|
||||||
AppLockSettings,
|
AppLockSettings,
|
||||||
biometricAvailable,
|
biometricAvailable,
|
||||||
|
|
@ -59,17 +64,57 @@ export function AppLockSettingsScreen() {
|
||||||
const [errorText, setErrorText] = useState<string | null>(null);
|
const [errorText, setErrorText] = useState<string | null>(null);
|
||||||
const { gridColumns, setGridColumns, defaultQuality, setDefaultQuality } = usePreferences();
|
const { gridColumns, setGridColumns, defaultQuality, setDefaultQuality } = usePreferences();
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
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() {
|
async function refresh() {
|
||||||
const [s, bio] = await Promise.all([getSettings(), biometricAvailable()]);
|
const [s, bio, dev] = await Promise.all([getSettings(), biometricAvailable(), getDeviceId()]);
|
||||||
setSettings(s);
|
setSettings(s);
|
||||||
setBioAvailable(bio);
|
setBioAvailable(bio);
|
||||||
|
setDeviceId(dev);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
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) {
|
if (!settings) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.center}>
|
<View style={styles.center}>
|
||||||
|
|
@ -331,6 +376,45 @@ export function AppLockSettingsScreen() {
|
||||||
</View>
|
</View>
|
||||||
</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}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>About</Text>
|
<Text style={styles.sectionTitle}>About</Text>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
|
|
@ -420,6 +504,18 @@ const styles = StyleSheet.create({
|
||||||
chipActive: { backgroundColor: theme.accent, borderColor: theme.accent },
|
chipActive: { backgroundColor: theme.accent, borderColor: theme.accent },
|
||||||
versionValue: { color: theme.fg, fontSize: 15, fontWeight: '700', fontVariant: ['tabular-nums'] },
|
versionValue: { color: theme.fg, fontSize: 15, fontWeight: '700', fontVariant: ['tabular-nums'] },
|
||||||
linkValue: { color: theme.accent, fontSize: 15, fontWeight: '700' },
|
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 },
|
chipText: { color: theme.muted, fontSize: 13 },
|
||||||
chipTextActive: { color: '#fff', fontWeight: '700' },
|
chipTextActive: { color: '#fff', fontWeight: '700' },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue