fix(mobile): monit o nowe APK takze na tym samym kanale OTA

0.4.0 i 0.4.1 maja ten sam runtime 1.4, a monit porownywal tylko runtime, wiec nikt
z 0.4.0 nie dostal propozycji aktualizacji i crash expo-video przy zamykaniu z ostatnich
wracal do Sentry dzien po poprawce (147164135).

- /version oddaje apk_build (versionCode 15)
- aplikacja czyta versionCode ZAINSTALOWANEGO APK przez natywny modul Sentry
  (expo-constants w SDK 52 nie oddaje go wcale, expo-application wymagalby nowego APK)
- numer builda trafia tez do kontekstu zgloszen z apki: app_version jedzie z paczka OTA,
  wiec nie mowil, ktore wydanie natywne ma zglaszajacy

Sprawdzone na emulatorze: 0.4.1 nie dostaje monitu i raportuje build=15, po instalacji
0.4.0 monit "Update available" pokazuje sie przy starcie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-09-16 09:45:40 +02:00
parent 2cdc3d69da
commit 7d5dd450f7
6 changed files with 74 additions and 15 deletions

View file

@ -100,7 +100,7 @@ def healthz() -> dict[str, str]:
@app.get("/version") @app.get("/version")
def version() -> dict[str, str | None]: def version() -> dict[str, str | int | None]:
"""Mobile sprawdza po starcie żeby wykryć dostępność nowszej wersji APK. """Mobile sprawdza po starcie żeby wykryć dostępność nowszej wersji APK.
Zwraca: Zwraca:
@ -123,12 +123,21 @@ def version() -> dict[str, str | None]:
# mobile sklei z baseUrl. # mobile sklei z baseUrl.
public_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/") 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" apk_url = f"{public_url}/static/app-release.apk" if public_url else "/static/app-release.apk"
# `runtime_version` to jedyny numer, po którym aplikacja pozna, JAKIE APK ma # `runtime_version` i `apk_build` to jedyne numery, po których aplikacja pozna, JAKIE
# zainstalowane. `version` z app.json przyjeżdża do niej razem z paczką OTA, więc po # APK ma zainstalowane. `version` z app.json przyjeżdża do niej razem z paczką OTA,
# aktualizacji stary APK raportuje nową wersję i sam siebie uznaje za aktualny. # 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). # `apk_build` = `versionCode` z android/app/build.gradle. Runtime sam nie wystarcza:
return {"version": "0.4.1", "runtime_version": "1.4", "apk_url": apk_url} # 0.4.0 i 0.4.1 mają ten sam runtime 1.4 (0.4.1 to poprawki natywne bez zmiany kanału
# OTA), więc przez dobę nikt z 0.4.0 nie dostał monitu, mimo że 0.4.1 naprawia crash
# expo-video przy zamykaniu z ostatnich (Sentry 147164135, zgłoszenie 2026-09-15).
# Podbijaj RAZEM z `version` przy każdym wydaniu APK.
return {
"version": "0.4.1",
"runtime_version": "1.4",
"apk_build": 15,
"apk_url": apk_url,
}
@app.get("/readyz") @app.get("/readyz")

View file

