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>
This commit is contained in:
goon-foss 2026-08-11 09:17:30 +02:00
parent 72b7b561e5
commit 0c818ac1df
10 changed files with 307 additions and 24 deletions

View file

@ -93,8 +93,8 @@ android {
applicationId 'com.goon.mobile'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 11
versionName "0.2.1"
versionCode 12
versionName "0.2.2"
}
signingConfigs {
debug {

View file

@ -2,11 +2,11 @@
"expo": {
"name": "goon",
"slug": "goon",
"version": "0.2.1",
"version": "0.2.2",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"newArchEnabled": false,
"runtimeVersion": "1.1",
"runtimeVersion": "1.2",
"updates": {
"enabled": true,
"url": "https://api.goon-foss.org/expo-updates/manifest",

View file

@ -20,6 +20,7 @@
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.7",
"expo-intent-launcher": "~12.0.2",
"expo-keep-awake": "^14.0.3",
"expo-linear-gradient": "~14.0.2",
"expo-local-authentication": "~15.0.2",
"expo-screen-capture": "~7.0.1",
@ -6301,6 +6302,16 @@
"integrity": "sha512-xjGfK9dL0B1wLnOqNkX0jM9p48Y0I5xEPzHude28LY67UmamUyAACkqhZGaPClyPNfdzczk7Ej6WaRMT3HfXvw==",
"license": "MIT"
},
"node_modules/expo-keep-awake": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.0.3.tgz",
"integrity": "sha512-6Jh94G6NvTZfuLnm2vwIpKe3GdOiVBuISl7FI8GqN0/9UOg9E0WXXp5cDcfAG8bn80RfgLJS8P7EPUGTZyOvhg==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*"
}
},
"node_modules/expo-linear-gradient": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-14.0.2.tgz",
@ -6514,16 +6525,6 @@
"react-native": "*"
}
},
"node_modules/expo/node_modules/expo-keep-awake": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.0.3.tgz",
"integrity": "sha512-6Jh94G6NvTZfuLnm2vwIpKe3GdOiVBuISl7FI8GqN0/9UOg9E0WXXp5cDcfAG8bn80RfgLJS8P7EPUGTZyOvhg==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*"
}
},
"node_modules/exponential-backoff": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",

View file

@ -22,6 +22,7 @@
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.7",
"expo-intent-launcher": "~12.0.2",
"expo-keep-awake": "^14.0.3",
"expo-linear-gradient": "~14.0.2",
"expo-local-authentication": "~15.0.2",
"expo-screen-capture": "~7.0.1",

View file

@ -0,0 +1,226 @@
/**
* 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' },
});

View file

@ -17,4 +17,4 @@ import Constants from 'expo-constants';
* też nie idzie do góry, więc consistency jest zachowana.
*/
export const APP_VERSION: string =
(Constants.expoConfig?.version as string | undefined) || '0.1.9';
(Constants.expoConfig?.version as string | undefined) || '0.2.2';

View file

@ -3,16 +3,17 @@
* `c40fb79f`: "ekran gaśnie w trakcie oglądania" — Android wygasza ekran po timeoucie,
* bo dotyk nie pada, a samo granie wideo nie trzyma wake-locka).
*
* Dlaczego nie `expo-keep-awake` z importu: pakiet NIE jest w top-level `node_modules`
* (siedzi w `node_modules/expo/node_modules/`), więc metro nie rozwiąże go z naszego
* kodu, a dokładanie zależności do package.json nie przeszłoby przez OTA gdyby wersja
* rozjechała się z APK. Natywny moduł `ExpoKeepAwake` (activate/deactivate po tagu) JEST
* jednak w buildzie, bo `expo` bundluje go i autolinkuje — więc sięgamy po niego wprost
* przez `requireNativeModule` z `expo-modules-core` (ten jest top-level). Dzięki temu
* całość leci OTA, bez nowego APK.
* Historia, żeby nikt nie powtórzył błędu: pierwsza wersja zakładała, że natywny moduł
* `ExpoKeepAwake` jest już w APK, bo `expo-keep-awake` leży w `node_modules/expo/
* node_modules/`. Sprawdzenie stringów w dexach APK 0.2.1 pokazało coś innego — jest tam
* tylko interfejs `expo.modules.core.interfaces.services.KeepAwakeManager` z
* expo-modules-core, samego modułu nie ma, bo zagnieżdżona kopia nie została
* zautolinkowana. Czyli przez OTA tego się NIE dało naprawić. Dlatego `expo-keep-awake`
* jest teraz jawną zależnością w package.json i wchodzi do APK 0.2.2 (runtime 1.2).
*
* Wszystko best-effort: gdyby modułu zabrakło (inny build), no-op zamiast wywalenia
* playera.
* Sięgamy po moduł przez `requireNativeModule`, a nie zwykłym importem: dzięki temu
* bundle OTA nie jest związany z wersją paczki JS i ten sam kod działa na buildach,
* które modułu nie mają. Wszystko best-effort — brak modułu to no-op, nie wywalony player.
*/
import { requireNativeModule } from 'expo-modules-core';

