From 765345e0f4e6d5b46c77a79e5a532844acbbeb42 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:55:34 +0300 Subject: [PATCH] feat(mobile): self-update dialog with verified APK download and install (M5) Checks the server for a newer release at most every 6 hours and on demand in Profile, shows release notes, downloads the APK with progress, verifies SHA-256 in 1 MB chunks and hands it to the system installer. Updates below min_supported cannot be dismissed. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/package.json | 3 + apps/mobile/src/app/(tabs)/_layout.tsx | 157 +++++++++++++------ apps/mobile/src/app/(tabs)/profile.tsx | 129 +++++++++++---- apps/mobile/src/lib/update.ts | 207 +++++++++++++++++++++++++ apps/mobile/src/ui/UpdateDialog.tsx | 175 +++++++++++++++++++++ docs/mobile.md | 10 +- packages/core/src/auth.ts | 5 + packages/i18n/locales/en/common.json | 16 +- packages/i18n/locales/ru/common.json | 16 +- pnpm-lock.yaml | 23 +++ 10 files changed, 663 insertions(+), 78 deletions(-) create mode 100644 apps/mobile/src/lib/update.ts create mode 100644 apps/mobile/src/ui/UpdateDialog.tsx diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 7e364d5..f4efd63 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -20,12 +20,15 @@ "expo": "~53.0.20", "expo-build-properties": "~0.14.8", "expo-constants": "~17.1.7", + "expo-file-system": "~18.1.11", "expo-font": "~13.3.2", + "expo-intent-launcher": "~12.1.5", "expo-linking": "~7.1.7", "expo-router": "~5.1.11", "expo-secure-store": "~14.2.4", "expo-status-bar": "~2.2.3", "i18next": "^25.2.1", + "js-sha256": "^1.0.0", "query-string": "^7.1.3", "react": "19.0.0", "react-i18next": "^15.5.3", diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 68b14a9..f4b64af 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -1,43 +1,55 @@ -import { Tabs, useRouter } from 'expo-router'; -import { useTranslation } from 'react-i18next'; -import { Pressable, Text, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Tabs, useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { Pressable, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { player } from '../../player/controller'; -import { usePlayerState } from '../../player/hooks'; -import { colors } from '../../theme'; -import { Cover, Icon, type IconName } from '../../ui/kit'; +import { player } from "../../player/controller"; +import { usePlayerState } from "../../player/hooks"; +import { colors } from "../../theme"; +import { Cover, Icon, type IconName } from "../../ui/kit"; +import { UpdateDialog } from "../../ui/UpdateDialog"; -const ICONS: Record = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' }; +const ICONS: Record = { + index: "home", + search: "search", + collection: "library-music", + profile: "person", +}; export default function TabsLayout() { const { t } = useTranslation(); const labels: Record = { - index: t('nav.home'), - search: t('mobile.search'), - collection: t('nav.collection'), - profile: t('mobile.profile'), + index: t("nav.home"), + search: t("mobile.search"), + collection: t("nav.collection"), + profile: t("mobile.profile"), }; return ( - ( - - - r.name)} - active={state.index} - labels={labels} - onPress={(name) => navigation.navigate(name)} - /> - - )} - > - - - - - + <> + ( + + + r.name)} + active={state.index} + labels={labels} + onPress={(name) => navigation.navigate(name)} + /> + + )} + > + + + + + + + ); } @@ -57,7 +69,7 @@ function TabBar({ - - + + {labels[name]} @@ -93,31 +119,68 @@ function MiniPlayer() { const st = usePlayerState(); const track = st.track; if (!track) return null; - const progress = st.durationMs ? Math.min(st.positionMs / st.durationMs, 1) : 0; + const progress = st.durationMs + ? Math.min(st.positionMs / st.durationMs, 1) + : 0; return ( router.push('/player')} + onPress={() => router.push("/player")} accessibilityRole="button" - accessibilityLabel={t('mobile.open_player')} - style={{ backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.line }} + accessibilityLabel={t("mobile.open_player")} + style={{ + backgroundColor: colors.surface, + borderTopWidth: 1, + borderTopColor: colors.line, + }} > - + - + - + {track.title} - {track.artists.map((a) => a.name).join(', ')} + {track.artists.map((a) => a.name).join(", ")} - player.toggle()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}> - + player.toggle()} + hitSlop={10} + accessibilityRole="button" + accessibilityLabel={t(st.playing ? "player.pause" : "player.play")} + > + - player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}> + player.next()} + hitSlop={10} + accessibilityRole="button" + accessibilityLabel={t("player.next")} + > diff --git a/apps/mobile/src/app/(tabs)/profile.tsx b/apps/mobile/src/app/(tabs)/profile.tsx index 9e9bdb5..ea3a0ae 100644 --- a/apps/mobile/src/app/(tabs)/profile.tsx +++ b/apps/mobile/src/app/(tabs)/profile.tsx @@ -1,26 +1,33 @@ -import { unwrap } from '@pipu/core'; -import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n'; -import Constants from 'expo-constants'; -import * as Linking from 'expo-linking'; -import { useTranslation } from 'react-i18next'; -import { Pressable, ScrollView, Text, View } from 'react-native'; +import { unwrap } from "@pipu/core"; +import { SUPPORTED_LOCALES, type Locale } from "@pipu/i18n"; +import Constants from "expo-constants"; +import * as Linking from "expo-linking"; +import { useTranslation } from "react-i18next"; +import { Pressable, ScrollView, Text, View } from "react-native"; -import { useSession, useSessionCtx, useUser } from '../../lib/session'; -import { player } from '../../player/controller'; -import { colors, radius } from '../../theme'; -import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit'; +import { useSession, useSessionCtx, useUser } from "../../lib/session"; +import { updater } from "../../lib/update"; +import { player } from "../../player/controller"; +import { colors, radius } from "../../theme"; +import { Button, H1, Icon, Muted, Screen, Segmented } from "../../ui/kit"; +import { useUpdateState } from "../../ui/UpdateDialog"; export default function ProfileScreen() { const { t, i18n } = useTranslation(); const session = useSession(); const user = useUser(); const { server, disconnect } = useSessionCtx(); - const staff = user.role !== 'user'; + const staff = user.role !== "user"; + const update = useUpdateState(); const setLocale = async (l: Locale) => { await i18n.changeLanguage(l); try { - session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { locale: l } }))); + session.setUser( + unwrap( + await session.client.PATCH("/api/v1/me", { body: { locale: l } }), + ), + ); } catch { // язык уже сменился локально } @@ -40,33 +47,79 @@ export default function ProfileScreen() {
- + - value={(SUPPORTED_LOCALES as readonly string[]).includes(i18n.language) ? (i18n.language as Locale) : 'en'} - options={SUPPORTED_LOCALES.map((l) => ({ value: l, label: l.toUpperCase() }))} + value={ + (SUPPORTED_LOCALES as readonly string[]).includes(i18n.language) + ? (i18n.language as Locale) + : "en" + } + options={SUPPORTED_LOCALES.map((l) => ({ + value: l, + label: l.toUpperCase(), + }))} onChange={(l) => void setLocale(l)} />
- + - {server?.replace(/^https?:\/\//, '')} + {server?.replace(/^https?:\/\//, "")} {staff && server && ( - void Linking.openURL(`${server}/admin/users`)} accessibilityRole="link" style={{ flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 12 }}> + void Linking.openURL(`${server}/admin/users`)} + accessibilityRole="link" + style={{ + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingVertical: 12, + }} + > - {t('mobile.open_admin')} + + {t("mobile.open_admin")} + )}
-