feat: enhance APK installation process with progress tracking and UI updates

This commit is contained in:
Linus Rath
2026-04-26 22:22:52 +02:00
parent cee4193e58
commit a76e0fe8c0
4 changed files with 136 additions and 9 deletions
+1
View File
@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
+67 -1
View File
@@ -4,6 +4,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { useUpdatesStore } from '../../stores/updates-store';
import type { InstallProgress } from '../../lib/install-update';
function formatTime(ts: number): string {
if (!ts) return 'never';
@@ -11,11 +12,37 @@ function formatTime(ts: number): string {
return date.toLocaleString();
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function formatProgressLabel(p: InstallProgress): string {
const pct = p.progress != null ? `${Math.round(p.progress * 100)}%` : null;
const written = p.bytesWritten ? formatBytes(p.bytesWritten) : null;
const total = p.totalBytes ? formatBytes(p.totalBytes) : null;
const sizes = written && total ? `${written} of ${total}` : written ?? '';
return [pct, sizes].filter(Boolean).join(' · ') || 'Downloading…';
}
function installButtonLabel(
installing: boolean,
progress: InstallProgress | null,
apkAsset: unknown,
): string {
if (!installing) return apkAsset ? 'Install' : 'Open release';
if (progress?.phase === 'installing') return 'Installing…';
if (progress?.progress != null) return `Downloading ${Math.round(progress.progress * 100)}%`;
return 'Downloading…';
}
export function UpdatesSettings() {
const hydrated = useUpdatesStore((s) => s.hydrated);
const hydrate = useUpdatesStore((s) => s.hydrate);
const checking = useUpdatesStore((s) => s.checking);
const installing = useUpdatesStore((s) => s.installing);
const installProgress = useUpdatesStore((s) => s.installProgress);
const error = useUpdatesStore((s) => s.error);
const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt);
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
@@ -81,7 +108,7 @@ export function UpdatesSettings() {
onPress={onInstall}
disabled={installing}
>
{installing ? 'Downloading…' : apkAsset ? 'Install' : 'Open release'}
{installButtonLabel(installing, installProgress, apkAsset)}
</Button>
) : (
<Button
@@ -95,6 +122,29 @@ export function UpdatesSettings() {
)}
</SettingItem>
{installing && installProgress ? (
<View style={styles.progressBox}>
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
{
width:
installProgress.phase === 'installing'
? '100%'
: `${Math.round((installProgress.progress ?? 0) * 100)}%`,
},
]}
/>
</View>
<Text style={styles.progressLabel}>
{installProgress.phase === 'installing'
? 'Opening installer…'
: formatProgressLabel(installProgress)}
</Text>
</View>
) : null}
{cachedLatest?.body ? (
<View style={styles.notesBox}>
<Text style={styles.notesTitle}>Release notes</Text>
@@ -121,4 +171,20 @@ const styles = StyleSheet.create({
},
notesTitle: { ...typography.caption, color: colors.mutedForeground, textTransform: 'uppercase' },
notesBody: { ...typography.body, color: colors.text, lineHeight: 20 },
progressBox: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: 6,
},
progressTrack: {
height: 6,
borderRadius: 3,
backgroundColor: colors.muted,
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: colors.primary,
},
progressLabel: { ...typography.caption, color: colors.mutedForeground },
});
+55 -3
View File
@@ -7,16 +7,55 @@ import type { ReleaseAsset } from '../api/updates';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
const FLAG_ACTIVITY_NEW_TASK = 0x10000000;
export async function downloadAndInstallApk(asset: ReleaseAsset): Promise<void> {
export interface InstallProgress {
phase: 'downloading' | 'installing';
/** 0-1 during download; undefined for installing phase */
progress?: number;
/** Bytes received during current download */
bytesWritten?: number;
/** Total bytes for the download (best-effort, may be 0 if server omits Content-Length) */
totalBytes?: number;
}
export type InstallProgressListener = (p: InstallProgress) => void;
export async function downloadAndInstallApk(
asset: ReleaseAsset,
onProgress?: InstallProgressListener,
): Promise<void> {
if (Platform.OS !== 'android') {
throw new Error('APK install is only supported on Android');
}
const dest = new File(Paths.cache, asset.name);
if (dest.exists) dest.delete();
const downloaded = await File.downloadFileAsync(asset.browser_download_url, dest);
const contentUri = await FileSystemLegacy.getContentUriAsync(downloaded.uri);
onProgress?.({ phase: 'downloading', progress: 0 });
const download = FileSystemLegacy.createDownloadResumable(
asset.browser_download_url,
dest.uri,
{},
(state) => {
const total = state.totalBytesExpectedToWrite ?? 0;
const progress = total > 0 ? state.totalBytesWritten / total : undefined;
onProgress?.({
phase: 'downloading',
progress,
bytesWritten: state.totalBytesWritten,
totalBytes: total,
});
},
);
const result = await download.downloadAsync();
if (!result?.uri) {
throw new Error('APK download failed');
}
onProgress?.({ phase: 'installing' });
const contentUri = await FileSystemLegacy.getContentUriAsync(result.uri);
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: contentUri,
@@ -24,3 +63,16 @@ export async function downloadAndInstallApk(asset: ReleaseAsset): Promise<void>
flags: FLAG_GRANT_READ_URI_PERMISSION | FLAG_ACTIVITY_NEW_TASK,
});
}
/**
* Open the system "Install unknown apps" settings page for this app, so the
* user can grant permission to install APKs from the package installer.
* Useful as a fallback if the install intent never reaches the installer.
*/
export async function openInstallUnknownAppsSettings(): Promise<void> {
if (Platform.OS !== 'android') return;
await IntentLauncher.startActivityAsync(
'android.settings.MANAGE_UNKNOWN_APP_SOURCES',
{ data: 'package:com.anonymous.bulwarkmobile' },
);
}
+13 -5
View File
@@ -3,7 +3,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import Constants from 'expo-constants';
import { fetchLatestRelease, type LatestRelease } from '../api/updates';
import { isNewer } from '../lib/version-compare';
import { downloadAndInstallApk } from '../lib/install-update';
import { downloadAndInstallApk, type InstallProgress } from '../lib/install-update';
const STORAGE_KEY = 'webmail:updates:v1';
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
@@ -26,6 +26,7 @@ export interface UpdatesState extends PersistedUpdates {
hydrated: boolean;
checking: boolean;
installing: boolean;
installProgress: InstallProgress | null;
error: string | null;
hydrate: () => Promise<void>;
@@ -48,6 +49,7 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
hydrated: false,
checking: false,
installing: false,
installProgress: null,
error: null,
hydrate: async () => {
@@ -100,12 +102,18 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
installLatest: async () => {
const s = get();
if (s.installing || !s.cachedLatest?.apkAsset) return;
set({ installing: true, error: null });
set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } });
try {
await downloadAndInstallApk(s.cachedLatest.apkAsset);
set({ installing: false });
await downloadAndInstallApk(s.cachedLatest.apkAsset, (p) => {
set({ installProgress: p });
});
set({ installing: false, installProgress: null });
} catch (err) {
set({ installing: false, error: err instanceof Error ? err.message : String(err) });
set({
installing: false,
installProgress: null,
error: err instanceof Error ? err.message : String(err),
});
}
},