feat(mobile): ekran "zaktualizuj" dla wersji starszych niz 0.4.x

Osobny punkt wejscia (update-required.tsx) z samym ekranem blokady, bez
reszty aplikacji i bez przycisku "Pozniej". Publikowany tylko na kanaly OTA
starych runtime'ow 1.0-1.3 (0.3.0 to dzis ~8% ruchu): stare APK nie dostaja
poprawek natywnych, a ich bledy wracaly w zgloszeniach. Uzywa tylko modulow
obecnych w kazdym APK. Runtime 1.0/1.1 (stary klucz podpisu) dostaje kroki
z kodem synchronizacji, od 1.2 nowa wersja instaluje sie na wierzchu.

publish_update.py --entry podmienia pole main w package.json na czas
eksportu i odmawia publikacji na biezacy runtime.

Emulator: prawdziwe APK 0.3.0 i 0.2.1 z ta paczka pokazuja ekran, pobieranie
otwiera APK w przegladarce, kopiowanie kodu dziala, Wstecz zamyka apke,
0.4.2 instaluje sie na 0.3.0 i startuje normalnie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-10-06 16:06:21 +02:00
parent 7e48cdee06
commit 3b061a5807
3 changed files with 191 additions and 1 deletions

View file

@ -0,0 +1,159 @@
/**
* Ekran "zaktualizuj aplikacje" dla starych APK (runtime 1.0-1.3, wersje sprzed 0.4.0).
*
* To NIE jest czesc zwyklej aplikacji: ta paczka jest budowana z osobnego punktu wejscia
* (`update-required.tsx`, `publish_update.py --entry`) i publikowana tylko na kanaly OTA
* starych runtime'ow. Stare wersje nie dostaja juz poprawek natywnych, a ich bledy wracaly
* w zgloszeniach i Sentry (decyzja Jana 2026-10-06: blokada wszystkiego starszego niz 0.4.x,
* okolo 8% ruchu). Nie ma przycisku "Pozniej" ani zadnej innej funkcji, celowo.
*
* Uzywa wylacznie modulow, ktore sa w kazdym APK od pierwszego wydania (react-native,
* expo-updates, expo-secure-store, expo-clipboard), wiec nie wywali sie na starym kodzie
* natywnym.
*
* Runtime 1.0 i 1.1 (0.2.1 i starsze) maja stary klucz podpisu, ktory przepadl: Android
* nie zainstaluje nowej wersji na wierzchu, trzeba odinstalowac, co czysci telefon. Dla nich
* kroki z kodem synchronizacji, jak w MigrationBanner. Od 1.2 nowa wersja instaluje sie
* na wierzchu i dane zostaja.
*/
import React from 'react';
import { Linking, Pressable, ScrollView, StatusBar, StyleSheet, Text, View } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import * as Updates from 'expo-updates';
const VERSION_URL = 'https://api.goon-foss.org/version';
const APK_FALLBACK = 'https://api.goon-foss.org/static/app-release.apk';
const SITE_URL = 'https://goon-foss.org/';
const OLD_KEY_RUNTIMES = ['1.0', '1.1'];
const C = {
bg: '#14110f',
card: '#1f1a17',
fg: '#f3ede4',
muted: '#a99f94',
accent: '#e05a6d',
};
export default function UpdateRequired() {
const runtime = Updates.runtimeVersion || '';
const oldKey = OLD_KEY_RUNTIMES.includes(runtime);
const [apkUrl, setApkUrl] = React.useState(APK_FALLBACK);
const [latest, setLatest] = React.useState<string | null>(null);
const [code, setCode] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
fetch(VERSION_URL)
.then((r) => r.json())
.then((v: { version?: string; apk_url?: string | null }) => {
if (v.apk_url && /^https?:\/\//.test(v.apk_url)) setApkUrl(v.apk_url);
if (v.version) setLatest(v.version);
})
.catch(() => {});
if (oldKey) {
SecureStore.getItemAsync('goon.device_id')
.then((id) => setCode(id))
.catch(() => {});
}
}, [oldKey]);
const copy = React.useCallback(async () => {
if (!code) return;
try {
const Clipboard = require('expo-clipboard');
await Clipboard.setStringAsync(code);
setCopied(true);
} catch {
// brak modulu: kod i tak da sie zaznaczyc i skopiowac recznie
}
}, [code]);
const open = (url: string) => Linking.openURL(url).catch(() => {});
return (
<View style={s.root}>
<StatusBar barStyle="light-content" backgroundColor={C.bg} />
<ScrollView contentContainerStyle={s.content}>
<Text style={s.title}>Update required</Text>
<Text style={s.lead}>
This version of Goon is no longer supported and does not get fixes anymore. Install
the new version{latest ? ` (${latest})` : ''} to keep watching.
</Text>
{oldKey ? (
<>
<Text style={s.lead}>
Your version was signed with an old key, so Android will not install the new one
over it. Uninstall first, then install the download. That wipes the phone, so copy
your sync code before you start.
</Text>
{code ? (
<>
<Text style={s.step}>1. Copy your sync code</Text>
<Pressable style={s.codeRow} onPress={copy}>
<Text style={s.code} selectable numberOfLines={1}>
{code}
</Text>
<Text style={s.copy}>{copied ? 'Copied' : 'Copy'}</Text>
</Pressable>
</>
) : null}
<Text style={s.step}>{code ? '2.' : '1.'} Download the new version</Text>
<Pressable style={s.btn} onPress={() => open(apkUrl)}>
<Text style={s.btnText}>Download the update</Text>
</Pressable>
<Text style={s.step}>{code ? '3.' : '2.'} Uninstall Goon, then open the file</Text>
<Text style={s.note}>
Long-press the app icon, Uninstall. Then open the .apk from your notifications or
Downloads.
</Text>
{code ? (
<>
<Text style={s.step}>4. Paste the code back</Text>
<Text style={s.note}>Settings, Backup &amp; sync, Restore: paste the code, tap Import.</Text>
</>
) : null}
</>
) : (
<>
<Pressable style={s.btn} onPress={() => open(apkUrl)}>
<Text style={s.btnText}>Download the update</Text>
</Pressable>
<Text style={s.note}>
Open the downloaded .apk from your notifications or Downloads and tap Install. It
installs over this version, your favourites and settings stay.
</Text>
</>
)}
<Pressable onPress={() => open(SITE_URL)} hitSlop={8}>
<Text style={s.link}>Download does not start? Open goon-foss.org</Text>
</Pressable>
<Text style={s.footer}>Installed: runtime {runtime || '?'}</Text>
</ScrollView>
</View>
);
}
const s = StyleSheet.create({
root: { flex: 1, backgroundColor: C.bg },
content: { padding: 24, paddingTop: 72, paddingBottom: 48 },
title: { color: C.fg, fontSize: 28, fontWeight: '800', marginBottom: 16 },
lead: { color: C.fg, fontSize: 16, lineHeight: 23, marginBottom: 14 },
step: { color: C.fg, fontSize: 16, fontWeight: '700', marginTop: 14, marginBottom: 8 },
note: { color: C.muted, fontSize: 14, lineHeight: 20, marginTop: 8 },
codeRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: C.card,
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
},
code: { flex: 1, color: C.fg, fontSize: 15, fontFamily: 'monospace' },
copy: { color: C.accent, fontWeight: '700', marginLeft: 12 },
btn: { backgroundColor: C.accent, borderRadius: 12, paddingVertical: 15, alignItems: 'center', marginTop: 8 },
btnText: { color: '#fff', fontSize: 17, fontWeight: '800' },
link: { color: C.accent, fontSize: 14, marginTop: 28, textAlign: 'center' },
footer: { color: C.muted, fontSize: 12, marginTop: 24, textAlign: 'center' },
});

