Реализована проверка версионности и требование soft\force обновлений приложения

This commit is contained in:
mi
2026-09-03 19:12:38 +03:00
parent 465a70d488
commit 44db38f6fe
37 changed files with 3057 additions and 53 deletions
+1
View File
@@ -3,5 +3,6 @@ EXPO_PUBLIC_AUTH_BASE_URL=https://chat.example.ru/auth
EXPO_PUBLIC_KEYCLOAK_REALM=han-chat
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend
EXPO_PUBLIC_APP_ENV=production-like
EXPO_PUBLIC_DISTRIBUTION_STORE=disabled
# Только публичные значения. Service tokens, S3 credentials и OTP-код запрещены.
+84 -8
View File
@@ -1,6 +1,7 @@
# HAN Chat Mobile
Мобильный Android-клиент HAN Chat на Expo SDK 57.
Мобильный клиент HAN Chat на Expo SDK 57. Текущая версия приложения — `1.0.1`,
native identity — Android `versionCode: 2` и iOS `buildNumber: 2`.
## Подготовка
@@ -23,15 +24,49 @@ npm test
Запуск приложения выполняется отдельно командой `npm run android`. Проект использует managed Expo workflow и не хранит каталоги `android/` и `ios/`.
## Store-only обновления
Приложение не загружает JS/asset OTA-обновления. Оно читает `mobile_update` из
`GET /api/v1/public/app-config` и отправляет пользователя в магазин своей сборки.
Канал фиксируется на этапе EAS build:
- `EXPO_PUBLIC_DISTRIBUTION_STORE=google_play`;
- `EXPO_PUBLIC_DISTRIBUTION_STORE=rustore`;
- `EXPO_PUBLIC_DISTRIBUTION_STORE=app_store`.
Неизвестное или отсутствующее значение отключает механику. Поэтому профили
`development` и `preview` имеют значение `disabled`. Профиль
`preview-rustore` намеренно задаёт `rustore`, чтобы проверять update policy на
внутреннем APK через dev-backend.
Решение принимается только по целому native build:
- build ниже `minimum_build` — обязательное (`force`) обновление;
- build ниже `latest_build`, но не ниже minimum — мягкое (`soft`) обновление;
- build не ниже latest — обновление не показывается.
Soft-карточку можно закрыть; выбор запоминается в SecureStore отдельно для пары
`канал + latest_build`. Force-карточка блокирует Android Back и не имеет способов
закрытия. Проверка выполняется на холодном старте и при возврате в foreground,
параллельные проверки объединяются. Сетевая ошибка, невалидный build или
недоверенный URL работают fail-open и не блокируют интерфейс.
Допускаются только HTTPS-ссылки соответствующего магазина:
`play.google.com/store/apps/details?id=ru.han.chat`,
`www.rustore.ru/catalog/app/ru.han.chat` и App Store URL на `apps.apple.com` с Apple ID.
## Сборка (EAS)
Профили заданы в `eas.json`. Env для билда берётся из профиля (`eas.json` → `env`), а не из локального `.env`.
| Профиль | Артефакт | API | `EXPO_PUBLIC_APP_ENV` |
|---|---|---|---|
| `preview` | APK (внутренняя установка) | `dev-chat.han0107.ru` | `production-like` (есть «Диагностика») |
| `production` | AAB (Google Play) | `chat.han0107.ru` | `production` (без диагностики) |
| `development` | Dev Client | из окружения/локально | для разработки с `expo-dev-client` |
| `preview` | APK (внутренняя установка) | `dev-chat.han0107.ru` | update policy отключена |
| `preview-rustore` | APK (тест RuStore policy) | `dev-chat.han0107.ru` | `rustore` |
| `google-play` | AAB (Google Play) | `chat.han0107.ru` | `google_play` |
| `rustore` | AAB (RuStore) | `chat.han0107.ru` | `rustore` |
| `app-store` | iOS App Store | `chat.han0107.ru` | `app_store` |
| `development` | Dev Client | из окружения/локально | update policy отключена |
### Один раз: вход в Expo
@@ -58,19 +93,52 @@ npx eas-cli build --profile preview --platform android
После успеха откройте ссылку из вывода CLI (или `https://expo.dev/accounts/anzh/projects/han-chat/builds`) и скачайте APK.
### Production — AAB для Play Store
### Preview RuStore — проверка soft/force update
```powershell
npx eas-cli build --profile production --platform android
npx eas-cli build:version:get --platform android --profile preview-rustore
npx eas-cli build --profile preview-rustore --platform android
```
`versionCode` поднимается автоматически (`appVersionSource: remote` + `autoIncrement`).
Профиль использует dev API и встраивает канал `rustore`, но не увеличивает общий
remote Android `versionCode` (`autoIncrement: false`). Запишите build из
`build:version:get` или карточки завершённой сборки. Для build `B` настройте
политику RuStore на dev-backend:
- soft: `latest_build=B+1`, `minimum_build<=B`;
- force: `latest_build=B+1`, `minimum_build=B+1`;
- none: `latest_build<=B`.
После изменения dev settings примените штатный `seed-settings`, подождите до
истечения cache TTL и перезапустите APK. Для повторной проверки soft на том же
`latest_build` очистите данные приложения или увеличьте `latest_build`, так как
«Позже» сохраняется в SecureStore без TTL.
APK имеет тот же package `ru.han.chat` и подпись EAS, поэтому может заменить
установленную store-сборку. Используйте отдельное тестовое устройство или заранее
учтите замену приложения и его локальных данных.
### Store-сборки
```powershell
npx eas-cli build --profile google-play --platform android
npx eas-cli build --profile rustore --platform android
npx eas-cli build --profile app-store --platform ios
```
Native build поднимается через `appVersionSource: remote` и platform-specific
`autoIncrement`. Google Play и RuStore используют общий Android application ID
`ru.han.chat`, поэтому их последовательные сборки увеличивают один remote
`versionCode`: например, первая получит `7`, следующая — `8`. Backend-пороги
магазинов поэтому заполняются независимо по фактически опубликованным
артефактам. Локальные `versionCode: 2`/`buildNumber: 2` служат исходной native
identity, но при remote source фактическое значение сборки определяет EAS.
### Полезные команды
```powershell
# только отправить билд и сразу вернуть ссылку (не ждать окончания)
npx eas-cli build --profile production --platform android --no-wait
npx eas-cli build --profile google-play --platform android --no-wait
# статус конкретной сборки
npx eas-cli build:view <BUILD_ID>
@@ -78,6 +146,11 @@ npx eas-cli build:view <BUILD_ID>
# список последних сборок
npx eas-cli build:list --platform android --limit 5
# прочитать текущий remote versionCode/buildNumber
npx eas-cli build:version:get --platform android --profile google-play
npx eas-cli build:version:get --platform android --profile rustore
npx eas-cli build:version:get --platform ios --profile app-store
# скачать артефакт готовой сборки
npx eas-cli build:download --id <BUILD_ID>
@@ -85,6 +158,9 @@ npx eas-cli build:download --id <BUILD_ID>
npx eas-cli build:version:set
```
Полная процедура rollout, включения soft/force policy и отката:
`../VM1_app/codebase/backend/deployment/RUNBOOK.mobile-updates.ru.md`.
### Keycloak redirect URI
Для боевого хоста в клиенте `han-chat-frontend` должны быть exact URI:
+6 -1
View File
@@ -4,13 +4,14 @@ const config: ExpoConfig = {
name: "HAN Chat",
slug: "han-chat",
owner: "anzh",
version: "1.0.0",
version: "1.0.1",
scheme: "han-chat",
icon: "./.assets/icons/icon.png",
orientation: "portrait",
userInterfaceStyle: "light",
android: {
package: "ru.han.chat",
versionCode: 2,
softwareKeyboardLayoutMode: "resize",
adaptiveIcon: {
foregroundImage: "./.assets/icons/adaptive-foreground.png",
@@ -32,6 +33,10 @@ const config: ExpoConfig = {
},
],
},
ios: {
bundleIdentifier: "ru.han.chat",
buildNumber: "2",
},
experiments: {
typedRoutes: true,
},
+9 -6
View File
@@ -3,15 +3,18 @@ import { StatusBar } from "expo-status-bar";
import React from "react";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import { AppProvider } from "../src/app-context";
import { AppUpdateProvider } from "../src/app-update-context";
import { colors } from "../src/theme";
export default function RootLayout() {
return <SafeAreaProvider>
<AppProvider>
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<StatusBar style="dark" />
<Stack screenOptions={{ headerShown: false }} />
</SafeAreaView>
</AppProvider>
<AppUpdateProvider>
<AppProvider>
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<StatusBar style="dark" />
<Stack screenOptions={{ headerShown: false }} />
</SafeAreaView>
</AppProvider>
</AppUpdateProvider>
</SafeAreaProvider>;
}
+56 -5
View File
@@ -6,7 +6,10 @@
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
"distribution": "internal",
"env": {
"EXPO_PUBLIC_DISTRIBUTION_STORE": "disabled"
}
},
"preview": {
"distribution": "internal",
@@ -19,18 +22,66 @@
"EXPO_PUBLIC_AUTH_BASE_URL": "https://dev-chat.han0107.ru/auth",
"EXPO_PUBLIC_KEYCLOAK_REALM": "han-chat",
"EXPO_PUBLIC_KEYCLOAK_CLIENT_ID": "han-chat-frontend",
"EXPO_PUBLIC_APP_ENV": "production-like"
"EXPO_PUBLIC_APP_ENV": "production-like",
"EXPO_PUBLIC_DISTRIBUTION_STORE": "disabled"
}
},
"production": {
"preview-rustore": {
"distribution": "internal",
"android": {
"buildType": "apk",
"autoIncrement": false
},
"env": {
"EXPO_PUBLIC_API_BASE_URL": "https://dev-chat.han0107.ru",
"EXPO_PUBLIC_AUTH_BASE_URL": "https://dev-chat.han0107.ru/auth",
"EXPO_PUBLIC_KEYCLOAK_REALM": "han-chat",
"EXPO_PUBLIC_KEYCLOAK_CLIENT_ID": "han-chat-frontend",
"EXPO_PUBLIC_APP_ENV": "production-like",
"EXPO_PUBLIC_DISTRIBUTION_STORE": "rustore"
}
},
"google-play": {
"distribution": "store",
"autoIncrement": true,
"android": {
"autoIncrement": true
},
"env": {
"EXPO_PUBLIC_API_BASE_URL": "https://chat.han0107.ru",
"EXPO_PUBLIC_AUTH_BASE_URL": "https://chat.han0107.ru/auth",
"EXPO_PUBLIC_KEYCLOAK_REALM": "han-chat",
"EXPO_PUBLIC_KEYCLOAK_CLIENT_ID": "han-chat-frontend",
"EXPO_PUBLIC_APP_ENV": "production"
"EXPO_PUBLIC_APP_ENV": "production",
"EXPO_PUBLIC_DISTRIBUTION_STORE": "google_play"
}
},
"rustore": {
"distribution": "store",
"android": {
"buildType": "app-bundle",
"autoIncrement": true
},
"env": {
"EXPO_PUBLIC_API_BASE_URL": "https://chat.han0107.ru",
"EXPO_PUBLIC_AUTH_BASE_URL": "https://chat.han0107.ru/auth",
"EXPO_PUBLIC_KEYCLOAK_REALM": "han-chat",
"EXPO_PUBLIC_KEYCLOAK_CLIENT_ID": "han-chat-frontend",
"EXPO_PUBLIC_APP_ENV": "production",
"EXPO_PUBLIC_DISTRIBUTION_STORE": "rustore"
}
},
"app-store": {
"distribution": "store",
"ios": {
"autoIncrement": true
},
"env": {
"EXPO_PUBLIC_API_BASE_URL": "https://chat.han0107.ru",
"EXPO_PUBLIC_AUTH_BASE_URL": "https://chat.han0107.ru/auth",
"EXPO_PUBLIC_KEYCLOAK_REALM": "han-chat",
"EXPO_PUBLIC_KEYCLOAK_CLIENT_ID": "han-chat-frontend",
"EXPO_PUBLIC_APP_ENV": "production",
"EXPO_PUBLIC_DISTRIBUTION_STORE": "app_store"
}
}
}
+3 -2
View File
@@ -1,16 +1,17 @@
{
"name": "han-chat",
"version": "1.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "han-chat",
"version": "1.0.0",
"version": "1.0.1",
"dependencies": {
"@expo/vector-icons": "15.0.3",
"@tanstack/react-query": "5.101.2",
"expo": "~57.0.15",
"expo-application": "~57.0.2",
"expo-auth-session": "~57.0.8",
"expo-constants": "~57.0.13",
"expo-crypto": "~57.0.1",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "han-chat",
"version": "1.0.0",
"version": "1.0.1",
"private": true,
"main": "expo-router/entry",
"scripts": {
@@ -16,6 +16,7 @@
"@expo/vector-icons": "15.0.3",
"@tanstack/react-query": "5.101.2",
"expo": "~57.0.15",
"expo-application": "~57.0.2",
"expo-auth-session": "~57.0.8",
"expo-constants": "~57.0.13",
"expo-crypto": "~57.0.1",
@@ -0,0 +1,98 @@
import * as Application from "expo-application";
import * as SecureStore from "expo-secure-store";
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { UpdateModal } from "./components/UpdateModal";
import { env } from "./config";
import {
isSoftUpdateDismissed,
parseNativeBuild,
persistSoftUpdateDismiss,
selectAppUpdate,
type AppUpdate,
} from "./app-update";
import { publicApi } from "./services";
type AppUpdateContextValue = {
update: AppUpdate | null;
checkForUpdate: () => Promise<void>;
dismissSoftUpdate: () => Promise<void>;
};
const Context = createContext<AppUpdateContextValue | null>(null);
export function AppUpdateProvider({ children }: { children: React.ReactNode }) {
const [update, setUpdate] = useState<AppUpdate | null>(null);
const checkPromise = useRef<Promise<void> | null>(null);
const appState = useRef<AppStateStatus>(AppState.currentState);
const checkForUpdate = useCallback(() => {
if (checkPromise.current) return checkPromise.current;
const operation = (async () => {
try {
const config = await publicApi.config();
const candidate = selectAppUpdate(
env.distributionStore,
{
version: Application.nativeApplicationVersion ?? "—",
build: parseNativeBuild(Application.nativeBuildVersion),
},
config.mobile_update,
);
if (candidate?.kind === "soft") {
const dismissed = await isSoftUpdateDismissed(candidate, {
getItem: SecureStore.getItemAsync,
});
setUpdate(dismissed ? null : candidate);
} else {
setUpdate(candidate);
}
} catch {
// Fail-open: недоступная или некорректная политика не блокирует приложение.
setUpdate((current) => current?.kind === "force" ? current : null);
}
})().finally(() => {
checkPromise.current = null;
});
checkPromise.current = operation;
return operation;
}, []);
useEffect(() => {
void checkForUpdate();
const subscription = AppState.addEventListener("change", (nextState) => {
const wasInactive = appState.current === "inactive" || appState.current === "background";
appState.current = nextState;
if (wasInactive && nextState === "active") void checkForUpdate();
});
return () => subscription.remove();
}, [checkForUpdate]);
const dismissSoftUpdate = useCallback(async () => {
if (update?.kind !== "soft") return;
setUpdate(null);
await persistSoftUpdateDismiss(update, { setItem: SecureStore.setItemAsync });
}, [update]);
const value = useMemo<AppUpdateContextValue>(() => ({
update,
checkForUpdate,
dismissSoftUpdate,
}), [update, checkForUpdate, dismissSoftUpdate]);
return (
<Context.Provider value={value}>
{children}
<UpdateModal update={update} onDismiss={dismissSoftUpdate} />
</Context.Provider>
);
}
export function useAppUpdate() {
const value = useContext(Context);
if (!value) throw new Error("AppUpdateProvider is missing");
return value;
}
+134
View File
@@ -0,0 +1,134 @@
import type { DistributionStore } from "./config";
import type { MobileUpdatePolicy } from "./types";
export type UpdateKind = "none" | "soft" | "force";
export type AppIdentity = {
version: string;
build: number | null;
};
export type UpdateDismissStore = {
getItem: (key: string) => Promise<string | null>;
setItem: (key: string, value: string) => Promise<void>;
};
export type AppUpdate = {
kind: Exclude<UpdateKind, "none">;
channel: DistributionStore;
currentVersion: string;
currentBuild: number;
latestVersion: string;
latestBuild: number;
storeUrl: string;
releaseNotes?: string;
};
export function parseNativeBuild(value: string | null | undefined): number | null {
if (!value || !/^[1-9]\d*$/.test(value)) return null;
const build = Number(value);
return Number.isSafeInteger(build) ? build : null;
}
export function isTrustedStoreUrl(channel: DistributionStore, value: string): boolean {
try {
const url = new URL(value);
if (
url.protocol !== "https:" ||
url.username !== "" ||
url.password !== "" ||
url.hash !== ""
) return false;
if (channel === "google_play") {
return url.hostname.toLowerCase() === "play.google.com" &&
url.pathname === "/store/apps/details" &&
url.searchParams.getAll("id").length === 1 &&
url.searchParams.get("id") === "ru.han.chat";
}
if (channel === "rustore") {
return url.hostname.toLowerCase() === "www.rustore.ru" &&
url.pathname.replace(/\/$/, "") === "/catalog/app/ru.han.chat" &&
url.search === "";
}
return url.hostname.toLowerCase() === "apps.apple.com" &&
/\/id\d+\/?$/.test(url.pathname);
} catch {
return false;
}
}
export function classifyUpdate(currentBuild: number | null, policy: MobileUpdatePolicy): UpdateKind {
if (
!policy.enabled ||
currentBuild === null ||
!Number.isSafeInteger(currentBuild) ||
currentBuild < 1 ||
policy.minimum_build === null ||
policy.latest_build === null ||
!Number.isSafeInteger(policy.minimum_build) ||
!Number.isSafeInteger(policy.latest_build) ||
policy.minimum_build < 1 ||
policy.latest_build < policy.minimum_build
) return "none";
if (currentBuild < policy.minimum_build) return "force";
if (currentBuild < policy.latest_build) return "soft";
return "none";
}
export function selectAppUpdate(
channel: DistributionStore | null,
identity: AppIdentity,
policies: Partial<Record<DistributionStore, MobileUpdatePolicy>> | undefined,
): AppUpdate | null {
if (!channel) return null;
const policy = policies?.[channel];
if (
!policy ||
policy.latest_build === null ||
policy.latest_version === null ||
policy.store_url === null ||
!isTrustedStoreUrl(channel, policy.store_url)
) return null;
const kind = classifyUpdate(identity.build, policy);
if (kind === "none" || identity.build === null) return null;
return {
kind,
channel,
currentVersion: identity.version,
currentBuild: identity.build,
latestVersion: policy.latest_version,
latestBuild: policy.latest_build,
storeUrl: policy.store_url,
...(policy.release_notes ? { releaseNotes: policy.release_notes } : {}),
};
}
export function softDismissKey(channel: DistributionStore, latestBuild: number): string {
return `han.app-update.dismissed.${channel}.${latestBuild}`;
}
export async function isSoftUpdateDismissed(
update: AppUpdate,
store: Pick<UpdateDismissStore, "getItem">,
): Promise<boolean> {
try {
return Boolean(await store.getItem(softDismissKey(update.channel, update.latestBuild)));
} catch {
return false;
}
}
export async function persistSoftUpdateDismiss(
update: AppUpdate,
store: Pick<UpdateDismissStore, "setItem">,
): Promise<boolean> {
try {
await store.setItem(softDismissKey(update.channel, update.latestBuild), new Date().toISOString());
return true;
} catch {
return false;
}
}
@@ -0,0 +1,232 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useEffect, useState } from "react";
import {
ActivityIndicator,
Linking,
Modal,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
import type { AppUpdate } from "../app-update";
import { colors, radii, spacing } from "../theme";
type Props = {
update: AppUpdate | null;
onDismiss: () => Promise<void>;
};
export function UpdateModal({ update, onDismiss }: Props) {
const [opening, setOpening] = useState(false);
const [urlError, setUrlError] = useState<string | null>(null);
const forced = update?.kind === "force";
useEffect(() => setUrlError(null), [update]);
if (!update) return null;
const openStore = async () => {
if (opening) return;
setOpening(true);
setUrlError(null);
try {
await Linking.openURL(update.storeUrl);
if (!forced) await onDismiss();
} catch {
setUrlError("Не удалось открыть магазин. Проверьте подключение и повторите попытку.");
} finally {
setOpening(false);
}
};
const dismiss = () => {
if (!forced) void onDismiss();
};
return (
<Modal
visible
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={dismiss}
accessibilityViewIsModal
>
<View style={styles.overlay}>
<View
style={styles.card}
accessibilityRole="alert"
accessibilityLabel={forced ? "Обновление приложения обязательно" : "Доступно обновление приложения"}
>
<View style={[styles.header, forced ? styles.forceHeader : styles.softHeader]}>
{!forced && (
<Pressable
onPress={dismiss}
accessibilityRole="button"
accessibilityLabel="Закрыть предложение обновления"
hitSlop={12}
style={({ pressed }) => [styles.close, pressed && styles.pressed]}
>
<Ionicons name="close" size={20} color={colors.mutedForeground} />
</Pressable>
)}
<View style={[styles.iconCircle, forced ? styles.forceIcon : styles.softIcon]}>
<Ionicons
name={forced ? "warning-outline" : "arrow-up-circle-outline"}
size={30}
color={forced ? colors.destructive : colors.primary}
/>
</View>
<Text style={styles.title}>
{forced ? "Обновление обязательно" : "Доступно обновление"}
</Text>
<Text style={styles.subtitle}>
Версия <Text style={styles.versionStrong}>{update.latestVersion}</Text>
</Text>
</View>
<View style={styles.body}>
<Text style={styles.description}>
{forced
? `Версия ${update.currentVersion} больше не поддерживается. Для продолжения работы необходимо установить обновление.`
: "Вышла новая версия приложения. Вы можете обновить его сейчас или сделать это позже."}
</Text>
{update.releaseNotes ? (
<View style={styles.notes}>
<Text style={styles.notesText}>{update.releaseNotes}</Text>
</View>
) : null}
<View style={styles.versionRow} accessibilityLabel={`Текущая версия ${update.currentVersion}, новая версия ${update.latestVersion}`}>
<Text style={styles.versionBadge}>{update.currentVersion}</Text>
<Ionicons name="arrow-forward" size={14} color={colors.mutedForeground} />
<Text style={[
styles.versionBadge,
styles.latestBadge,
forced ? styles.forceLatest : styles.softLatest,
]}>
{update.latestVersion}
</Text>
</View>
{urlError ? (
<Text style={styles.error} accessibilityRole="alert" accessibilityLiveRegion="polite">
{urlError}
</Text>
) : null}
</View>
<View style={styles.actions}>
<Pressable
onPress={() => void openStore()}
disabled={opening}
accessibilityRole="button"
accessibilityLabel="Обновить приложение в магазине"
accessibilityState={{ disabled: opening, busy: opening }}
style={({ pressed }) => [
styles.primaryButton,
forced ? styles.forceButton : styles.softButton,
(pressed || opening) && styles.pressed,
]}
>
{opening
? <ActivityIndicator color={colors.primaryForeground} />
: <Text style={styles.primaryButtonText}>Обновить приложение</Text>}
</Pressable>
{!forced && (
<Pressable
onPress={dismiss}
accessibilityRole="button"
accessibilityLabel="Напомнить об обновлении позже"
style={({ pressed }) => [styles.laterButton, pressed && styles.pressed]}
>
<Text style={styles.laterText}>Позже</Text>
</Pressable>
)}
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: spacing.lg,
backgroundColor: "rgba(0, 0, 0, 0.60)",
},
card: {
width: "100%",
maxWidth: 360,
overflow: "hidden",
borderRadius: radii.xl,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.border,
backgroundColor: colors.card,
shadowColor: "#000",
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.3,
shadowRadius: 24,
elevation: 16,
},
header: { alignItems: "center", paddingHorizontal: 24, paddingTop: 28, paddingBottom: 20 },
softHeader: { backgroundColor: "#f7f7fa" },
forceHeader: { backgroundColor: "#fdf0f3" },
close: { position: "absolute", right: 16, top: 16, padding: 4, borderRadius: radii.full },
iconCircle: {
width: 56,
height: 56,
alignItems: "center",
justifyContent: "center",
borderRadius: radii.full,
marginBottom: spacing.md,
},
softIcon: { backgroundColor: "#e9e9ee" },
forceIcon: { backgroundColor: "#f9dfe5" },
title: { color: colors.foreground, fontSize: 18, lineHeight: 24, fontWeight: "600", textAlign: "center" },
subtitle: { marginTop: spacing.xs, color: colors.mutedForeground, fontSize: 14 },
versionStrong: { color: colors.foreground, fontWeight: "600" },
body: { paddingHorizontal: 24, paddingTop: spacing.xl },
description: { color: colors.mutedForeground, fontSize: 14, lineHeight: 21, textAlign: "center" },
notes: { marginTop: spacing.lg, padding: spacing.md, borderRadius: 12, backgroundColor: "#f5f5f7" },
notesText: { color: colors.mutedForeground, fontSize: 12, lineHeight: 18 },
versionRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
marginTop: spacing.lg,
},
versionBadge: {
overflow: "hidden",
borderRadius: radii.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
color: colors.mutedForeground,
backgroundColor: colors.muted,
fontSize: 12,
},
latestBadge: { fontWeight: "600" },
softLatest: { color: colors.primary, backgroundColor: "#e9e9ee" },
forceLatest: { color: colors.destructive, backgroundColor: "#fbe8ed" },
error: { marginTop: spacing.md, color: colors.destructive, fontSize: 12, lineHeight: 17, textAlign: "center" },
actions: { gap: spacing.sm, paddingHorizontal: 24, paddingTop: spacing.xl, paddingBottom: 24 },
primaryButton: {
minHeight: 48,
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
paddingHorizontal: spacing.lg,
},
softButton: { backgroundColor: colors.primary },
forceButton: { backgroundColor: colors.destructive },
primaryButtonText: { color: colors.primaryForeground, fontSize: 14, fontWeight: "600" },
laterButton: { minHeight: 44, alignItems: "center", justifyContent: "center", borderRadius: 12 },
laterText: { color: colors.mutedForeground, fontSize: 14, fontWeight: "500" },
pressed: { opacity: 0.72 },
});
+8
View File
@@ -1,6 +1,13 @@
const required = (value: string | undefined, fallback: string) =>
(value ?? fallback).replace(/\/$/, "");
export type DistributionStore = "google_play" | "rustore" | "app_store";
const distributionStore = (value: string | undefined): DistributionStore | null => {
if (value === "google_play" || value === "rustore" || value === "app_store") return value;
return null;
};
export const env = Object.freeze({
apiBaseUrl: required(process.env.EXPO_PUBLIC_API_BASE_URL, "http://localhost:8000"),
authBaseUrl: required(
@@ -10,6 +17,7 @@ export const env = Object.freeze({
realm: process.env.EXPO_PUBLIC_KEYCLOAK_REALM ?? "han-chat",
clientId: process.env.EXPO_PUBLIC_KEYCLOAK_CLIENT_ID ?? "han-chat-frontend",
appEnv: process.env.EXPO_PUBLIC_APP_ENV ?? "development",
distributionStore: distributionStore(process.env.EXPO_PUBLIC_DISTRIBUTION_STORE),
});
export const mobileHttpsRedirectUri = `${env.apiBaseUrl}/mobile/oidc/callback`;
+16
View File
@@ -58,6 +58,21 @@ export type DocumentItem = {
sent_at: string;
};
export type MobileUpdatePolicy = {
enabled: boolean;
minimum_build: number | null;
latest_build: number | null;
latest_version: string | null;
store_url: string | null;
release_notes?: string | null;
};
export type MobileUpdateConfig = {
google_play: MobileUpdatePolicy;
rustore: MobileUpdatePolicy;
app_store: MobileUpdatePolicy;
};
export type PublicConfig = {
auth: { phone_enabled: boolean; password_enabled: boolean };
operator: { call_phone: string };
@@ -78,6 +93,7 @@ export type PublicConfig = {
allowed_mime_types: string[];
};
ux: { idle_timeout_minutes: number };
mobile_update: MobileUpdateConfig;
};
export type PublicContent = {
locale: string;
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
classifyUpdate,
isSoftUpdateDismissed,
isTrustedStoreUrl,
parseNativeBuild,
persistSoftUpdateDismiss,
selectAppUpdate,
softDismissKey,
} from "../../src/app-update";
import type { DistributionStore } from "../../src/config";
import type { MobileUpdatePolicy } from "../../src/types";
const policy = (overrides: Partial<MobileUpdatePolicy> = {}): MobileUpdatePolicy => ({
enabled: true,
minimum_build: 2,
latest_build: 4,
latest_version: "1.0.4",
store_url: "https://play.google.com/store/apps/details?id=ru.han.chat",
...overrides,
});
describe("app update classification", () => {
it("классифицирует целочисленные границы как force, soft и none", () => {
expect(classifyUpdate(1, policy())).toBe("force");
expect(classifyUpdate(2, policy())).toBe("soft");
expect(classifyUpdate(3, policy())).toBe("soft");
expect(classifyUpdate(4, policy())).toBe("none");
expect(classifyUpdate(5, policy())).toBe("none");
});
it("работает fail-open для выключенной, отсутствующей и некорректной политики", () => {
expect(classifyUpdate(null, policy())).toBe("none");
expect(classifyUpdate(1, policy({ enabled: false }))).toBe("none");
expect(classifyUpdate(1.5, policy())).toBe("none");
expect(classifyUpdate(1, policy({ minimum_build: 5, latest_build: 4 }))).toBe("none");
});
it("принимает только положительный integer native build", () => {
expect(parseNativeBuild("2")).toBe(2);
expect(parseNativeBuild("002")).toBeNull();
expect(parseNativeBuild("2.0")).toBeNull();
expect(parseNativeBuild("0")).toBeNull();
expect(parseNativeBuild(undefined)).toBeNull();
});
});
describe("store channels and URLs", () => {
const trusted: Record<DistributionStore, string> = {
google_play: "https://play.google.com/store/apps/details?id=ru.han.chat",
rustore: "https://www.rustore.ru/catalog/app/ru.han.chat",
app_store: "https://apps.apple.com/ru/app/han-chat/id123456789",
};
it("разрешает только HTTPS URL доверенного магазина для каждого канала", () => {
for (const [channel, url] of Object.entries(trusted) as Array<[DistributionStore, string]>) {
expect(isTrustedStoreUrl(channel, url)).toBe(true);
}
expect(isTrustedStoreUrl("google_play", trusted.rustore)).toBe(false);
expect(isTrustedStoreUrl("rustore", "http://www.rustore.ru/catalog/app/ru.han.chat")).toBe(false);
expect(isTrustedStoreUrl("app_store", "https://apps.apple.com.evil.test/app/id1")).toBe(false);
expect(isTrustedStoreUrl("google_play", `${trusted.google_play}#redirect`)).toBe(false);
expect(isTrustedStoreUrl(
"google_play",
"https://play.google.com/store/apps/details?id=ru.attacker.app",
)).toBe(false);
expect(isTrustedStoreUrl(
"rustore",
"https://www.rustore.ru/catalog/app/ru.attacker.app",
)).toBe(false);
expect(isTrustedStoreUrl("rustore", `${trusted.rustore}?ref=other`)).toBe(false);
expect(isTrustedStoreUrl("app_store", "https://apps.apple.com/ru/app/han-chat")).toBe(false);
});
it("выбирает только политику канала текущей store-сборки", () => {
const policies = {
google_play: policy({ store_url: trusted.google_play }),
rustore: policy({ store_url: trusted.rustore }),
app_store: policy({ store_url: trusted.app_store }),
};
const update = selectAppUpdate("rustore", { version: "1.0.1", build: 1 }, policies);
expect(update).toMatchObject({ kind: "force", channel: "rustore", storeUrl: trusted.rustore });
expect(selectAppUpdate(null, { version: "1.0.1", build: 1 }, policies)).toBeNull();
});
it("формирует отдельный dismiss key по каналу и latest build", () => {
expect(softDismissKey("google_play", 4)).toBe("han.app-update.dismissed.google_play.4");
expect(softDismissKey("rustore", 4)).not.toBe(softDismissKey("google_play", 4));
expect(softDismissKey("google_play", 5)).not.toBe(softDismissKey("google_play", 4));
});
it("не скрывает soft update при ошибке чтения SecureStore", async () => {
const update = selectAppUpdate(
"google_play",
{ version: "1.0.1", build: 2 },
{ google_play: policy() },
);
expect(update?.kind).toBe("soft");
await expect(isSoftUpdateDismissed(update!, {
getItem: async () => { throw new Error("secure store unavailable"); },
})).resolves.toBe(false);
});
it("позволяет закрыть soft update при ошибке записи SecureStore", async () => {
const update = selectAppUpdate(
"google_play",
{ version: "1.0.1", build: 2 },
{ google_play: policy() },
);
await expect(persistSoftUpdateDismiss(update!, {
setItem: async () => { throw new Error("secure store unavailable"); },
})).resolves.toBe(false);
});
});
+2 -2
View File
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
const secureValues = vi.hoisted(() => new Map<string, string>());
vi.mock("expo-constants", () => ({
default: { expoConfig: { version: "1.0.0" } },
default: { expoConfig: { version: "1.0.1" } },
}));
vi.mock("expo-crypto", () => ({
CryptoDigestAlgorithm: { SHA256: "SHA-256" },
@@ -217,7 +217,7 @@ describe("OIDC device metadata", () => {
expect(first).toMatchObject({
han_fingerprint: "stable-fingerprint",
han_platform: "android",
han_app_version: "1.0.0",
han_app_version: "1.0.1",
});
});
});