mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 16:06:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7bf3204a | ||
|
|
a76e0fe8c0 | ||
|
|
cee4193e58 | ||
|
|
5ac2a142b5 | ||
|
|
e73dc4e5ba | ||
|
|
892ff33f14 | ||
|
|
7e42cb85ea |
@@ -42,6 +42,16 @@ jobs:
|
|||||||
key: android-sdk-${{ runner.os }}-${{ hashFiles('android/build.gradle', 'android/gradle.properties', 'android/app/build.gradle') }}
|
key: android-sdk-${{ runner.os }}-${{ hashFiles('android/build.gradle', 'android/gradle.properties', 'android/app/build.gradle') }}
|
||||||
restore-keys: android-sdk-${{ runner.os }}-
|
restore-keys: android-sdk-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Compute native cache key input
|
||||||
|
run: |
|
||||||
|
node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
|
||||||
|
if (lock.packages && lock.packages['']) delete lock.packages[''].version;
|
||||||
|
delete lock.version;
|
||||||
|
fs.writeFileSync('.cxx-cache-input.json', JSON.stringify(lock.packages || {}));
|
||||||
|
"
|
||||||
|
|
||||||
- name: Cache native CMake build outputs
|
- name: Cache native CMake build outputs
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -50,7 +60,7 @@ jobs:
|
|||||||
android/app/build/intermediates/cxx
|
android/app/build/intermediates/cxx
|
||||||
android/app/build/intermediates/merged_native_libs
|
android/app/build/intermediates/merged_native_libs
|
||||||
android/app/build/intermediates/stripped_native_libs
|
android/app/build/intermediates/stripped_native_libs
|
||||||
key: cxx-${{ runner.os }}-${{ hashFiles('package-lock.json', 'android/build.gradle', 'android/app/build.gradle') }}
|
key: cxx-${{ runner.os }}-${{ hashFiles('.cxx-cache-input.json', 'android/build.gradle', 'android/app/build.gradle') }}
|
||||||
restore-keys: cxx-${{ runner.os }}-
|
restore-keys: cxx-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||||
import ComposeScreen from './src/screens/ComposeScreen';
|
import ComposeScreen from './src/screens/ComposeScreen';
|
||||||
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
||||||
|
import EmailSourceScreen from './src/screens/EmailSourceScreen';
|
||||||
import LoginScreen from './src/screens/LoginScreen';
|
import LoginScreen from './src/screens/LoginScreen';
|
||||||
import EmailListScreen from './src/screens/EmailListScreen';
|
import EmailListScreen from './src/screens/EmailListScreen';
|
||||||
import FilesScreen from './src/screens/FilesScreen';
|
import FilesScreen from './src/screens/FilesScreen';
|
||||||
@@ -325,6 +326,7 @@ export default function App() {
|
|||||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
|
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
|
||||||
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
|
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
|
||||||
|
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Compose"
|
name="Compose"
|
||||||
component={ComposeScreen}
|
component={ComposeScreen}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
<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.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<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.READ_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ android.enablePngCrunchInReleaseBuilds=true
|
|||||||
# Use this property to specify which architecture you want to build.
|
# Use this property to specify which architecture you want to build.
|
||||||
# You can also override it from the CLI using
|
# You can also override it from the CLI using
|
||||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||||
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
reactNativeArchitectures=arm64-v8a
|
||||||
|
|
||||||
# Use this property to enable support to the new architecture.
|
# Use this property to enable support to the new architecture.
|
||||||
# This will allow you to use TurboModules and the Fabric render in
|
# This will allow you to use TurboModules and the Fabric render in
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "0.1.4",
|
"version": "0.1.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "0.1.4",
|
"version": "0.1.7",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "0.1.4",
|
"version": "0.1.7",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"bump": "node scripts/bump-version.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const versionFile = path.join(root, 'VERSION');
|
||||||
|
const pkgFile = path.join(root, 'package.json');
|
||||||
|
const lockFile = path.join(root, 'package-lock.json');
|
||||||
|
|
||||||
|
const git = (...args) =>
|
||||||
|
execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'inherit'] }).toString().trim();
|
||||||
|
const gitInherit = (...args) =>
|
||||||
|
execFileSync('git', args, { cwd: root, stdio: 'inherit' });
|
||||||
|
|
||||||
|
const dirty = git('status', '--porcelain');
|
||||||
|
if (dirty) {
|
||||||
|
console.error('Working tree is not clean. Commit or stash changes before bumping.');
|
||||||
|
console.error(dirty);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = fs.readFileSync(versionFile, 'utf8').trim();
|
||||||
|
const parts = current.split('.').map(Number);
|
||||||
|
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
||||||
|
console.error(`Invalid version in VERSION: "${current}" (expected MAJOR.MINOR.PATCH)`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
parts[2] += 1;
|
||||||
|
const next = parts.join('.');
|
||||||
|
|
||||||
|
const existingTags = new Set(git('tag', '--list').split(/\r?\n/).filter(Boolean));
|
||||||
|
if (existingTags.has(next)) {
|
||||||
|
console.error(`Tag ${next} already exists.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(versionFile, `${next}\n`);
|
||||||
|
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8'));
|
||||||
|
pkg.version = next;
|
||||||
|
fs.writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`);
|
||||||
|
|
||||||
|
const lock = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
|
||||||
|
lock.version = next;
|
||||||
|
if (lock.packages?.['']) lock.packages[''].version = next;
|
||||||
|
fs.writeFileSync(lockFile, `${JSON.stringify(lock, null, 2)}\n`);
|
||||||
|
|
||||||
|
console.log(`${current} -> ${next}`);
|
||||||
|
|
||||||
|
gitInherit('add', 'VERSION', 'package.json', 'package-lock.json');
|
||||||
|
gitInherit('commit', '-m', `chore: update version number to ${next}`);
|
||||||
|
gitInherit('tag', '-a', next, '-m', next);
|
||||||
|
gitInherit('push', '--follow-tags');
|
||||||
|
|
||||||
|
execFileSync(
|
||||||
|
'gh',
|
||||||
|
['release', 'create', next, '--title', next, '--generate-notes'],
|
||||||
|
{ cwd: root, stdio: 'inherit' },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Released ${next}`);
|
||||||
@@ -4,6 +4,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
|||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||||
import { useUpdatesStore } from '../../stores/updates-store';
|
import { useUpdatesStore } from '../../stores/updates-store';
|
||||||
|
import type { InstallProgress } from '../../lib/install-update';
|
||||||
|
|
||||||
function formatTime(ts: number): string {
|
function formatTime(ts: number): string {
|
||||||
if (!ts) return 'never';
|
if (!ts) return 'never';
|
||||||
@@ -11,11 +12,37 @@ function formatTime(ts: number): string {
|
|||||||
return date.toLocaleString();
|
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() {
|
export function UpdatesSettings() {
|
||||||
const hydrated = useUpdatesStore((s) => s.hydrated);
|
const hydrated = useUpdatesStore((s) => s.hydrated);
|
||||||
const hydrate = useUpdatesStore((s) => s.hydrate);
|
const hydrate = useUpdatesStore((s) => s.hydrate);
|
||||||
const checking = useUpdatesStore((s) => s.checking);
|
const checking = useUpdatesStore((s) => s.checking);
|
||||||
const installing = useUpdatesStore((s) => s.installing);
|
const installing = useUpdatesStore((s) => s.installing);
|
||||||
|
const installProgress = useUpdatesStore((s) => s.installProgress);
|
||||||
const error = useUpdatesStore((s) => s.error);
|
const error = useUpdatesStore((s) => s.error);
|
||||||
const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt);
|
const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt);
|
||||||
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
||||||
@@ -81,7 +108,7 @@ export function UpdatesSettings() {
|
|||||||
onPress={onInstall}
|
onPress={onInstall}
|
||||||
disabled={installing}
|
disabled={installing}
|
||||||
>
|
>
|
||||||
{installing ? 'Downloading…' : apkAsset ? 'Install' : 'Open release'}
|
{installButtonLabel(installing, installProgress, apkAsset)}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -95,6 +122,29 @@ export function UpdatesSettings() {
|
|||||||
)}
|
)}
|
||||||
</SettingItem>
|
</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 ? (
|
{cachedLatest?.body ? (
|
||||||
<View style={styles.notesBox}>
|
<View style={styles.notesBox}>
|
||||||
<Text style={styles.notesTitle}>Release notes</Text>
|
<Text style={styles.notesTitle}>Release notes</Text>
|
||||||
@@ -121,4 +171,20 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
notesTitle: { ...typography.caption, color: colors.mutedForeground, textTransform: 'uppercase' },
|
notesTitle: { ...typography.caption, color: colors.mutedForeground, textTransform: 'uppercase' },
|
||||||
notesBody: { ...typography.body, color: colors.text, lineHeight: 20 },
|
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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { File, Paths } from 'expo-file-system';
|
||||||
|
import * as Sharing from 'expo-sharing';
|
||||||
|
import { jmapClient } from '../api/jmap-client';
|
||||||
|
import { getDownloadUrl } from '../api/blob';
|
||||||
|
|
||||||
|
const RFC822 = 'message/rfc822';
|
||||||
|
|
||||||
|
function authHeader(): string {
|
||||||
|
const h = (jmapClient as { authHeader?: string }).authHeader;
|
||||||
|
if (!h) throw new Error('Not connected');
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRawEmail(blobId: string): Promise<string> {
|
||||||
|
const url = getDownloadUrl(blobId, 'email.eml', RFC822);
|
||||||
|
const r = await fetch(url, { headers: { Authorization: authHeader() } });
|
||||||
|
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
|
||||||
|
return r.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFilename(subject: string | undefined): string {
|
||||||
|
const base = (subject ?? 'email').replace(/[^\w\s-]/g, '').trim().slice(0, 60) || 'email';
|
||||||
|
return `${base}.eml`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shareEmailEml(blobId: string, subject?: string): Promise<void> {
|
||||||
|
const dest = new File(Paths.cache, safeFilename(subject));
|
||||||
|
if (dest.exists) dest.delete();
|
||||||
|
const url = getDownloadUrl(blobId, dest.name, RFC822);
|
||||||
|
const downloaded = await File.downloadFileAsync(url, dest, {
|
||||||
|
headers: { Authorization: authHeader() },
|
||||||
|
});
|
||||||
|
if (!(await Sharing.isAvailableAsync())) {
|
||||||
|
throw new Error('Sharing is not available on this device');
|
||||||
|
}
|
||||||
|
await Sharing.shareAsync(downloaded.uri, {
|
||||||
|
mimeType: RFC822,
|
||||||
|
dialogTitle: 'Share email',
|
||||||
|
UTI: 'public.email-message',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,16 +7,55 @@ import type { ReleaseAsset } from '../api/updates';
|
|||||||
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
|
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
|
||||||
const FLAG_ACTIVITY_NEW_TASK = 0x10000000;
|
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') {
|
if (Platform.OS !== 'android') {
|
||||||
throw new Error('APK install is only supported on Android');
|
throw new Error('APK install is only supported on Android');
|
||||||
}
|
}
|
||||||
|
|
||||||
const dest = new File(Paths.cache, asset.name);
|
const dest = new File(Paths.cache, asset.name);
|
||||||
if (dest.exists) dest.delete();
|
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', {
|
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
|
||||||
data: contentUri,
|
data: contentUri,
|
||||||
@@ -24,3 +63,16 @@ export async function downloadAndInstallApk(asset: ReleaseAsset): Promise<void>
|
|||||||
flags: FLAG_GRANT_READ_URI_PERMISSION | FLAG_ACTIVITY_NEW_TASK,
|
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,6 +3,7 @@ import type { EmailAddress } from '../api/types';
|
|||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
MainTabs: undefined;
|
MainTabs: undefined;
|
||||||
EmailThread: { emailId: string; threadId: string; subject?: string };
|
EmailThread: { emailId: string; threadId: string; subject?: string };
|
||||||
|
EmailSource: { emailId: string; blobId: string; subject?: string };
|
||||||
Compose:
|
Compose:
|
||||||
| {
|
| {
|
||||||
mode?: 'reply' | 'replyAll' | 'forward';
|
mode?: 'reply' | 'replyAll' | 'forward';
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, StyleSheet, ScrollView, ActivityIndicator, Pressable, Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { ArrowLeft, Share2 } from 'lucide-react-native';
|
||||||
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
|
import { fetchRawEmail, shareEmailEml } from '../lib/email-export';
|
||||||
|
import { colors, spacing, typography, componentSizes, radius } from '../theme/tokens';
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'EmailSource'>;
|
||||||
|
|
||||||
|
export default function EmailSourceScreen({ route, navigation }: Props) {
|
||||||
|
const { blobId, subject } = route.params;
|
||||||
|
const [raw, setRaw] = React.useState<string | null>(null);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const text = await fetchRawEmail(blobId);
|
||||||
|
if (!cancelled) setRaw(text);
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load email source');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [blobId]);
|
||||||
|
|
||||||
|
const onShare = async () => {
|
||||||
|
try {
|
||||||
|
await shareEmailEml(blobId, subject);
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert('Share failed', e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
||||||
|
<ArrowLeft size={22} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||||
|
{subject || 'Email source'}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={onShare} style={styles.headerBtn} hitSlop={8} disabled={!raw}>
|
||||||
|
<Share2 size={20} color={raw ? colors.text : colors.textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!raw && !error ? (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<ActivityIndicator color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
) : error ? (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<Text style={styles.error}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView contentContainerStyle={styles.body}>
|
||||||
|
<Text selectable style={styles.source}>{raw}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.background },
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: componentSizes.headerHeight,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
headerBtn: {
|
||||||
|
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
|
||||||
|
},
|
||||||
|
headerTitle: { ...typography.h3, color: colors.text, flex: 1 },
|
||||||
|
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
error: { ...typography.body, color: colors.error, paddingHorizontal: spacing.lg, textAlign: 'center' },
|
||||||
|
body: { padding: spacing.lg },
|
||||||
|
source: {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 16,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
|
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
|
||||||
Modal, useWindowDimensions, Animated, Easing,
|
Modal, useWindowDimensions, Animated, Easing, Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
|
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
|
||||||
ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen,
|
ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen,
|
||||||
FolderInput, ShieldAlert, ShieldCheck, X, Check,
|
FolderInput, ShieldAlert, ShieldCheck, X, Check,
|
||||||
Inbox, Send, File as FileIcon, Ban, Folder,
|
Inbox, Send, File as FileIcon, Ban, Folder, Code, Download,
|
||||||
} from 'lucide-react-native';
|
} from 'lucide-react-native';
|
||||||
import type { LucideIcon } from 'lucide-react-native';
|
import type { LucideIcon } from 'lucide-react-native';
|
||||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||||
@@ -17,6 +17,7 @@ import EmailBodyView from '../components/EmailBodyView';
|
|||||||
import SenderAvatar from '../components/SenderAvatar';
|
import SenderAvatar from '../components/SenderAvatar';
|
||||||
import { useEmailStore } from '../stores/email-store';
|
import { useEmailStore } from '../stores/email-store';
|
||||||
import { setEmailKeywords } from '../api/email';
|
import { setEmailKeywords } from '../api/email';
|
||||||
|
import { shareEmailEml } from '../lib/email-export';
|
||||||
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
|
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
|
||||||
import type { Email, Mailbox } from '../api/types';
|
import type { Email, Mailbox } from '../api/types';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
@@ -386,15 +387,36 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
|||||||
visible={moreMenuOpen}
|
visible={moreMenuOpen}
|
||||||
onClose={() => setMoreMenuOpen(false)}
|
onClose={() => setMoreMenuOpen(false)}
|
||||||
unread={unread}
|
unread={unread}
|
||||||
canArchive={!!archiveMailbox && !showArchive && currentMailboxId !== archiveMailbox?.id}
|
canArchive={!!archiveMailbox && currentMailboxId !== archiveMailbox?.id}
|
||||||
canMarkUnread={!showMarkUnread}
|
canMarkUnread={true}
|
||||||
canMove={mailboxes.length > 0}
|
canMove={mailboxes.length > 0}
|
||||||
showSpam={!!junkMailbox || isInJunk}
|
showSpam={!!junkMailbox || isInJunk}
|
||||||
isInJunk={isInJunk}
|
isInJunk={isInJunk}
|
||||||
|
canViewSource={!!email?.blobId}
|
||||||
|
canExport={!!email?.blobId}
|
||||||
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
|
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
|
||||||
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
|
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
|
||||||
onMove={() => { setMoreMenuOpen(false); setMoveMenuOpen(true); }}
|
onMove={() => { setMoreMenuOpen(false); setMoveMenuOpen(true); }}
|
||||||
onToggleSpam={onToggleSpam}
|
onToggleSpam={onToggleSpam}
|
||||||
|
onViewSource={() => {
|
||||||
|
setMoreMenuOpen(false);
|
||||||
|
if (email?.blobId) {
|
||||||
|
navigation.navigate('EmailSource', {
|
||||||
|
emailId: email.id,
|
||||||
|
blobId: email.blobId,
|
||||||
|
subject: email.subject,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onExport={async () => {
|
||||||
|
setMoreMenuOpen(false);
|
||||||
|
if (!email?.blobId) return;
|
||||||
|
try {
|
||||||
|
await shareEmailEml(email.blobId, email.subject);
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert('Export failed', e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MoveMenuSheet
|
<MoveMenuSheet
|
||||||
@@ -417,15 +439,20 @@ interface MoreMenuSheetProps {
|
|||||||
canMove: boolean;
|
canMove: boolean;
|
||||||
showSpam: boolean;
|
showSpam: boolean;
|
||||||
isInJunk: boolean;
|
isInJunk: boolean;
|
||||||
|
canViewSource: boolean;
|
||||||
|
canExport: boolean;
|
||||||
onArchive: () => void;
|
onArchive: () => void;
|
||||||
onToggleUnread: () => void;
|
onToggleUnread: () => void;
|
||||||
onMove: () => void;
|
onMove: () => void;
|
||||||
onToggleSpam: () => void;
|
onToggleSpam: () => void;
|
||||||
|
onViewSource: () => void;
|
||||||
|
onExport: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MoreMenuSheet({
|
function MoreMenuSheet({
|
||||||
visible, onClose, unread, canArchive, canMarkUnread, canMove,
|
visible, onClose, unread, canArchive, canMarkUnread, canMove,
|
||||||
showSpam, isInJunk, onArchive, onToggleUnread, onMove, onToggleSpam,
|
showSpam, isInJunk, canViewSource, canExport,
|
||||||
|
onArchive, onToggleUnread, onMove, onToggleSpam, onViewSource, onExport,
|
||||||
}: MoreMenuSheetProps) {
|
}: MoreMenuSheetProps) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const slideY = React.useRef(new Animated.Value(400)).current;
|
const slideY = React.useRef(new Animated.Value(400)).current;
|
||||||
@@ -504,6 +531,20 @@ function MoreMenuSheet({
|
|||||||
onPress={onToggleSpam}
|
onPress={onToggleSpam}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{canViewSource && (
|
||||||
|
<MoreMenuItem
|
||||||
|
icon={<Code size={18} color={colors.textSecondary} />}
|
||||||
|
label="View source"
|
||||||
|
onPress={onViewSource}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canExport && (
|
||||||
|
<MoreMenuItem
|
||||||
|
icon={<Download size={18} color={colors.textSecondary} />}
|
||||||
|
label="Export email (.eml)"
|
||||||
|
onPress={onExport}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
|||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
import { fetchLatestRelease, type LatestRelease } from '../api/updates';
|
import { fetchLatestRelease, type LatestRelease } from '../api/updates';
|
||||||
import { isNewer } from '../lib/version-compare';
|
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 STORAGE_KEY = 'webmail:updates:v1';
|
||||||
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||||
@@ -26,6 +26,7 @@ export interface UpdatesState extends PersistedUpdates {
|
|||||||
hydrated: boolean;
|
hydrated: boolean;
|
||||||
checking: boolean;
|
checking: boolean;
|
||||||
installing: boolean;
|
installing: boolean;
|
||||||
|
installProgress: InstallProgress | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
hydrate: () => Promise<void>;
|
hydrate: () => Promise<void>;
|
||||||
@@ -48,6 +49,7 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
|||||||
hydrated: false,
|
hydrated: false,
|
||||||
checking: false,
|
checking: false,
|
||||||
installing: false,
|
installing: false,
|
||||||
|
installProgress: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
hydrate: async () => {
|
hydrate: async () => {
|
||||||
@@ -100,12 +102,18 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
|||||||
installLatest: async () => {
|
installLatest: async () => {
|
||||||
const s = get();
|
const s = get();
|
||||||
if (s.installing || !s.cachedLatest?.apkAsset) return;
|
if (s.installing || !s.cachedLatest?.apkAsset) return;
|
||||||
set({ installing: true, error: null });
|
set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } });
|
||||||
try {
|
try {
|
||||||
await downloadAndInstallApk(s.cachedLatest.apkAsset);
|
await downloadAndInstallApk(s.cachedLatest.apkAsset, (p) => {
|
||||||
set({ installing: false });
|
set({ installProgress: p });
|
||||||
|
});
|
||||||
|
set({ installing: false, installProgress: null });
|
||||||
} catch (err) {
|
} 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