Реализована проверка версионности и требование soft\force обновлений приложения
This commit is contained in:
@@ -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-код запрещены.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+3
-2
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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`;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user