View file

@ -0,0 +1,7 @@
// Punkt wejscia paczki "zaktualizuj aplikacje" dla starych runtime'ow (1.0-1.3).
// Zwykla aplikacja startuje z App.tsx; ten plik buduje tylko
// `scripts/publish_update.py --entry update-required.tsx`. Opis w src/UpdateRequired.tsx.
import { registerRootComponent } from 'expo';
import UpdateRequired from './src/UpdateRequired';
registerRootComponent(UpdateRequired);

View file

@ -141,15 +141,39 @@ def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--runtime", default=RUNTIME_DEFAULT)
ap.add_argument("--skip-export", action="store_true", help="reuse existing dist/")
ap.add_argument(
"--entry",
help="inny punkt wejscia niz App.tsx, np. update-required.tsx (ekran blokady starych wersji)",
)
args = ap.parse_args()
# Ekran blokady na biezacym kanale zablokowalby wszystkich uzytkownikow.
if args.entry and args.runtime == RUNTIME_DEFAULT:
print(f"ERROR: --entry {args.entry} na biezacym runtime {RUNTIME_DEFAULT} zablokowalby wszystkich. Przerwane.")
return 1
if not args.skip_export:
if DIST.exists():
import shutil
shutil.rmtree(DIST)
# `--source-maps` dokłada `.hbc.map`, potrzebną do odczytania śladów JS
# w Sentry. Mapa NIE jedzie na serwer, kasuje ją `strip_sourcemaps()`.
run(["npx", "expo", "export", "--platform", "android", "--source-maps"], cwd=MOBILE)
# `--entry`: expo export bierze punkt wejscia z pola `main` w package.json, wiec
# podmieniamy je na czas eksportu i zawsze przywracamy.
pkg_path = MOBILE / "package.json"
pkg_orig = pkg_path.read_text(encoding="utf-8")
if args.entry:
if not (MOBILE / args.entry).exists():
print(f"ERROR: brak pliku mobile/{args.entry}")
return 1
pkg = json.loads(pkg_orig)
pkg["main"] = args.entry
pkg_path.write_text(json.dumps(pkg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
try:
run(["npx", "expo", "export", "--platform", "android", "--source-maps"], cwd=MOBILE)
finally:
if args.entry:
pkg_path.write_text(pkg_orig, encoding="utf-8")
upload_sourcemaps()
strip_sourcemaps()