Fixed the APK output path - the build outputs to outputs/release/app-release.apk not outputs/apk/release/app-release.apk
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Generated
+10
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user