From 0c818ac1dfb374c6d2562309613e40f044998dfd Mon Sep 17 00:00:00 2001 From: goon-foss Date: Tue, 11 Aug 2026 09:17:30 +0200 Subject: [PATCH] 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 --- mobile/android/app/build.gradle | 4 +- mobile/app.json | 4 +- mobile/package-lock.json | 21 +- mobile/package.json | 1 + mobile/src/components/MigrationBanner.tsx | 226 ++++++++++++++++++++++ mobile/src/lib/appVersion.ts | 2 +- mobile/src/lib/keepAwake.ts | 19 +- mobile/src/migrationNotice.ts | 23 +++ mobile/src/screens/ScenesScreen.tsx | 5 + mobile/src/storage.ts | 26 +++ 10 files changed, 307 insertions(+), 24 deletions(-) create mode 100644 mobile/src/components/MigrationBanner.tsx create mode 100644 mobile/src/migrationNotice.ts diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index b4a6d13..161bd53 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -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 { diff --git a/mobile/app.json b/mobile/app.json index f49b503..53e4a6d 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -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", diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 88b0278..9348f39 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -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", diff --git a/mobile/package.json b/mobile/package.json index 3381c14..55ee5db 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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", diff --git a/mobile/src/components/MigrationBanner.tsx b/mobile/src/components/MigrationBanner.tsx new file mode 100644 index 0000000..1daf234 --- /dev/null +++ b/mobile/src/components/MigrationBanner.tsx @@ -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 ( + <> + setOpen(true)}> + + Update {MIGRATION_NOTICE.version} needs a manual install + + Copy your sync code first, or your favourites do not come across. Tap for the steps. + + + › + + + + + + Update {MIGRATION_NOTICE.version} + + + 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. + + + 1. Copy your sync code + + + {deviceId || '…'} + + {copied ? 'Copied' : 'Copy'} + + + Paste it somewhere safe (notes, a message to yourself). Without it your + favourites, saved searches, hidden list and watch progress cannot be found again. + + + 2. Download the new version + Linking.openURL(MIGRATION_NOTICE.downloadUrl).catch(() => {})} + > + Open {MIGRATION_NOTICE.downloadUrl.replace(/^https?:\/\//, '').replace(/\/$/, '')} + + + 3. Uninstall Goon, then open the downloaded file + + Long-press the app icon, Uninstall. Then open the .apk from your notifications + or Downloads. If Android asks, allow installing from your browser. + + + 4. Paste the code back + + Settings, Backup & sync, Restore from another device: paste the code, tap + Import. Everything comes back. + + + + Sorry for the hassle. Signing keys cannot be recovered, so this is a one-off: + updates after this one go back to installing themselves. + + + + + Done, do not show again + + + Remind me tomorrow + + + + + + ); +} + +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' }, +}); diff --git a/mobile/src/lib/appVersion.ts b/mobile/src/lib/appVersion.ts index dcc497f..7ea569c 100644 --- a/mobile/src/lib/appVersion.ts +++ b/mobile/src/lib/appVersion.ts @@ -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'; diff --git a/mobile/src/lib/keepAwake.ts b/mobile/src/lib/keepAwake.ts index 3ea3a6d..25f781e 100644 --- a/mobile/src/lib/keepAwake.ts +++ b/mobile/src/lib/keepAwake.ts @@ -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'; diff --git a/mobile/src/migrationNotice.ts b/mobile/src/migrationNotice.ts new file mode 100644 index 0000000..eb82f98 --- /dev/null +++ b/mobile/src/migrationNotice.ts @@ -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; diff --git a/mobile/src/screens/ScenesScreen.tsx b/mobile/src/screens/ScenesScreen.tsx index 7d1a348..75afe11 100644 --- a/mobile/src/screens/ScenesScreen.tsx +++ b/mobile/src/screens/ScenesScreen.tsx @@ -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 && {error.message}} + {/* 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. */} + + {isLoading ? ( ) : ( diff --git a/mobile/src/storage.ts b/mobile/src/storage.ts index 90fc414..7d1395b 100644 --- a/mobile/src/storage.ts +++ b/mobile/src/storage.ts @@ -121,6 +121,32 @@ export async function setAutoRotate(on: boolean): Promise { 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 { + 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 { + await SecureStore.setItemAsync(MIGRATION_SNOOZE_KEY, String(Date.now())); +} + +export async function dismissMigrationNotice(): Promise { + await SecureStore.setItemAsync(MIGRATION_DONE_KEY, '1'); +} + export interface Credentials { baseUrl: string; apiKey: string;