goon/mobile/src/components/MigrationBanner.tsx
goon-foss 0c818ac1df feat(mobile): baner migracji na nowo podpisane APK + expo-keep-awake do buildu
Klucz podpisujacy stare buildy przepadl, wiec update in-place jest niemozliwy:
Android nadpisze aplikacje tylko buildem z tym samym podpisem. Odinstalowanie
czysci SecureStore, a w nim goon.device_id, czyli kod synchronizacji, bez ktorego
ulubione/postep zostaja na serwerze bez sposobu wskazania ich. Baner pokazuje kod
na wierzchu i prowadzi przez kroki (kopiuj kod, pobierz, odinstaluj, wklej kod).

expo-keep-awake: sprawdzenie stringow w dexach APK 0.2.1 potwierdzilo, ze modulu
ExpoKeepAwake tam nie ma (jest tylko interfejs z expo-modules-core), wiec ekranu
gasnacego w trakcie ogladania NIE dalo sie naprawic przez OTA. Teraz jawna
zaleznosc, autolinking ja widzi, wchodzi do APK 0.2.2 (runtime 1.2).

UWAGA przy publikacji banera na runtime 1.1: manifest bierze runtimeVersion z
argumentu --runtime, wiec 1.1 jest serwowane poprawnie mimo app.json=1.2, ale
extras nadal niosa app.json (APP_VERSION zaczyna raportowac 0.2.2). Dlatego
serwerowe /version trzeba bumpnac do 0.2.2 razem z wgraniem APK, inaczej stary
dialog Update available wyskoczy z mylacym Server 0.2.1 / App 0.2.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:17:30 +02:00

