mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
feat: enhance APK installation process with progress tracking and UI updates
This commit is contained in:
@@ -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"/>
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user