@ -32,7 +32,7 @@ import { OnboardingModal } from './src/components/OnboardingModal';
import { WhatsNewModal } from './src/components/WhatsNewModal'; import { WhatsNewModal } from './src/components/WhatsNewModal';
import { ErrorBoundary } from './src/ErrorBoundary'; import { ErrorBoundary } from './src/ErrorBoundary';
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate'; import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
import { APP_VERSION, NATIVE_RUNTIME, isNewerVersion } from './src/lib/appVersion'; import { APP_VERSION, NATIVE_RUNTIME, isNewerVersion, nativeBuildNumber } from './src/lib/appVersion';
import { DEFAULT_API_KEY, DEFAULT_BACKEND_URL } from './src/lib/backend'; import { DEFAULT_API_KEY, DEFAULT_BACKEND_URL } from './src/lib/backend';
import { getSettings as getLockSettings } from './src/lib/applock'; import { getSettings as getLockSettings } from './src/lib/applock';
import { AppNavigator } from './src/navigation'; import { AppNavigator } from './src/navigation';
@ -257,11 +257,18 @@ export default function App() {
try { try {
const out = await client.getServerVersion(); const out = await client.getServerVersion();
const bundled = APP_VERSION; const bundled = APP_VERSION;
// Porównujemy RUNTIME, nie numer wersji: `bundled` bierze się z app.json, które // Kolejność źródeł: numer builda APK → runtime → numer wersji. `bundled` bierze
// przyjeżdża razem z paczką OTA, więc stary APK po aktualizacji raportuje nową // się z app.json, które przyjeżdża razem z paczką OTA, więc stary APK po
// wersję i sam siebie uznaje za aktualny. Runtime jest z natywnego manifestu. // aktualizacji raportuje nową wersję i sam siebie uznaje za aktualny. Runtime
// Fallback na porównanie wersji, gdy serwer jeszcze nie podaje runtime. // jest z natywnego manifestu, ale zmienia się tylko przy zmianie kanału OTA:
const needsApk = out.runtime_version && NATIVE_RUNTIME // 0.4.0 i 0.4.1 mają ten sam runtime 1.4, więc wydanie naprawcze nie docierało
// do nikogo (crash expo-video z 0.4.0 wracał do Sentry dzień po poprawce).
// Numer builda rozróżnia nawet dwa APK na tym samym kanale.
const nativeBuild = await nativeBuildNumber();
const serverBuild = typeof out.apk_build === 'number' ? out.apk_build : null;
const needsApk = serverBuild && nativeBuild
? serverBuild > nativeBuild
: out.runtime_version && NATIVE_RUNTIME
? isNewerVersion(out.runtime_version, NATIVE_RUNTIME) ? isNewerVersion(out.runtime_version, NATIVE_RUNTIME)
: isNewerVersion(out.version, bundled); : isNewerVersion(out.version, bundled);
if (out.version && needsApk && out.apk_url) { if (out.version && needsApk && out.apk_url) {

View file

@ -589,6 +589,8 @@ export class GoonClient {
async getServerVersion(): Promise<{ async getServerVersion(): Promise<{
version: string; version: string;
runtime_version?: string | null; runtime_version?: string | null;
/** versionCode najnowszego APK; rozróżnia wydania na tym samym runtime. */
apk_build?: number | null;
apk_url: string | null; apk_url: string | null;
}> { }> {
// /version nie wymaga API key (publiczny endpoint do upgrade discovery). // /version nie wymaga API key (publiczny endpoint do upgrade discovery).

View file

@ -35,6 +35,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-09-16a',
date: 'September 2026',
items: [
'The app now notices when a newer build is out even if it shares the silent-update channel with yours, so fixes that need a real install stop waiting for you to find them. If you are on 0.4.0, expect a prompt for 0.4.1: it fixes a crash when closing the app from recents.',
],
},
{ {
id: '2026-09-15d', id: '2026-09-15d',
date: 'September 2026', date: 'September 2026',

View file

@ -36,6 +36,7 @@ import { captureScreen } from 'react-native-view-shot';
import { GoonClient } from '../api'; import { GoonClient } from '../api';
import { theme } from '../theme'; import { theme } from '../theme';
import { nativeBuildNumber } from '../lib/appVersion';
import { BOTTOM_BAR_GAP, BOTTOM_BAR_HEIGHT, barHidden } from '../lib/bottomBar'; import { BOTTOM_BAR_GAP, BOTTOM_BAR_HEIGHT, barHidden } from '../lib/bottomBar';
import { formatLastPlayback } from '../lib/lastPlayback'; import { formatLastPlayback } from '../lib/lastPlayback';
import { getSxyprnDiag } from '../lib/sxyprnResolver'; import { getSxyprnDiag } from '../lib/sxyprnResolver';
@ -220,6 +221,11 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
? `${pc?.Model || 'android'} / Android ${pc?.Release || Platform.Version}` ? `${pc?.Model || 'android'} / Android ${pc?.Release || Platform.Version}`
: `${Platform.OS} ${Platform.Version}`; : `${Platform.OS} ${Platform.Version}`;
extraContext.push(`device=${device.slice(0, 48)}`); extraContext.push(`device=${device.slice(0, 48)}`);
// Numer builda ZAINSTALOWANEGO APK. `app_version` idzie z paczki OTA, wiec zgloszenie
// z APK 0.4.0 po aktualizacji podawalo 0.4.1 i nie dalo sie odroznic, kto ma ktore
// wydanie natywne (crash expo-video siedzial tylko w 0.4.0).
const build = await nativeBuildNumber();
if (build) extraContext.push(`build=${build}`);
// Ostatnie odtwarzanie (serwer/host/pozycja/błąd), kluczowe dla "nie gra"/"audio // Ostatnie odtwarzanie (serwer/host/pozycja/błąd), kluczowe dla "nie gra"/"audio
// się rozjeżdża", bo mówi KTÓRE źródło i gdzie, niezależnie od ekranu zgłoszenia. // się rozjeżdża", bo mówi KTÓRE źródło i gdzie, niezależnie od ekranu zgłoszenia.
const play = formatLastPlayback(); const play = formatLastPlayback();

View file

@ -39,6 +39,34 @@ export const NATIVE_RUNTIME: string | null = (() => {
})(); })();
/**
* `versionCode` ZAINSTALOWANEGO APK (0.4.0 = 14, 0.4.1 = 15), albo null.
*
* Po co, skoro jest runtime: runtime zmienia się tylko przy zmianie kanału OTA, a wydanie
* naprawcze zostaje na tym samym kanale. 0.4.0 i 0.4.1 mają oba runtime 1.4, więc nikt
* z 0.4.0 nie dostał monitu o 0.4.1, mimo że tamto APK naprawia crash przy zamykaniu
* aplikacji z ostatnich (Sentry 147164135). Numer builda rozróżnia je bez podbijania
* runtime i bez utrzymywania kolejnego kanału OTA.
*
* Skąd: natywny moduł Sentry, który i tak jest w aplikacji i czyta PackageInfo (to stąd
* bierze się `app_build` w zdarzeniach). expo-constants w SDK 52 oddaje puste
* `platform.android`, a expo-application wymagałby nowego APK, czyli dokładnie tego,
* czego tu brakuje. Wejście prywatne, więc całość w try/catch: brak numeru = stare
* zachowanie (porównanie runtime).
*/
export async function nativeBuildNumber(): Promise<number | null> {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { NATIVE } = require('@sentry/react-native/dist/js/wrapper');
const ctx: any = await NATIVE.fetchNativeDeviceContexts();
const raw = ctx?.contexts?.app?.app_build ?? ctx?.app?.app_build;
const n = parseInt(String(raw ?? ''), 10);
return Number.isFinite(n) && n > 0 ? n : null;
} catch {
return null;
}
}
/** „0.3.0" → [0,3,0]. Człony nieliczbowe (np. „0.3.0-rc1") lecą na 0. */ /** „0.3.0" → [0,3,0]. Człony nieliczbowe (np. „0.3.0-rc1") lecą na 0. */
function versionParts(v: string): number[] { function versionParts(v: string): number[] {
return String(v || '').split('.').map((x) => parseInt(x, 10) || 0); return String(v || '').split('.').map((x) => parseInt(x, 10) || 0);