diff --git a/app/main.py b/app/main.py index 2a645a4..d79b075 100644 --- a/app/main.py +++ b/app/main.py @@ -121,7 +121,12 @@ def version() -> dict[str, str | None]: # mobile sklei z baseUrl. public_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/") apk_url = f"{public_url}/static/app-release.apk" if public_url else "/static/app-release.apk" - return {"version": "0.3.0", "apk_url": apk_url} + # `runtime_version` to jedyny numer, po którym aplikacja pozna, JAKIE APK ma + # zainstalowane. `version` z app.json przyjeżdża do niej razem z paczką OTA, więc po + # aktualizacji stary APK raportuje nową wersję i sam siebie uznaje za aktualny. + # Runtime siedzi w AndroidManifest, OTA go nie rusza. Podbijaj RAZEM z `version`, + # ale tylko przy wydaniu APK (zmiana natywna). + return {"version": "0.3.0", "runtime_version": "1.3", "apk_url": apk_url} @app.get("/readyz") diff --git a/mobile/App.tsx b/mobile/App.tsx index 254d8d9..deadae7 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -32,7 +32,7 @@ import { OnboardingModal } from './src/components/OnboardingModal'; import { WhatsNewModal } from './src/components/WhatsNewModal'; import { ErrorBoundary } from './src/ErrorBoundary'; import { isAccepted as isAgeGateAccepted } from './src/lib/agegate'; -import { APP_VERSION, isNewerVersion } from './src/lib/appVersion'; +import { APP_VERSION, NATIVE_RUNTIME, isNewerVersion } from './src/lib/appVersion'; import { DEFAULT_API_KEY, DEFAULT_BACKEND_URL } from './src/lib/backend'; import { getSettings as getLockSettings } from './src/lib/applock'; import { AppNavigator } from './src/navigation'; @@ -257,7 +257,14 @@ export default function App() { try { const out = await client.getServerVersion(); const bundled = APP_VERSION; - if (out.version && isNewerVersion(out.version, bundled) && out.apk_url) { + // Porównujemy RUNTIME, nie numer wersji: `bundled` bierze się z app.json, które + // przyjeżdża razem z paczką OTA, więc stary APK po aktualizacji raportuje nową + // wersję i sam siebie uznaje za aktualny. Runtime jest z natywnego manifestu. + // Fallback na porównanie wersji, gdy serwer jeszcze nie podaje runtime. + const needsApk = out.runtime_version && NATIVE_RUNTIME + ? isNewerVersion(out.runtime_version, NATIVE_RUNTIME) + : isNewerVersion(out.version, bundled); + if (out.version && needsApk && out.apk_url) { // In-app installer dostępny → "Install" zamiast "Download" if (apkInstallerAvailable) { const pendingApkUrl = out.apk_url!; @@ -301,7 +308,11 @@ export default function App() { }; Alert.alert( 'Update available', - `Server: ${out.version}\nApp: ${bundled}\n\nInstall update now?`, + // NIE pokazujemy tu numeru wersji aplikacji. Po aktualizacji OTA bierze się + // on z manifestu paczki, a nie z zainstalowanego APK, więc na starym APK + // wyświetlały się dwie identyczne liczby („Server: 0.3.0, App: 0.3.0") i + // monit wyglądał na pomyłkę. + `Goon ${out.version} is ready. It adds parts that silent updates cannot deliver, so it installs like a normal app.\n\nInstall now?`, [ { text: 'Later', style: 'cancel' }, { text: 'Install', onPress: tryInstall }, @@ -311,7 +322,7 @@ export default function App() { // Fallback: Chrome download Alert.alert( 'Update available', - `Server: ${out.version}\nApp: ${bundled}\n\nTap "Download" to install the latest APK.`, + `Goon ${out.version} is ready. It adds parts that silent updates cannot deliver, so you install it yourself.\n\nTap "Download" to get the APK.`, [ { text: 'Later', style: 'cancel' }, { diff --git a/mobile/src/api.ts b/mobile/src/api.ts index 7f69f2b..1c3b75f 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -554,7 +554,11 @@ export class GoonClient { return this.request('/bug-reports/mine/seen', { method: 'POST' }); } - async getServerVersion(): Promise<{ version: string; apk_url: string | null }> { + async getServerVersion(): Promise<{ + version: string; + runtime_version?: string | null; + apk_url: string | null; + }> { // /version nie wymaga API key (publiczny endpoint do upgrade discovery). // Używamy this.request bo czysty fetch bez API key też jest OK, ale endpoint jest auth-free. const res = await fetch(`${this.baseUrl}/version`); diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 03b3717..0f0e051 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -6,7 +6,7 @@ * Przy każdym publish OTA: DOPISZ nowy wpis NA GÓRZE (newest-first), z nowym `id`. * Treść user-facing (bez wewnętrznego żargonu), to widzi użytkownik. */ -import { APP_VERSION, isNewerVersion } from './lib/appVersion'; +import { NATIVE_RUNTIME, isNewerVersion } from './lib/appVersion'; export type ChangelogEntry = { /** Stabilny, rosnący identyfikator (np. data + seq). Newest-first w CHANGELOG. */ @@ -16,13 +16,16 @@ export type ChangelogEntry = { /** Punkty zmian, krótkie, user-facing. */ items: string[]; /** - * Pokaż ten wpis tylko użytkownikom PONIŻEJ tej wersji aplikacji. + * Pokaż ten wpis tylko użytkownikom, których APK ma runtime STARSZY niż podany. * - * Po co: przy zmianach wymagających nowego APK (natywnych) ten sam bundle leci na - * kanał starej i nowej wersji. Bez tego użytkownik, który już zainstalował 0.3.0, - * dostawał komunikat „pobierz 0.3.0". Wpisy bez tego pola widzą wszyscy. + * Po co: przy zmianach natywnych ten sam bundle leci na kanał starej i nowej wersji. + * Bez bramki użytkownik, który już zainstalował nowe APK, dostawał komunikat „pobierz + * nowe APK". Bramkujemy po runtime, a nie po numerze wersji, bo numer wersji przyjeżdża + * razem z paczką OTA i stary APK po aktualizacji podaje już nowy (przez to wpis ukrywał + * się przed dokładnie tymi ludźmi, do których był kierowany). Wpisy bez tego pola widzą + * wszyscy. */ - onlyBelowVersion?: string; + onlyBelowRuntime?: string; }; export const CHANGELOG: ChangelogEntry[] = [ @@ -32,7 +35,7 @@ export const CHANGELOG: ChangelogEntry[] = [ items: [ 'Chromecast is here, but it needs version 0.3.0, which you have to download and install yourself. Casting needs a part of the app that cannot be delivered as a silent update. Get it from goon-foss.org; it installs over the version you already have and keeps your settings.', ], - onlyBelowVersion: '0.3.0', + onlyBelowRuntime: '1.3', }, { id: '2026-08-24a', @@ -579,7 +582,7 @@ export const NEWEST_CHANGELOG_ID = CHANGELOG.length ? CHANGELOG[0].id : null; export function unseenEntries(lastSeenId: string | null): ChangelogEntry[] { if (!CHANGELOG.length) return []; const visible = CHANGELOG.filter( - (e) => !e.onlyBelowVersion || isNewerVersion(e.onlyBelowVersion, APP_VERSION), + (e) => !e.onlyBelowRuntime || isNewerVersion(e.onlyBelowRuntime, NATIVE_RUNTIME || '0'), ); if (!visible.length) return []; if (!lastSeenId) return [visible[0]]; diff --git a/mobile/src/lib/appVersion.ts b/mobile/src/lib/appVersion.ts index a2ca123..d761c76 100644 --- a/mobile/src/lib/appVersion.ts +++ b/mobile/src/lib/appVersion.ts @@ -1,4 +1,5 @@ import Constants from 'expo-constants'; +import * as Updates from 'expo-updates'; /** * Aktualna wersja appki, używana do display'u (BugReportFAB, Update dialog) @@ -19,6 +20,24 @@ import Constants from 'expo-constants'; export const APP_VERSION: string = (Constants.expoConfig?.version as string | undefined) || '0.3.0'; +/** + * Runtime zapisany w APK (`EXPO_RUNTIME_VERSION` w AndroidManifest). JEDYNY numer, po + * którym aplikacja pozna, jaką paczkę natywną ma naprawdę zainstalowaną. + * + * Po co osobno, skoro jest `APP_VERSION`: ta bierze się z `app.json`, a `app.json` + * przyjeżdża do aplikacji RAZEM Z PACZKĄ OTA. Po podbiciu wersji na 0.3.0 i wysłaniu tej + * paczki na stary kanał każdy użytkownik APK 0.2.2 zaczął raportować 0.3.0 i sam siebie + * uznał za aktualnego: nie dostał ani monitu o nowym APK, ani wpisu w changelogu, który + * właśnie do niego był kierowany. Runtime siedzi w natywnym manifeście i OTA go nie rusza. + */ +export const NATIVE_RUNTIME: string | null = (() => { + try { + return Updates.runtimeVersion ?? null; + } catch { + return null; + } +})(); + /** „0.3.0" → [0,3,0]. Człony nieliczbowe (np. „0.3.0-rc1") lecą na 0. */ function versionParts(v: string): number[] {