feat(native): a capability handshake between the app binary and the server's UI

The UI ships with the server and the binary through the stores, so the two
drift. TrekShell.getCapabilities now reports the binary's shell API, version
and native features by name (iOS and Android), and the UI turns each native
feature on only where the binary has it; a binary from before the handshake
counts as shell API 1 with none. The other way round, the server names the
oldest binary its UI works with (minNativeShellApi), and an app below it is
asked to update with a link to its store.
This commit is contained in:
Maurice
2026-11-06 13:08:39 +01:00
parent 7b29a5d34e
commit f92aea80a7
40 changed files with 446 additions and 0 deletions
@@ -24,6 +24,37 @@ public class TrekShellPlugin extends Plugin {
private boolean authBrowserOpen;
private final Handler handler = new Handler(Looper.getMainLooper());
/**
* What this binary offers the server's UI, raised with every native feature
* an older binary lacks. The UI asks before it calls (native/capabilities.ts),
* and a server can require a minimum (minNativeShellApi in /api/health/features).
*/
static final int SHELL_API = 2;
/** The native features by name, so the UI can turn each on by itself. */
static final String[] FEATURES = { "capabilities" };
@PluginMethod
public void getCapabilities(PluginCall call) {
JSObject result = new JSObject();
result.put("shellApi", SHELL_API);
result.put("platform", "android");
String version = "";
String build = "";
try {
android.content.pm.PackageInfo info = getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0);
version = info.versionName == null ? "" : info.versionName;
build = String.valueOf(androidx.core.content.pm.PackageInfoCompat.getLongVersionCode(info));
} catch (android.content.pm.PackageManager.NameNotFoundException ignored) {
// Our own package is always there; the fields simply stay empty.
}
result.put("appVersion", version);
result.put("build", build);
com.getcapacitor.JSArray features = new com.getcapacitor.JSArray();
for (String feature : FEATURES) features.put(feature);
result.put("features", features);
call.resolve(result);
}
@PluginMethod
public void getServer(PluginCall call) {
String url = TrekServerStore.getUrl(getContext());
+19
View File
@@ -16,8 +16,27 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
CAPPluginMethod(name: "authenticate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "refreshOfflineCopy", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "openOfflineCopy", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getCapabilities", returnType: CAPPluginReturnPromise),
]
/// What this binary offers the server's UI, raised with every native feature
/// an older binary lacks. The UI asks before it calls (native/capabilities.ts),
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
static let shellApi = 2
/// The native features by name, so the UI can turn each on by itself.
static let features: [String] = ["capabilities"]
@objc func getCapabilities(_ call: CAPPluginCall) {
let info = Bundle.main.infoDictionary
call.resolve([
"shellApi": TrekShellPlugin.shellApi,
"platform": "ios",
"appVersion": info?["CFBundleShortVersionString"] as? String ?? "",
"build": info?["CFBundleVersion"] as? String ?? "",
"features": TrekShellPlugin.features,
])
}
private var authSession: ASWebAuthenticationSession?
@objc func getServer(_ call: CAPPluginCall) {
+2
View File
@@ -32,6 +32,7 @@ import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
import OfflineBanner from './components/Layout/OfflineBanner'
import NewVersionNotice from './components/Layout/NewVersionNotice'
import NativeUpdateNotice from './components/Layout/NativeUpdateNotice'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
import HelpPanel from './components/Help/HelpPanel'
// Notice action registrations (side-effect imports):
@@ -442,6 +443,7 @@ export default function App() {
)}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:native-update" fallback={null}><NativeUpdateNotice /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
across navigations. react-router runs location updates inside a transition,
so a mounted boundary keeps the current page on screen instead of flashing
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
import { _resetNativeCapabilities, checkAppUpdate, loadNativeCapabilities } from '../../native/capabilities';
import { isNativeApp } from '../../native/platform';
import { TrekShell } from '../../native/trekShell';
import NativeUpdateNotice from './NativeUpdateNotice';
vi.mock('../../native/platform', () => ({ isNativeApp: vi.fn(), isNativeIos: vi.fn(() => false) }));
vi.mock('../../native/trekShell', () => ({
TrekShell: { getCapabilities: vi.fn() },
isUnimplemented: () => false,
}));
beforeEach(() => {
_resetNativeCapabilities();
vi.mocked(isNativeApp).mockReturnValue(true);
vi.mocked(TrekShell.getCapabilities).mockResolvedValue({
shellApi: 2,
platform: 'android',
appVersion: '5.2.0',
build: '3',
features: [],
} as never);
});
describe('NativeUpdateNotice', () => {
it('stays away while the binary is new enough', async () => {
render(<NativeUpdateNotice />);
await act(async () => {
await checkAppUpdate(async () => ({ minNativeShellApi: 2 }));
});
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('leads to the store when the server needs a newer binary, and can be dismissed', async () => {
render(<NativeUpdateNotice />);
await act(async () => {
await loadNativeCapabilities();
await checkAppUpdate(async () => ({ minNativeShellApi: 3 }));
});
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', 'https://play.google.com/store/apps/details?id=com.liketrek.trek');
fireEvent.click(screen.getByRole('button'));
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,54 @@
/**
* NativeUpdateNotice: this server's UI needs a newer app binary than the one
* installed (native/capabilities.ts, minNativeShellApi). The UI updates with
* the server, the binary only through the store, so the two can drift; the
* notice says so and leads to the store instead of leaving native calls to
* fail one by one. Rendered once in App, inside the app only.
*/
import { Download, X } from 'lucide-react';
import React, { useState } from 'react';
import { useTranslation } from '../../i18n';
import { useNativeCapabilities } from '../../native/capabilities';
const STORE_URL = {
ios: 'https://apps.apple.com/app/id6831237775',
android: 'https://play.google.com/store/apps/details?id=com.liketrek.trek',
} as const;
export default function NativeUpdateNotice(): React.ReactElement | null {
const { t } = useTranslation();
const { caps, updateRequired } = useNativeCapabilities();
const [dismissed, setDismissed] = useState(false);
if (!updateRequired || dismissed || caps.platform === 'web') return null;
const store = caps.platform === 'android' ? STORE_URL.android : STORE_URL.ios;
return (
<div
role="status"
style={{ bottom: 'calc(var(--bottom-nav-h) + 56px)' }}
className="fixed inset-x-4 z-[9999] flex items-center gap-3 rounded-2xl border border-edge bg-surface-elevated px-4 py-3 text-content shadow-lg"
>
<Download size={18} className="shrink-0 text-content-muted" aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="text-body font-semibold">{t('native.update.title')}</div>
<div className="text-caption text-content-secondary">{t('native.update.body')}</div>
</div>
<a
href={store}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 rounded-full bg-accent px-3 py-1.5 text-caption font-semibold text-accent-text"
>
{t('native.update.open')}
</a>
<button
type="button"
onClick={() => setDismissed(true)}
aria-label={t('common.close')}
className="shrink-0 rounded-full p-1 text-content-muted"
>
<X size={14} aria-hidden="true" />
</button>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
_resetNativeCapabilities,
appUpdateRequired,
checkAppUpdate,
hasNativeFeature,
loadNativeCapabilities,
nativeCapabilities,
} from './capabilities';
import { isNativeApp } from './platform';
import { TrekShell } from './trekShell';
vi.mock('./platform', () => ({ isNativeApp: vi.fn() }));
vi.mock('./trekShell', () => ({
TrekShell: { getCapabilities: vi.fn() },
isUnimplemented: (err: unknown) => (err as { code?: string })?.code === 'UNIMPLEMENTED',
}));
const CAPS = { shellApi: 2, platform: 'ios', appVersion: '5.2.0', build: '12', features: ['capabilities', 'edgeBack'] };
beforeEach(() => {
_resetNativeCapabilities();
vi.mocked(isNativeApp).mockReturnValue(true);
vi.mocked(TrekShell.getCapabilities)
.mockReset()
.mockResolvedValue(CAPS as never);
});
afterEach(() => vi.unstubAllGlobals());
describe('native capabilities', () => {
it('asks the binary once and answers features by name', async () => {
await loadNativeCapabilities();
await loadNativeCapabilities();
expect(TrekShell.getCapabilities).toHaveBeenCalledTimes(1);
expect(hasNativeFeature('edgeBack')).toBe(true);
expect(hasNativeFeature('tileCache')).toBe(false);
expect(nativeCapabilities().appVersion).toBe('5.2.0');
});
it('counts a binary from before the handshake as shell API 1 with no features', async () => {
vi.mocked(TrekShell.getCapabilities).mockRejectedValue({ code: 'UNIMPLEMENTED' });
const caps = await loadNativeCapabilities();
expect(caps.shellApi).toBe(1);
expect(hasNativeFeature('capabilities')).toBe(false);
});
it('reports a real failure but still settles on the legacy answer', async () => {
const report = vi.fn();
vi.stubGlobal('reportError', report);
vi.mocked(TrekShell.getCapabilities).mockRejectedValue(new Error('bridge down'));
expect((await loadNativeCapabilities()).shellApi).toBe(1);
expect(report).toHaveBeenCalled();
});
it('asks nothing in a browser', async () => {
vi.mocked(isNativeApp).mockReturnValue(false);
expect((await loadNativeCapabilities()).platform).toBe('web');
expect(TrekShell.getCapabilities).not.toHaveBeenCalled();
expect(await checkAppUpdate(async () => ({ minNativeShellApi: 9 }))).toBe(false);
});
it('asks for an update only when the server needs a newer binary', async () => {
expect(await checkAppUpdate(async () => ({ minNativeShellApi: 2 }))).toBe(false);
expect(await checkAppUpdate(async () => ({ minNativeShellApi: 3 }))).toBe(true);
expect(appUpdateRequired()).toBe(true);
});
it('requires nothing from a server that names no minimum, or cannot be asked', async () => {
expect(await checkAppUpdate(async () => ({}))).toBe(false);
expect(
await checkAppUpdate(async () => {
throw new Error('offline');
})
).toBe(false);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { useSyncExternalStore } from 'react';
import { isNativeApp } from './platform';
import { reportNativeFailure } from './reportFailure';
import { TrekShell, isUnimplemented } from './trekShell';
/**
* What the installed app binary offers the UI this server delivers.
*
* The two update on their own clocks: the UI ships with the server, the binary
* through the stores. So the UI asks before it calls into anything native the
* binary may predate, and turns each feature on by name (hasNativeFeature). A
* binary from before this handshake answers UNIMPLEMENTED and counts as shell
* API 1 with no named features, which is exactly what it can do.
*
* The other direction runs through the server's features: minNativeShellApi
* names the oldest binary its UI works with, and an app below it is asked to
* update (appUpdateRequired) rather than left calling methods it lacks.
*/
export interface NativeCapabilities {
shellApi: number;
platform: 'ios' | 'android' | 'web';
appVersion: string;
build: string;
features: string[];
}
const WEB: NativeCapabilities = { shellApi: 0, platform: 'web', appVersion: '', build: '', features: [] };
const LEGACY_APP: NativeCapabilities = { shellApi: 1, platform: 'web', appVersion: '', build: '', features: [] };
let current: NativeCapabilities = WEB;
let updateRequired = false;
let loading: Promise<NativeCapabilities> | null = null;
const listeners = new Set<() => void>();
function emit(): void {
for (const listener of listeners) listener();
}
/** Asks the binary once and keeps the answer for the session. */
export function loadNativeCapabilities(): Promise<NativeCapabilities> {
if (!isNativeApp()) return Promise.resolve(WEB);
loading ??= TrekShell.getCapabilities()
.then(
(caps) => caps,
(err: unknown) => {
if (!isUnimplemented(err)) reportNativeFailure(err);
return LEGACY_APP;
}
)
.then((caps) => {
current = caps;
emit();
return caps;
});
return loading;
}
/** Whether the installed binary has a native feature, as far as it is known yet. */
export function hasNativeFeature(feature: string): boolean {
return current.features.includes(feature);
}
export function nativeCapabilities(): NativeCapabilities {
return current;
}
/**
* Compares the binary with the oldest one this server's UI works with. A
* server that says nothing (older than the handshake) requires nothing.
*/
export async function checkAppUpdate(fetchFeatures: () => Promise<{ minNativeShellApi?: unknown }>): Promise<boolean> {
if (!isNativeApp()) return false;
const [caps, features] = await Promise.all([loadNativeCapabilities(), fetchFeatures().catch(() => ({}))]);
const min = typeof features.minNativeShellApi === 'number' ? features.minNativeShellApi : 0;
updateRequired = caps.shellApi < min;
emit();
return updateRequired;
}
export function appUpdateRequired(): boolean {
return updateRequired;
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Re-renders when the capabilities arrive or the update check answers. */
export function useNativeCapabilities(): { caps: NativeCapabilities; updateRequired: boolean } {
const caps = useSyncExternalStore(subscribe, nativeCapabilities, nativeCapabilities);
const required = useSyncExternalStore(subscribe, appUpdateRequired, appUpdateRequired);
return { caps, updateRequired: required };
}
/** Test helper: forget what was asked. */
export function _resetNativeCapabilities(): void {
current = WEB;
updateRequired = false;
loading = null;
listeners.clear();
}
+7
View File
@@ -4,6 +4,7 @@ import { SplashScreen } from '@capacitor/splash-screen';
import { StatusBar, Style } from '@capacitor/status-bar';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { isEffectivelyOffline } from '../sync/networkMode';
import { checkAppUpdate } from './capabilities';
import { startNativeApp } from './nativeApp';
import { keepOfflineShellFresh } from './offlineShell';
import { isNativeApp, isNativeIos } from './platform';
@@ -15,6 +16,7 @@ vi.mock('@capacitor/status-bar', () => ({ StatusBar: { setStyle: vi.fn() }, Styl
vi.mock('./platform', () => ({ isNativeApp: vi.fn(), isNativeIos: vi.fn() }));
vi.mock('./offlineShell', () => ({ keepOfflineShellFresh: vi.fn() }));
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn() }));
vi.mock('./capabilities', () => ({ checkAppUpdate: vi.fn(async () => false) }));
function backButtonHandler(): (event: { canGoBack: boolean }) => void {
const call = vi.mocked(App.addListener).mock.calls.find(([name]) => name === 'backButton');
@@ -44,6 +46,11 @@ describe('startNativeApp', () => {
expect(StatusBar.setStyle).not.toHaveBeenCalled();
});
it('asks the binary what it can do and the server which binary it needs', () => {
startNativeApp();
expect(checkAppUpdate).toHaveBeenCalledWith(expect.any(Function));
});
it('hides the splash after the first frame', () => {
startNativeApp();
vi.runOnlyPendingTimers();
+11
View File
@@ -3,6 +3,7 @@ import { Keyboard } from '@capacitor/keyboard';
import { SplashScreen } from '@capacitor/splash-screen';
import { StatusBar, Style } from '@capacitor/status-bar';
import { isEffectivelyOffline } from '../sync/networkMode';
import { checkAppUpdate } from './capabilities';
import { keepOfflineShellFresh } from './offlineShell';
import { isNativeApp, isNativeIos } from './platform';
import { reportNativeFailure } from './reportFailure';
@@ -20,6 +21,9 @@ export function startNativeApp(): void {
followThemeInStatusBar();
// What this binary can do, and whether this server's UI needs a newer one.
void checkAppUpdate(fetchServerFeatures);
// Android's back gesture: through the app's own history first, and only
// then out of the app, the way a browser tab would behave.
void App.addListener('backButton', ({ canGoBack }) => {
@@ -58,3 +62,10 @@ function followThemeInStatusBar(): void {
apply();
new MutationObserver(apply).observe(root, { attributes: true, attributeFilter: ['class'] });
}
/** The server's feature flags, bounded so a slow server cannot hold anything up. */
async function fetchServerFeatures(): Promise<{ minNativeShellApi?: unknown }> {
const res = await fetch('/api/health/features', { credentials: 'same-origin', signal: AbortSignal.timeout(8000) });
if (!res.ok) return {};
return (await res.json()) as { minNativeShellApi?: unknown };
}
+3
View File
@@ -1,4 +1,5 @@
import { registerPlugin } from '@capacitor/core';
import type { NativeCapabilities } from './capabilities';
/**
* The app's own native plugin, implemented in
@@ -29,6 +30,8 @@ export interface TrekShellPlugin {
refreshOfflineCopy(): Promise<{ saved: boolean }>;
/** iOS: loads that copy under the server's origin. False when there is none. */
openOfflineCopy(): Promise<{ opened: boolean }>;
/** What this binary offers the UI (native/capabilities.ts). Missing before shell API 2. */
getCapabilities(): Promise<NativeCapabilities>;
}
export const TrekShell = registerPlugin<TrekShellPlugin>('TrekShell');
@@ -70,6 +70,10 @@ export class FeaturesController {
// routes, the shell bridge in the served UI). Raised only for a change an
// older app cannot follow; the app refuses a server below its minimum.
nativeApi: 1,
// The other way round: the oldest app binary this server's UI works with,
// as the shell API the app reports (TrekShell.getCapabilities). An app
// below it is asked to update instead of running native calls it lacks.
minNativeShellApi: 1,
};
}
}
@@ -38,6 +38,7 @@ describe('FeaturesController (GET /api/health/features)', () => {
aiAssistant: true,
nativeApp: true,
nativeApi: 1,
minNativeShellApi: 1,
});
});
@@ -49,6 +50,7 @@ describe('FeaturesController (GET /api/health/features)', () => {
aiAssistant: false,
nativeApp: true,
nativeApi: 1,
minNativeShellApi: 1,
});
});
@@ -59,6 +61,7 @@ describe('FeaturesController (GET /api/health/features)', () => {
aiAssistant: false,
nativeApp: true,
nativeApi: 1,
minNativeShellApi: 1,
});
expect(await make(false, true).controller.features()).toEqual({
bookingImport: false,
@@ -66,6 +69,7 @@ describe('FeaturesController (GET /api/health/features)', () => {
aiAssistant: true,
nativeApp: true,
nativeApi: 1,
minNativeShellApi: 1,
});
});
@@ -91,6 +95,8 @@ describe('FeaturesController (GET /api/health/features)', () => {
const features = await make(false, false).controller.features();
expect(features.nativeApp).toBe(true);
expect(features.nativeApi).toBe(1);
// And which app binaries its UI still works with.
expect(features.minNativeShellApi).toBe(1);
});
it('FEAT-005: bookingImport is read live, not cached at construction', async () => {
+3
View File
@@ -71,6 +71,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'شارف على الجهوزية',
'native.provisioning.failedSub': 'TREK الخاص بك في الطريق. حاول مرة أخرى بعد قليل، أو افتح التطبيق لاحقًا.',
'native.provisioning.retry': 'حاول الآن',
'native.update.title': 'حدّث تطبيق TREK',
'native.update.body': 'يحتاج هذا الخادم إلى إصدار أحدث من التطبيق ليعمل كل شيء.',
'native.update.open': 'تحديث',
};
export default native;
+3
View File
@@ -72,6 +72,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Demək olar ki hazırdır',
'native.provisioning.failedSub': 'TREK-iniz yoldadır. Bir azdan yenidən cəhd edin və ya tətbiqi sonra açın.',
'native.provisioning.retry': 'İndi cəhd edin',
'native.update.title': 'TREK tətbiqini yeniləyin',
'native.update.body': 'Hər şeyin işləməsi üçün bu server tətbiqin daha yeni versiyasını tələb edir.',
'native.update.open': 'Yenilə',
};
export default native;
+3
View File
@@ -72,6 +72,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Quase pronto',
'native.provisioning.failedSub': 'Seu TREK está a caminho. Tente novamente em instantes ou abra o app mais tarde.',
'native.provisioning.retry': 'Tentar agora',
'native.update.title': 'Atualize o app TREK',
'native.update.body': 'Este servidor precisa de uma versão mais nova do app para tudo funcionar.',
'native.update.open': 'Atualizar',
};
export default native;
+3
View File
@@ -72,6 +72,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Gairebé a punt',
'native.provisioning.failedSub': 'El teu TREK està en camí. Torna-ho a provar en un moment o obre l’app més tard.',
'native.provisioning.retry': 'Prova-ho ara',
'native.update.title': 'Actualitza l’app TREK',
'native.update.body': 'Aquest servidor necessita una versió més nova de l’app perquè tot funcioni.',
'native.update.open': 'Actualitza',
};
export default native;
+3
View File
@@ -72,6 +72,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Téměř hotovo',
'native.provisioning.failedSub': 'Váš TREK je na cestě. Zkuste to za chvíli znovu nebo otevřete aplikaci později.',
'native.provisioning.retry': 'Zkusit teď',
'native.update.title': 'Aktualizujte aplikaci TREK',
'native.update.body': 'Tento server potřebuje novější verzi aplikace, aby vše fungovalo.',
'native.update.open': 'Aktualizovat',
};
export default native;
+3
View File
@@ -73,6 +73,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Gleich fertig',
'native.provisioning.failedSub': 'Deine TREK ist unterwegs. Versuch es gleich noch einmal oder öffne die App später.',
'native.provisioning.retry': 'Jetzt versuchen',
'native.update.title': 'TREK-App aktualisieren',
'native.update.body': 'Dieser Server braucht eine neuere App-Version, damit alles funktioniert.',
'native.update.open': 'Aktualisieren',
};
export default native;
+3
View File
@@ -72,6 +72,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Almost ready',
'native.provisioning.failedSub': 'Your TREK is on its way. Try again in a moment, or open the app later.',
'native.provisioning.retry': 'Try now',
'native.update.title': 'Update the TREK app',
'native.update.body': 'This server needs a newer version of the app for everything to work.',
'native.update.open': 'Update',
};
export default native;
+3
View File
@@ -73,6 +73,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Casi listo',
'native.provisioning.failedSub': 'Tu TREK está en camino. Inténtalo en un momento o abre la app más tarde.',
'native.provisioning.retry': 'Probar ahora',
'native.update.title': 'Actualiza la app TREK',
'native.update.body': 'Este servidor necesita una versión más reciente de la app para que todo funcione.',
'native.update.open': 'Actualizar',
};
export default native;
+3
View File
@@ -72,6 +72,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Peaaegu valmis',
'native.provisioning.failedSub': 'Sinu TREK on teel. Proovi hetke pärast uuesti või ava rakendus hiljem.',
'native.provisioning.retry': 'Proovi nüüd',
'native.update.title': 'Uuenda TREK-i rakendust',
'native.update.body': 'See server vajab kõige toimimiseks rakenduse uuemat versiooni.',
'native.update.open': 'Uuenda',
};
export default native;
+3
View File
@@ -73,6 +73,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Presque prêt',
'native.provisioning.failedSub': 'Votre TREK arrive. Réessayez dans un instant ou rouvrez l’app plus tard.',
'native.provisioning.retry': 'Réessayer',
'native.update.title': 'Mettez à jour l’app TREK',
'native.update.body': 'Ce serveur a besoin d’une version plus récente de l’app pour que tout fonctionne.',
'native.update.open': 'Mettre à jour',
};
export default native;
+3
View File
@@ -75,6 +75,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Σχεδόν έτοιμο',
'native.provisioning.failedSub': 'Το TREK σας έρχεται. Δοκιμάστε ξανά σε λίγο ή ανοίξτε την εφαρμογή αργότερα.',
'native.provisioning.retry': 'Δοκιμάστε τώρα',
'native.update.title': 'Ενημερώστε την εφαρμογή TREK',
'native.update.body': 'Αυτός ο διακομιστής χρειάζεται νεότερη έκδοση της εφαρμογής για να λειτουργούν όλα.',
'native.update.open': 'Ενημέρωση',
};
export default native;
+3
View File
@@ -71,6 +71,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'כמעט מוכן',
'native.provisioning.failedSub': 'ה-TREK שלכם בדרך. נסו שוב עוד רגע, או פתחו את האפליקציה מאוחר יותר.',
'native.provisioning.retry': 'נסו עכשיו',
'native.update.title': 'עדכנו את אפליקציית TREK',
'native.update.body': 'השרת הזה צריך גרסה חדשה יותר של האפליקציה כדי שהכול יעבוד.',
'native.update.open': 'עדכון',
};
export default native;
+3
View File
@@ -74,6 +74,9 @@ const native: TranslationStrings = {
'native.provisioning.failedSub':
'A TREK-ed úton van. Próbáld újra egy pillanat múlva, vagy nyisd meg az appot később.',
'native.provisioning.retry': 'Próbáld most',
'native.update.title': 'Frissítsd a TREK alkalmazást',
'native.update.body': 'Ehhez a szerverhez az alkalmazás újabb verziója kell, hogy minden működjön.',
'native.update.open': 'Frissítés',
};
export default native;
+3
View File
@@ -73,6 +73,9 @@ const native: TranslationStrings = {
'native.provisioning.failedSub':
'TREK Anda sedang dalam perjalanan. Coba lagi sebentar lagi, atau buka aplikasi nanti.',
'native.provisioning.retry': 'Coba sekarang',
'native.update.title': 'Perbarui aplikasi TREK',
'native.update.body': 'Server ini memerlukan versi aplikasi yang lebih baru agar semuanya berfungsi.',
'native.update.open': 'Perbarui',
};
export default native;
+3
View File
@@ -73,6 +73,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Quasi pronto',
'native.provisioning.failedSub': 'Il tuo TREK sta arrivando. Riprova tra poco o riapri l’app più tardi.',
'native.provisioning.retry': 'Riprova',
'native.update.title': 'Aggiorna l’app TREK',
'native.update.body': 'Questo server richiede una versione più recente dell’app perché tutto funzioni.',
'native.update.open': 'Aggiorna',
};
export default native;
+3
View File
@@ -73,6 +73,9 @@ const native: TranslationStrings = {
'native.provisioning.failedSub':
'TREKはまもなく準備できます。少ししてからもう一度試すか、後でアプリを開いてください。',
'native.provisioning.retry': '今すぐ試す',
'native.update.title': 'TREKアプリを更新してください',
'native.update.body': 'すべての機能を使うには、このサーバーに対応した新しいバージョンのアプリが必要です。',
'native.update.open': '更新',
};
export default native;
+3
View File
@@ -71,6 +71,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': '거의 준비됐어요',
'native.provisioning.failedSub': 'TREK이 곧 준비됩니다. 잠시 후 다시 시도하거나 나중에 앱을 열어 보세요.',
'native.provisioning.retry': '지금 시도',
'native.update.title': 'TREK 앱을 업데이트하세요',
'native.update.body': '모든 기능을 사용하려면 이 서버에 맞는 최신 앱 버전이 필요합니다.',
'native.update.open': '업데이트',
};
export default native;
+3
View File
@@ -72,6 +72,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Bijna klaar',
'native.provisioning.failedSub': 'Je TREK is onderweg. Probeer het zo opnieuw of open de app later.',
'native.provisioning.retry': 'Nu proberen',
'native.update.title': 'Werk de TREK-app bij',
'native.update.body': 'Deze server heeft een nieuwere versie van de app nodig om alles te laten werken.',
'native.update.open': 'Bijwerken',
};
export default native;
+3
View File
@@ -72,6 +72,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Już prawie',
'native.provisioning.failedSub': 'Twój TREK jest w drodze. Spróbuj za chwilę lub otwórz aplikację później.',
'native.provisioning.retry': 'Spróbuj teraz',
'native.update.title': 'Zaktualizuj aplikację TREK',
'native.update.body': 'Ten serwer wymaga nowszej wersji aplikacji, aby wszystko działało.',
'native.update.open': 'Aktualizuj',
};
export default native;
+3
View File
@@ -73,6 +73,9 @@ const native: TranslationStrings = {
'native.provisioning.failedSub':
'Ваш TREK уже в пути. Повторите попытку через мгновение или откройте приложение позже.',
'native.provisioning.retry': 'Повторить сейчас',
'native.update.title': 'Обновите приложение TREK',
'native.update.body': 'Для полной работы этому серверу нужна более новая версия приложения.',
'native.update.open': 'Обновить',
};
export default native;
+3
View File
@@ -72,6 +72,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Takmer hotovo',
'native.provisioning.failedSub': 'Váš TREK je na ceste. Skúste to o chvíľu znova alebo otvorte aplikáciu neskôr.',
'native.provisioning.retry': 'Skúsiť teraz',
'native.update.title': 'Aktualizujte aplikáciu TREK',
'native.update.body': 'Tento server potrebuje novšiu verziu aplikácie, aby všetko fungovalo.',
'native.update.open': 'Aktualizovať',
};
export default native;
+3
View File
@@ -73,6 +73,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Snart klart',
'native.provisioning.failedSub': 'Din TREK är på väg. Försök igen om en stund eller öppna appen senare.',
'native.provisioning.retry': 'Försök nu',
'native.update.title': 'Uppdatera TREK-appen',
'native.update.body': 'Den här servern behöver en nyare version av appen för att allt ska fungera.',
'native.update.open': 'Uppdatera',
};
export default native;
+3
View File
@@ -71,6 +71,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'เกือบเสร็จแล้ว',
'native.provisioning.failedSub': 'TREK ของคุณกำลังมา ลองอีกครั้งในอีกสักครู่ หรือเปิดแอปในภายหลัง',
'native.provisioning.retry': 'ลองตอนนี้',
'native.update.title': 'อัปเดตแอป TREK',
'native.update.body': 'เซิร์ฟเวอร์นี้ต้องใช้แอปเวอร์ชันใหม่กว่าเพื่อให้ทุกอย่างทำงานได้',
'native.update.open': 'อัปเดต',
};
export default native;
+3
View File
@@ -73,6 +73,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Neredeyse hazır',
'native.provisioning.failedSub': 'TREK’iniz yolda. Birazdan tekrar deneyin ya da uygulamayı sonra açın.',
'native.provisioning.retry': 'Şimdi dene',
'native.update.title': 'TREK uygulamasını güncelleyin',
'native.update.body': 'Her şeyin çalışması için bu sunucu uygulamanın daha yeni bir sürümüne ihtiyaç duyuyor.',
'native.update.open': 'Güncelle',
};
export default native;
+3
View File
@@ -72,6 +72,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Майже готово',
'native.provisioning.failedSub': 'Ваш TREK уже в дорозі. Повторіть спробу за мить або відкрийте застосунок пізніше.',
'native.provisioning.retry': 'Повторити зараз',
'native.update.title': 'Оновіть застосунок TREK',
'native.update.body': 'Для повної роботи цьому серверу потрібна новіша версія застосунку.',
'native.update.open': 'Оновити',
};
export default native;
+3
View File
@@ -73,6 +73,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': 'Gần xong',
'native.provisioning.failedSub': 'TREK của bạn đang trên đường. Hãy thử lại sau giây lát, hoặc mở ứng dụng sau.',
'native.provisioning.retry': 'Thử ngay',
'native.update.title': 'Cập nhật ứng dụng TREK',
'native.update.body': 'Máy chủ này cần phiên bản ứng dụng mới hơn để mọi thứ hoạt động.',
'native.update.open': 'Cập nhật',
};
export default native;
+3
View File
@@ -71,6 +71,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': '即將就緒',
'native.provisioning.failedSub': '你的 TREK 即將就緒。請稍後重試,或稍後開啟應用程式。',
'native.provisioning.retry': '立即重試',
'native.update.title': '請更新 TREK 應用程式',
'native.update.body': '此伺服器需要較新版本的應用程式才能正常使用所有功能。',
'native.update.open': '更新',
};
export default native;
+3
View File
@@ -71,6 +71,9 @@ const native: TranslationStrings = {
'native.provisioning.failedTitle': '即将就绪',
'native.provisioning.failedSub': '你的 TREK 正在路上。请稍后重试,或稍后打开应用。',
'native.provisioning.retry': '立即重试',
'native.update.title': '请更新 TREK 应用',
'native.update.body': '此服务器需要更新版本的应用才能正常使用全部功能。',
'native.update.open': '更新',
};
export default native;