226 lines
8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Baner jednorazowej migracji na nowo podpisane APK.
*
* Dlaczego w ogóle: klucz podpisujący stare buildy przepadł, a Android pozwala nadpisać
* aplikację tylko buildem z tym samym podpisem. Nowa wersja wymaga więc odinstalowania
* starej, co czyści SecureStore razem z `goon.device_id` — czyli kodem synchronizacji.
* Dane (ulubione, zapisane wyszukiwania, ukryte, postęp) zostają na serwerze pod tym id,
* ale bez kodu nie ma jak ich odzyskać. Dlatego kod jest w banerze na wierzchu, a nie
* schowany w ustawieniach: liczy się, żeby user skopiował go PRZED odinstalowaniem.
*
* Zwinięty pasek siedzi nad listą scen (nie w ListHeaderComponent — tam znikałby przy
* wyszukiwaniu i odjeżdżał przy scrollu). Rozwija się w modal z krokami.
*/
import React from 'react';
import { Linking, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { MIGRATION_NOTICE } from '../migrationNotice';
import {
dismissMigrationNotice,
getDeviceId,
isMigrationNoticeHidden,
snoozeMigrationNotice,
} from '../storage';
import { theme } from '../theme';
export function MigrationBanner() {
const [show, setShow] = React.useState(false);
const [open, setOpen] = React.useState(false);
const [deviceId, setDeviceId] = React.useState('');
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!MIGRATION_NOTICE.enabled) return;
let cancelled = false;
(async () => {
try {
const [hidden, id] = await Promise.all([isMigrationNoticeHidden(), getDeviceId()]);
if (cancelled || hidden) return;
setDeviceId(id);
setShow(true);
} catch {
// brak SecureStore → nie ma czym migrować, nie strasz banerem
}
})();
return () => {
cancelled = true;
};
}, []);
const copy = React.useCallback(async () => {
await Clipboard.setStringAsync(deviceId);
setCopied(true);
}, [deviceId]);
const later = React.useCallback(() => {
setOpen(false);
setShow(false);
snoozeMigrationNotice().catch(() => {});
}, []);
const done = React.useCallback(() => {
setOpen(false);
setShow(false);
dismissMigrationNotice().catch(() => {});
}, []);
if (!show) return null;
return (
<>
<Pressable style={styles.bar} onPress={() => setOpen(true)}>
<View style={{ flex: 1 }}>
<Text style={styles.barTitle}>Update {MIGRATION_NOTICE.version} needs a manual install</Text>
<Text style={styles.barHint}>
Copy your sync code first, or your favourites do not come across. Tap for the steps.
</Text>
</View>
<Text style={styles.barChevron}>›</Text>
</Pressable>
<Modal visible={open} transparent animationType="fade" onRequestClose={later}>
<View style={styles.backdrop}>
<View style={styles.card}>
<Text style={styles.title}>Update {MIGRATION_NOTICE.version}</Text>
<ScrollView style={styles.scroll} contentContainerStyle={{ paddingBottom: 4 }}>
<Text style={styles.lead}>
This one has to be installed by hand: the new build is signed with a different
key, so Android will not put it on top of the current app. You uninstall Goon
first, then install the download. That wipes what is stored on the phone, so
copy your sync code now and paste it back afterwards.
</Text>
<Text style={styles.step}>1. Copy your sync code</Text>
<Pressable style={styles.codeRow} onPress={copy}>
<Text style={styles.code} numberOfLines={1}>
{deviceId || '…'}
</Text>
<Text style={styles.copyBtn}>{copied ? 'Copied' : 'Copy'}</Text>
</Pressable>
<Text style={styles.note}>
Paste it somewhere safe (notes, a message to yourself). Without it your
favourites, saved searches, hidden list and watch progress cannot be found again.
</Text>
<Text style={styles.step}>2. Download the new version</Text>
<Pressable
style={styles.dlBtn}
onPress={() => Linking.openURL(MIGRATION_NOTICE.downloadUrl).catch(() => {})}
>
<Text style={styles.dlText}>Open {MIGRATION_NOTICE.downloadUrl.replace(/^https?:\/\//, '').replace(/\/$/, '')}</Text>
</Pressable>
<Text style={styles.step}>3. Uninstall Goon, then open the downloaded file</Text>
<Text style={styles.note}>
Long-press the app icon, Uninstall. Then open the .apk from your notifications
or Downloads. If Android asks, allow installing from your browser.
</Text>
<Text style={styles.step}>4. Paste the code back</Text>
<Text style={styles.note}>
Settings, Backup &amp; sync, Restore from another device: paste the code, tap
Import. Everything comes back.
</Text>
<Text style={styles.sorry}>
Sorry for the hassle. Signing keys cannot be recovered, so this is a one-off:
updates after this one go back to installing themselves.
</Text>
</ScrollView>
<Pressable style={styles.primaryBtn} onPress={done}>
<Text style={styles.primaryText}>Done, do not show again</Text>
</Pressable>
<Pressable style={styles.secondaryBtn} onPress={later}>
<Text style={styles.secondaryText}>Remind me tomorrow</Text>
</Pressable>
</View>
</View>
</Modal>
</>
);
}
const styles = StyleSheet.create({
bar: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
marginHorizontal: 12,
marginBottom: 10,
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: theme.accent,
backgroundColor: theme.bgElevated,
},
barTitle: { color: theme.fg, fontSize: 14, fontWeight: '700' },
barHint: { color: theme.muted, fontSize: 12, marginTop: 2, lineHeight: 16 },
barChevron: { color: theme.accent, fontSize: 22, fontWeight: '700' },
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.7)',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
card: {
width: '100%',
maxHeight: '85%',
backgroundColor: theme.bgElevated,
borderRadius: 16,
borderColor: theme.border,
borderWidth: 1,
padding: 20,
},
title: { color: theme.fg, fontSize: 20, fontWeight: '800', marginBottom: 12 },
scroll: { flexGrow: 0 },
lead: { color: theme.fg, fontSize: 14, lineHeight: 20, marginBottom: 16 },
step: {
color: theme.accent,
fontSize: 12,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginTop: 8,
marginBottom: 8,
},
codeRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: theme.card,
borderRadius: 10,
borderWidth: 1,
borderColor: theme.border,
paddingVertical: 10,
paddingHorizontal: 12,
},
code: { color: theme.fg, fontSize: 13, flex: 1 },
copyBtn: { color: theme.accent, fontSize: 13, fontWeight: '800' },
note: { color: theme.muted, fontSize: 13, lineHeight: 19, marginTop: 8 },
dlBtn: {
backgroundColor: theme.card,
borderRadius: 10,
borderWidth: 1,
borderColor: theme.accent,
paddingVertical: 12,
alignItems: 'center',
},
dlText: { color: theme.fg, fontSize: 14, fontWeight: '700' },
sorry: { color: theme.mutedDim, fontSize: 12, lineHeight: 18, marginTop: 18 },
primaryBtn: {
marginTop: 14,
backgroundColor: theme.accent,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
},
primaryText: { color: theme.fg, fontSize: 15, fontWeight: '700' },
secondaryBtn: { marginTop: 8, paddingVertical: 10, alignItems: 'center' },
secondaryText: { color: theme.muted, fontSize: 14, fontWeight: '600' },
});