View file

@ -0,0 +1,23 @@
/**
* Konfiguracja banera migracyjnego (MigrationBanner).
*
* Kontekst: klucz podpisujący release APK (v0.2.1 i wcześniejsze) został utracony.
* Androidowy update in-place wymaga TEGO SAMEGO podpisu, więc kolejnej wersji nie da
* się już zainstalować na wierzchu — trzeba odinstalować starą i zainstalować nową.
* Odinstalowanie kasuje SecureStore, a w nim `goon.device_id`, czyli kod synchronizacji.
* Bez skopiowanego kodu ulubione, historia i postęp oglądania stają się nieosiągalne
* (dane leżą na serwerze pod tym id, ale nie ma jak ich wskazać).
*
* Stąd baner: pokazuje kod PRZED odinstalowaniem i prowadzi przez kolejne kroki.
*
* Trafia tylko do APK na runtime 1.1. Nowe APK ma runtime 1.2, czyli inny kanał OTA,
* więc nigdy tego banera nie zobaczy — nie trzeba go potem wyłączać zdalnie.
*/
export const MIGRATION_NOTICE = {
/** Master switch — false chowa baner całkowicie (publikacja OTA na runtime 1.1). */
enabled: true,
/** Wersja do zainstalowania. Pokazywana w banerze. */
version: '0.2.2',
/** Strona pobierania. Celowo strona, nie plik: link przeżyje zmianę nazwy APK. */
downloadUrl: 'https://goon-foss.org/',
} as const;

View file

@ -15,6 +15,7 @@ import {
} from 'react-native';
import { Image } from 'expo-image';
import * as Haptics from 'expo-haptics';
import { MigrationBanner } from '../components/MigrationBanner';
import { SceneTile, sceneGridProps } from '../components/SceneTile';
import { SceneGridSkeleton } from '../components/SceneGridSkeleton';
import { Thumb } from '../components/Thumb';
@ -215,6 +216,10 @@ export function ScenesScreen() {
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
{/* Nad listą, nie w ListHeaderComponent: tam znikałby przy wyszukiwaniu i odjeżdżał
przy scrollu, a to komunikat, którego przegapienie kosztuje usera ulubione. */}
<MigrationBanner />
{isLoading ? (
<SceneGridSkeleton count={8} />
) : (

View file

@ -121,6 +121,32 @@ export async function setAutoRotate(on: boolean): Promise<void> {
await SecureStore.setItemAsync(AUTO_ROTATE_KEY, on ? '1' : '0');
}
// Baner migracji na nowy podpis APK (patrz `migrationNotice.ts`). Trzymamy moment
// odłożenia „na później", nie zwykłą flagę widziano/nie-widziano: to komunikat, który
// MUSI wrócić, bo bez przeniesienia kodu synchronizacji user traci ulubione. Wraca po
// dobie, chyba że wybierze „Nie pokazuj więcej".
const MIGRATION_SNOOZE_KEY = 'goon.migration_snoozed_at';
const MIGRATION_DONE_KEY = 'goon.migration_dismissed';
const MIGRATION_SNOOZE_MS = 24 * 60 * 60 * 1000;
export async function isMigrationNoticeHidden(): Promise<boolean> {
if ((await SecureStore.getItemAsync(MIGRATION_DONE_KEY)) === '1') return true;
const at = await SecureStore.getItemAsync(MIGRATION_SNOOZE_KEY);
if (!at) return false;
const ts = Number(at);
// Zegar cofnięty ręcznie / śmieć w store → traktuj jak brak odłożenia (pokaż baner).
if (!Number.isFinite(ts)) return false;
return Date.now() - ts < MIGRATION_SNOOZE_MS;
}
export async function snoozeMigrationNotice(): Promise<void> {
await SecureStore.setItemAsync(MIGRATION_SNOOZE_KEY, String(Date.now()));
}
export async function dismissMigrationNotice(): Promise<void> {
await SecureStore.setItemAsync(MIGRATION_DONE_KEY, '1');
}
export interface Credentials {
baseUrl: string;
apiKey: string;