Compare commits

...
2 Commits
5 changed files with 354 additions and 28 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: carry-your-live-release name: carry-your-live-release
path: carry-your-live/android/app/build/outputs/apk/release/app-release.apk path: carry-your-live/android/app/build/outputs/release/app-release.apk
release: release:
needs: build needs: build
+10
View File
@@ -19,6 +19,7 @@
"expo-constants": "~57.0.9", "expo-constants": "~57.0.9",
"expo-dev-client": "~57.0.1", "expo-dev-client": "~57.0.1",
"expo-device": "^57.0.1", "expo-device": "^57.0.1",
"expo-intent-launcher": "~57.0.1",
"expo-linking": "~57.0.5", "expo-linking": "~57.0.5",
"expo-notifications": "~57.0.8", "expo-notifications": "~57.0.8",
"expo-router": "~57.0.10", "expo-router": "~57.0.10",
@@ -7594,6 +7595,15 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-intent-launcher": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-57.0.1.tgz",
"integrity": "sha512-BYiPEOUeW3AkW/uxEESbS2UBw9LasJ8Heb6A8kF9Ueh6pqrXQHMNJFHzfrOP8qZfv9G2e8CTzrtzpN5p0vH5fA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-json-utils": { "node_modules/expo-json-utils": {
"version": "57.0.1", "version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-57.0.1.tgz", "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-57.0.1.tgz",
+1
View File
@@ -14,6 +14,7 @@
"expo-constants": "~57.0.9", "expo-constants": "~57.0.9",
"expo-dev-client": "~57.0.1", "expo-dev-client": "~57.0.1",
"expo-device": "^57.0.1", "expo-device": "^57.0.1",
"expo-intent-launcher": "~57.0.1",
"expo-linking": "~57.0.5", "expo-linking": "~57.0.5",
"expo-notifications": "~57.0.8", "expo-notifications": "~57.0.8",
"expo-router": "~57.0.10", "expo-router": "~57.0.10",
+274 -26
View File
@@ -1,30 +1,278 @@
import { useEffect } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Alert, Linking } from 'react-native'; import {
import { checkForUpdates, wasPromptedFor, markPrompted } from '@/services/updates'; Modal,
View,
Text,
StyleSheet,
Pressable,
ScrollView,
Platform,
} from 'react-native';
import { checkForUpdates, wasPromptedFor, markPrompted, downloadAPK, installAPK, openReleaseUrl, AppUpdate } from '@/services/updates';
import { useSettings } from '@/theme';
export function UpdateNotifier() { export function UpdateNotifier() {
useEffect(() => { const { theme } = useSettings();
const run = async () => { const [update, setUpdate] = useState<AppUpdate | null>(null);
const update = await checkForUpdates(); const [visible, setVisible] = useState(false);
if (!update || (await wasPromptedFor(update.tagName))) return; const [downloading, setDownloading] = useState(false);
await markPrompted(update.tagName); const [downloadProgress, setDownloadProgress] = useState(0);
const url = update.apkUrl ?? update.releaseUrl; const [downloadError, setDownloadError] = useState<string | null>(null);
Alert.alert(
'Update available', const checkUpdate = useCallback(async () => {
`A new version (${update.version}) is available for download.`, const available = await checkForUpdates();
[ if (!available) return;
{ text: 'Later', style: 'cancel' },
{ const prompted = await wasPromptedFor(available.tagName);
text: 'Download', if (prompted && !available.mandatory) return;
onPress: () => {
if (url) Linking.openURL(url).catch(() => {}); setUpdate(available);
}, setVisible(true);
},
],
);
};
run();
}, []); }, []);
return null; useEffect(() => {
} checkUpdate();
}, [checkUpdate]);
const handleDownload = async () => {
if (!update?.apkUrl) {
await openReleaseUrl(update?.releaseUrl ?? '');
return;
}
setDownloading(true);
setDownloadProgress(0);
setDownloadError(null);
try {
const uri = await downloadAPK(update.apkUrl, setDownloadProgress);
if (uri) {
const installed = await installAPK(uri);
if (!installed) {
setDownloadError('Failed to open installer. Please install manually from Downloads.');
}
} else {
setDownloadError('Download failed. Please try again or download from the release page.');
}
} catch (error) {
setDownloadError('An error occurred during download.');
} finally {
setDownloading(false);
}
};
const handleLater = async () => {
if (update) {
await markPrompted(update.tagName);
}
setVisible(false);
};
const handleReleasePage = async () => {
if (update) {
await openReleaseUrl(update.releaseUrl);
}
};
if (!visible || !update) return null;
const isMandatory = update.mandatory;
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={isMandatory ? () => {} : handleLater}>
<View style={[styles.overlay, { backgroundColor: theme.overlay }]}>
<View style={[styles.modal, { backgroundColor: theme.card }]}>
<View style={[styles.header, { borderBottomColor: theme.border }]}>
<Text style={[styles.title, { color: theme.text }]}>Update Available</Text>
<Text style={[styles.version, { color: theme.textSecondary }]}>Version {update.version}</Text>
</View>
<ScrollView style={styles.content} contentContainerStyle={styles.contentContainer}>
<Text style={[styles.sectionTitle, { color: theme.text }]}>What's New</Text>
<Text style={[styles.notes, { color: theme.textSecondary }]}>
{update.notes || 'No release notes provided.'}
</Text>
{isMandatory && (
<View style={styles.mandatoryBadge}>
<Text style={styles.mandatoryText}>Mandatory Update</Text>
</View>
)}
{downloading && (
<View style={styles.progressContainer}>
<View style={[styles.progressBar, { backgroundColor: theme.border }]}>
<View
style={[
styles.progressFill,
{ backgroundColor: theme.accent, width: `${downloadProgress * 100}%` },
]}
/>
</View>
<Text style={[styles.progressText, { color: theme.textSecondary }]}>
Downloading... {Math.round(downloadProgress * 100)}%
</Text>
</View>
)}
{downloadError && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{downloadError}</Text>
<Pressable style={styles.errorAction} onPress={handleDownload}>
<Text style={styles.errorActionText}>Retry Download</Text>
</Pressable>
</View>
)}
</ScrollView>
<View style={[styles.actions, { borderTopColor: theme.border }]}>
{!isMandatory && (
<Pressable style={[styles.button, styles.buttonSecondary, { backgroundColor: theme.cardAlt }]} onPress={handleLater}>
<Text style={[styles.buttonText, { color: theme.textSecondary }]}>Later</Text>
</Pressable>
)}
<Pressable
style={[
styles.button,
styles.buttonPrimary,
{ backgroundColor: theme.accent, opacity: downloading ? 0.7 : 1 },
]}
onPress={handleDownload}
disabled={downloading}
>
<Text style={[styles.buttonText, { color: theme.accentText }]}>
{downloading ? 'Downloading...' : update.apkUrl ? 'Download & Install' : 'View Release'}
</Text>
</Pressable>
<Pressable style={[styles.button, styles.buttonGhost, { backgroundColor: 'transparent' }]} onPress={handleReleasePage}>
<Text style={[styles.buttonText, { color: theme.textSecondary }]}>Release Notes</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modal: {
width: '100%',
maxWidth: 480,
borderRadius: 20,
overflow: 'hidden',
maxHeight: '85%',
},
header: {
padding: 20,
borderBottomWidth: 1,
},
title: {
fontSize: 20,
fontWeight: '700',
marginBottom: 4,
},
version: {
fontSize: 14,
},
content: {
maxHeight: 300,
},
contentContainer: {
padding: 20,
paddingBottom: 12,
},
sectionTitle: {
fontSize: 14,
fontWeight: '600',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
notes: {
fontSize: 14,
lineHeight: 22,
},
mandatoryBadge: {
marginTop: 12,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
backgroundColor: '#FFEBEE',
alignSelf: 'flex-start',
},
mandatoryText: {
fontSize: 12,
fontWeight: '600',
color: '#C62828',
},
progressContainer: {
marginTop: 16,
},
progressBar: {
height: 6,
borderRadius: 3,
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: 3,
},
progressText: {
fontSize: 12,
marginTop: 6,
textAlign: 'center',
},
errorContainer: {
marginTop: 16,
padding: 12,
borderRadius: 8,
backgroundColor: '#FFF3E0',
},
errorText: {
fontSize: 13,
color: '#E65100',
marginBottom: 8,
},
errorAction: {
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 6,
backgroundColor: '#FF9800',
alignSelf: 'flex-start',
},
errorActionText: {
fontSize: 13,
fontWeight: '600',
color: '#FFF',
},
actions: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'flex-end',
gap: 8,
padding: 16,
borderTopWidth: 1,
},
button: {
paddingVertical: 10,
paddingHorizontal: 20,
borderRadius: 10,
minWidth: 100,
alignItems: 'center',
},
buttonPrimary: {},
buttonSecondary: {},
buttonGhost: {},
buttonText: {
fontSize: 14,
fontWeight: '600',
},
});
+68 -1
View File
@@ -1,5 +1,8 @@
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import * as FileSystem from 'expo-file-system/legacy';
import * as IntentLauncher from 'expo-intent-launcher';
import { Platform } from 'react-native';
const GITEA_BASE_URL = 'https://gitea.techmos.org'; const GITEA_BASE_URL = 'https://gitea.techmos.org';
const GITEA_OWNER = 'tech08mag'; const GITEA_OWNER = 'tech08mag';
@@ -12,6 +15,8 @@ export interface AppUpdate {
notes: string; notes: string;
apkUrl: string | null; apkUrl: string | null;
releaseUrl: string; releaseUrl: string;
mandatory: boolean;
publishedAt: string;
} }
export function getCurrentAppVersion(): string { export function getCurrentAppVersion(): string {
@@ -28,6 +33,13 @@ async function fetchWithTimeout(path: string, timeoutMs: number): Promise<Respon
} }
} }
function parseMandatoryFromNotes(notes: string, tagName: string): boolean {
const mandatoryKeywords = ['mandatory', 'critical', 'security', 'breaking', 'required'];
const lowerNotes = notes.toLowerCase();
const lowerTag = tagName.toLowerCase();
return mandatoryKeywords.some(kw => lowerNotes.includes(kw) || lowerTag.includes(kw));
}
export async function fetchLatestRelease(): Promise<AppUpdate | null> { export async function fetchLatestRelease(): Promise<AppUpdate | null> {
try { try {
const response = await fetchWithTimeout( const response = await fetchWithTimeout(
@@ -40,14 +52,17 @@ export async function fetchLatestRelease(): Promise<AppUpdate | null> {
(asset: { name?: string }) => asset.name && /\.apk$/i.test(asset.name), (asset: { name?: string }) => asset.name && /\.apk$/i.test(asset.name),
); );
const tagName: string = release.tag_name ?? release.name ?? ''; const tagName: string = release.tag_name ?? release.name ?? '';
const notes: string = release.body ?? '';
return { return {
version: tagName.replace(/^v/i, ''), version: tagName.replace(/^v/i, ''),
tagName, tagName,
notes: release.body ?? '', notes,
apkUrl: apkAsset?.browser_download_url ?? null, apkUrl: apkAsset?.browser_download_url ?? null,
releaseUrl: releaseUrl:
release.html_url ?? release.html_url ??
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/${tagName}`, `${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/${tagName}`,
mandatory: parseMandatoryFromNotes(notes, tagName),
publishedAt: release.published_at ?? new Date().toISOString(),
}; };
} catch { } catch {
return null; return null;
@@ -91,4 +106,56 @@ export async function markPrompted(tagName: string): Promise<void> {
} catch { } catch {
// ignore // ignore
} }
}
export async function downloadAPK(
url: string,
onProgress?: (progress: number) => void
): Promise<string | null> {
if (Platform.OS !== 'android') return null;
try {
const downloadDir = `${FileSystem.documentDirectory}downloads/`;
await FileSystem.makeDirectoryAsync(downloadDir, { intermediates: true });
const filename = `carry-your-live-${Date.now()}.apk`;
const filepath = `${downloadDir}${filename}`;
const downloadResumable = FileSystem.createDownloadResumable(
url,
filepath,
{},
(downloadProgress) => {
const progress = downloadProgress.totalBytesWritten / downloadProgress.totalBytesExpectedToWrite;
onProgress?.(progress);
}
);
const result = await downloadResumable.downloadAsync();
return result?.uri ?? null;
} catch (error) {
console.error('[Updates] Download failed:', error);
return null;
}
}
export async function installAPK(uri: string): Promise<boolean> {
if (Platform.OS !== 'android') return false;
try {
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: uri.startsWith('file://') ? uri : `file://${uri}`,
type: 'application/vnd.android.package-archive',
flags: 1,
});
return true;
} catch (error) {
console.error('[Updates] Install failed:', error);
return false;
}
}
export async function openReleaseUrl(url: string): Promise<void> {
const { openURL } = await import('expo-linking');
await openURL(url);
} }