Compare commits

..
5 Commits
11 changed files with 468 additions and 6 deletions
+46 -3
View File
@@ -28,9 +28,31 @@ jobs:
with:
distribution: temurin
java-version: 17
cache: gradle
- uses: android-actions/setup-android@v3
- name: Cache Android NDK / CMake
uses: actions/cache@v4
with:
path: |
~/.android/build-cache
/usr/local/lib/android/sdk/ndk
/usr/local/lib/android/sdk/cmake
key: android-sdk-${{ runner.os }}-${{ hashFiles('android/build.gradle', 'android/gradle.properties', 'android/app/build.gradle') }}
restore-keys: android-sdk-${{ runner.os }}-
- name: Cache native CMake build outputs
uses: actions/cache@v4
with:
path: |
android/app/.cxx
android/app/build/intermediates/cxx
android/app/build/intermediates/merged_native_libs
android/app/build/intermediates/stripped_native_libs
key: cxx-${{ runner.os }}-${{ hashFiles('package-lock.json', 'android/build.gradle', 'android/app/build.gradle') }}
restore-keys: cxx-${{ runner.os }}-
- name: Install JS dependencies
run: npm ci
@@ -54,7 +76,9 @@ jobs:
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: ./gradlew assembleRelease --no-daemon
run: |
chmod +x ./gradlew
./gradlew assembleRelease --no-daemon
- name: Rename APK with version
id: artifact
@@ -72,9 +96,28 @@ jobs:
path: ${{ steps.artifact.outputs.path }}
if-no-files-found: error
- name: Resolve target release tag
id: target_tag
env:
GH_TOKEN: ${{ github.token }}
run: |
if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
elif [ -n "${{ github.event.inputs.attach_to_release_tag }}" ]; then
TAG="${{ github.event.inputs.attach_to_release_tag }}"
else
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true)
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
if [ -n "$TAG" ]; then
echo "Will attach APK to release: $TAG"
else
echo "No release tag resolved — APK is in workflow artifacts only."
fi
- name: Attach APK to release
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.attach_to_release_tag != '')
if: steps.target_tag.outputs.tag != ''
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.release.tag_name || github.event.inputs.attach_to_release_tag }}
tag_name: ${{ steps.target_tag.outputs.tag }}
files: ${{ steps.artifact.outputs.path }}
+18
View File
@@ -34,6 +34,8 @@ import { useCalendarStore } from './src/stores/calendar-store';
import { useContactsStore } from './src/stores/contacts-store';
import { useEmailStore } from './src/stores/email-store';
import { useSettingsStore } from './src/stores/settings-store';
import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
import { colors, spacing, typography } from './src/theme/tokens';
const Stack = createNativeStackNavigator<RootStackParamList>();
@@ -64,6 +66,8 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<UpdateBanner />
<Tab.Navigator
screenOptions={{
headerShown: false,
@@ -147,6 +151,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
{() => <SettingsScreen onLogout={logout} />}
</Tab.Screen>
</Tab.Navigator>
</View>
);
}
@@ -171,6 +176,19 @@ export default function App() {
void useSettingsStore.getState().hydrate();
}, []);
React.useEffect(() => {
let cancelled = false;
void (async () => {
const store = useUpdatesStore.getState();
await store.hydrate();
if (cancelled) return;
if (useUpdatesStore.getState().autoCheck) {
await useUpdatesStore.getState().checkNow();
}
})();
return () => { cancelled = true; };
}, []);
// Listen for FCM messages so the app can refresh state even when woken
// from the background — the FirebaseMessagingService also posts the system
// notification so the user sees it regardless of RN runtime state.
+1 -1
View File
@@ -1 +1 @@
0.1.0
0.1.1
Vendored Regular → Executable
View File
+43
View File
@@ -0,0 +1,43 @@
export const UPDATE_REPO = 'bulwarkmail/native';
export interface ReleaseAsset {
name: string;
browser_download_url: string;
size: number;
}
export interface LatestRelease {
tag: string;
name: string;
htmlUrl: string;
publishedAt: string;
body: string;
apkAsset: ReleaseAsset | null;
}
export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<LatestRelease | null> {
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
headers: { Accept: 'application/vnd.github+json' },
});
if (!res.ok) {
if (res.status === 404) return null;
throw new Error(`GitHub API ${res.status}`);
}
const json = (await res.json()) as {
tag_name: string;
name: string;
html_url: string;
published_at: string;
body: string;
assets: ReleaseAsset[];
};
const apkAsset = json.assets.find((a) => a.name.endsWith('.apk')) ?? null;
return {
tag: json.tag_name,
name: json.name || json.tag_name,
htmlUrl: json.html_url,
publishedAt: json.published_at,
body: json.body || '',
apkAsset,
};
}
+61
View File
@@ -0,0 +1,61 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Download, X } from 'lucide-react-native';
import { useUpdatesStore } from '../stores/updates-store';
import { colors, spacing, radius, typography } from '../theme/tokens';
export function UpdateBanner(): React.ReactElement | null {
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
const installing = useUpdatesStore((s) => s.installing);
const installLatest = useUpdatesStore((s) => s.installLatest);
const dismissCurrent = useUpdatesStore((s) => s.dismissCurrent);
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
if (!hasUpdate()) return null;
if (cachedLatest && dismissedTag === cachedLatest.tag) return null;
if (!cachedLatest?.apkAsset) return null;
return (
<View style={styles.banner}>
<Download size={16} color={colors.primaryForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.title}>Update available</Text>
<Text style={styles.subtitle}>
v{cachedLatest.tag} is ready to install.
</Text>
</View>
<Pressable
style={styles.installButton}
onPress={() => void installLatest()}
disabled={installing}
>
<Text style={styles.installText}>{installing ? '…' : 'Install'}</Text>
</Pressable>
<Pressable style={styles.dismiss} onPress={dismissCurrent} hitSlop={8}>
<X size={14} color={colors.primaryForeground} />
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
banner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
backgroundColor: colors.primary,
},
title: { ...typography.bodyMedium, color: colors.primaryForeground },
subtitle: { ...typography.caption, color: colors.primaryForeground, opacity: 0.85 },
installButton: {
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: 'rgba(255,255,255,0.2)',
},
installText: { ...typography.captionMedium, color: colors.primaryForeground },
dismiss: { padding: 4 },
});
+124
View File
@@ -0,0 +1,124 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, Linking, Alert } from 'react-native';
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';
function formatTime(ts: number): string {
if (!ts) return 'never';
const date = new Date(ts);
return date.toLocaleString();
}
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 error = useUpdatesStore((s) => s.error);
const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt);
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
const autoCheck = useUpdatesStore((s) => s.autoCheck);
const setAutoCheck = useUpdatesStore((s) => s.setAutoCheck);
const checkNow = useUpdatesStore((s) => s.checkNow);
const installLatest = useUpdatesStore((s) => s.installLatest);
const currentVersion = useUpdatesStore((s) => s.currentVersion);
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const updateAvailable = hasUpdate();
const apkAsset = cachedLatest?.apkAsset ?? null;
const onInstall = () => {
if (!apkAsset) {
if (cachedLatest?.htmlUrl) {
void Linking.openURL(cachedLatest.htmlUrl);
}
return;
}
void installLatest();
};
return (
<View style={styles.container}>
<SettingsSection title="App updates" description="Check for new versions and install them.">
<SettingItem label="Current version">
<Text style={styles.value}>v{currentVersion()}</Text>
</SettingItem>
<SettingItem
label="Latest version"
description={cachedLatest?.publishedAt ? `Published ${new Date(cachedLatest.publishedAt).toLocaleDateString()}` : undefined}
>
<Text style={styles.value}>{cachedLatest ? cachedLatest.tag : '—'}</Text>
</SettingItem>
<SettingItem
label="Last checked"
description={error ? `Last error: ${error}` : undefined}
>
<Text style={styles.value}>{formatTime(lastCheckedAt)}</Text>
</SettingItem>
<SettingItem label="Check automatically" description="Check for updates every few hours when the app starts.">
<ToggleSwitch checked={autoCheck} onChange={setAutoCheck} />
</SettingItem>
<SettingItem
label={updateAvailable ? 'Update available' : 'Check for updates'}
description={updateAvailable
? `Tap install to download v${cachedLatest?.tag} and open it with Android's package installer.`
: undefined}
>
{updateAvailable ? (
<Button
variant="default"
size="sm"
onPress={onInstall}
disabled={installing}
>
{installing ? 'Downloading…' : apkAsset ? 'Install' : 'Open release'}
</Button>
) : (
<Button
variant="outline"
size="sm"
onPress={() => void checkNow({ force: true })}
disabled={checking}
>
{checking ? 'Checking…' : 'Check now'}
</Button>
)}
</SettingItem>
{cachedLatest?.body ? (
<View style={styles.notesBox}>
<Text style={styles.notesTitle}>Release notes</Text>
<Text style={styles.notesBody} numberOfLines={20}>
{cachedLatest.body.trim()}
</Text>
</View>
) : null}
</SettingsSection>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
value: { ...typography.bodyMedium, color: colors.text },
notesBox: {
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.muted,
gap: spacing.sm,
},
notesTitle: { ...typography.caption, color: colors.mutedForeground, textTransform: 'uppercase' },
notesBody: { ...typography.body, color: colors.text, lineHeight: 20 },
});
+21
View File
@@ -0,0 +1,21 @@
import { Platform } from 'react-native';
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import type { ReleaseAsset } from '../api/updates';
export async function downloadAndInstallApk(asset: ReleaseAsset): 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);
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(downloaded.uri, {
mimeType: 'application/vnd.android.package-archive',
dialogTitle: 'Install update',
UTI: 'com.android.package-archive',
});
}
+17
View File
@@ -0,0 +1,17 @@
export function parseVersion(v: string): [number, number, number] {
const cleaned = v.replace(/^v/, '').split(/[-+]/)[0];
const parts = cleaned.split('.').map((n) => parseInt(n, 10));
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
}
export function compareVersions(a: string, b: string): number {
const [a1, a2, a3] = parseVersion(a);
const [b1, b2, b3] = parseVersion(b);
if (a1 !== b1) return a1 - b1;
if (a2 !== b2) return a2 - b2;
return a3 - b3;
}
export function isNewer(latest: string, current: string): boolean {
return compareVersions(latest, current) > 0;
}
+5 -2
View File
@@ -5,7 +5,7 @@ import {
ArrowLeft, LogOut, Settings, ChevronRight,
Palette, Mail, User, Shield, UserPen, Palmtree, Calendar,
Filter, FileText, FolderOpen, Tags, HardDrive, Wrench,
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle,
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw,
type LucideIcon,
} from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
@@ -16,13 +16,14 @@ import { IdentitySettings } from '../components/settings/IdentitySettings';
import { VacationSettings } from '../components/settings/VacationSettings';
import { FolderSettings } from '../components/settings/FolderSettings';
import { AdvancedSettings } from '../components/settings/AdvancedSettings';
import { UpdatesSettings } from '../components/settings/UpdatesSettings';
type Tab =
| 'appearance' | 'email' | 'notifications'
| 'account' | 'security' | 'identities' | 'encryption' | 'vacation'
| 'filters' | 'templates' | 'folders' | 'keywords'
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
| 'themes' | 'plugins' | 'advanced';
| 'themes' | 'plugins' | 'updates' | 'advanced';
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
@@ -64,6 +65,7 @@ const TABS: TabDef[] = [
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: false },
{ id: 'themes', label: 'Themes', icon: Palette, group: 'system', experimental: true, implemented: false },
{ id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'system', experimental: true, implemented: false },
{ id: 'updates', label: 'Updates', icon: RefreshCw, group: 'system', implemented: true },
{ id: 'advanced', label: 'Advanced', icon: Wrench, group: 'system', implemented: true },
];
@@ -74,6 +76,7 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
identities: IdentitySettings,
vacation: VacationSettings,
folders: FolderSettings,
updates: UpdatesSettings,
advanced: AdvancedSettings,
};
+132
View File
@@ -0,0 +1,132 @@
import { create } from 'zustand';
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';
const STORAGE_KEY = 'webmail:updates:v1';
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
interface PersistedUpdates {
autoCheck: boolean;
lastCheckedAt: number;
cachedLatest: LatestRelease | null;
dismissedTag: string | null;
}
const DEFAULT_PERSISTED: PersistedUpdates = {
autoCheck: true,
lastCheckedAt: 0,
cachedLatest: null,
dismissedTag: null,
};
export interface UpdatesState extends PersistedUpdates {
hydrated: boolean;
checking: boolean;
installing: boolean;
error: string | null;
hydrate: () => Promise<void>;
setAutoCheck: (enabled: boolean) => void;
checkNow: (opts?: { force?: boolean }) => Promise<void>;
installLatest: () => Promise<void>;
dismissCurrent: () => void;
currentVersion: () => string;
hasUpdate: () => boolean;
}
function persist(state: PersistedUpdates): void {
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(state)).catch((err) => {
console.warn('[updates-store] persist failed', err);
});
}
export const useUpdatesStore = create<UpdatesState>((set, get) => ({
...DEFAULT_PERSISTED,
hydrated: false,
checking: false,
installing: false,
error: null,
hydrate: async () => {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<PersistedUpdates>;
set({ ...DEFAULT_PERSISTED, ...parsed, hydrated: true });
} else {
set({ hydrated: true });
}
} catch (err) {
console.warn('[updates-store] hydrate failed', err);
set({ hydrated: true });
}
},
setAutoCheck: (enabled) => {
set({ autoCheck: enabled });
const s = get();
persist({
autoCheck: enabled,
lastCheckedAt: s.lastCheckedAt,
cachedLatest: s.cachedLatest,
dismissedTag: s.dismissedTag,
});
},
checkNow: async (opts) => {
const s = get();
if (s.checking) return;
const now = Date.now();
if (!opts?.force && now - s.lastCheckedAt < MIN_CHECK_INTERVAL_MS) return;
set({ checking: true, error: null });
try {
const latest = await fetchLatestRelease();
const next: PersistedUpdates = {
autoCheck: s.autoCheck,
lastCheckedAt: now,
cachedLatest: latest,
dismissedTag: s.dismissedTag,
};
set({ ...next, checking: false });
persist(next);
} catch (err) {
set({ checking: false, error: err instanceof Error ? err.message : String(err) });
}
},
installLatest: async () => {
const s = get();
if (s.installing || !s.cachedLatest?.apkAsset) return;
set({ installing: true, error: null });
try {
await downloadAndInstallApk(s.cachedLatest.apkAsset);
set({ installing: false });
} catch (err) {
set({ installing: false, error: err instanceof Error ? err.message : String(err) });
}
},
dismissCurrent: () => {
const s = get();
const tag = s.cachedLatest?.tag ?? null;
set({ dismissedTag: tag });
persist({
autoCheck: s.autoCheck,
lastCheckedAt: s.lastCheckedAt,
cachedLatest: s.cachedLatest,
dismissedTag: tag,
});
},
currentVersion: () => Constants.expoConfig?.version ?? '0.0.0',
hasUpdate: () => {
const s = get();
if (!s.cachedLatest) return false;
const current = Constants.expoConfig?.version ?? '0.0.0';
return isNewer(s.cachedLatest.tag, current);
},
}));