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;