Merge pull request 'Fixed the APK output path - the build outputs to outputs/release/app-release.apk not outputs/apk/release/app-release.apk' (#33) from dev into main
Reviewed-on: #33
This commit was merged in pull request #33.
This commit is contained in:
@@ -83,7 +83,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
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:
|
||||
needs: build
|
||||
|
||||
Generated
+10
@@ -19,6 +19,7 @@
|
||||
"expo-constants": "~57.0.9",
|
||||
"expo-dev-client": "~57.0.1",
|
||||
"expo-device": "^57.0.1",
|
||||
"expo-intent-launcher": "~57.0.1",
|
||||
"expo-linking": "~57.0.5",
|
||||
"expo-notifications": "~57.0.8",
|
||||
"expo-router": "~57.0.10",
|
||||
@@ -7594,6 +7595,15 @@
|
||||
"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": {
|
||||
"version": "57.0.1",
|
||||
"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-dev-client": "~57.0.1",
|
||||
"expo-device": "^57.0.1",
|
||||
"expo-intent-launcher": "~57.0.1",
|
||||
"expo-linking": "~57.0.5",
|
||||
"expo-notifications": "~57.0.8",
|
||||
"expo-router": "~57.0.10",
|
||||
|
||||
@@ -1,30 +1,278 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Alert, Linking } from 'react-native';
|
||||
import { checkForUpdates, wasPromptedFor, markPrompted } from '@/services/updates';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
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() {
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const update = await checkForUpdates();
|
||||
if (!update || (await wasPromptedFor(update.tagName))) return;
|
||||
await markPrompted(update.tagName);
|
||||
const url = update.apkUrl ?? update.releaseUrl;
|
||||
Alert.alert(
|
||||
'Update available',
|
||||
`A new version (${update.version}) is available for download.`,
|
||||
[
|
||||
{ text: 'Later', style: 'cancel' },
|
||||
{
|
||||
text: 'Download',
|
||||
onPress: () => {
|
||||
if (url) Linking.openURL(url).catch(() => {});
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
run();
|
||||
const { theme } = useSettings();
|
||||
const [update, setUpdate] = useState<AppUpdate | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||
|
||||
const checkUpdate = useCallback(async () => {
|
||||
const available = await checkForUpdates();
|
||||
if (!available) return;
|
||||
|
||||
const prompted = await wasPromptedFor(available.tagName);
|
||||
if (prompted && !available.mandatory) return;
|
||||
|
||||
setUpdate(available);
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
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 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_OWNER = 'tech08mag';
|
||||
@@ -12,6 +15,8 @@ export interface AppUpdate {
|
||||
notes: string;
|
||||
apkUrl: string | null;
|
||||
releaseUrl: string;
|
||||
mandatory: boolean;
|
||||
publishedAt: 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> {
|
||||
try {
|
||||
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),
|
||||
);
|
||||
const tagName: string = release.tag_name ?? release.name ?? '';
|
||||
const notes: string = release.body ?? '';
|
||||
return {
|
||||
version: tagName.replace(/^v/i, ''),
|
||||
tagName,
|
||||
notes: release.body ?? '',
|
||||
notes,
|
||||
apkUrl: apkAsset?.browser_download_url ?? null,
|
||||
releaseUrl:
|
||||
release.html_url ??
|
||||
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/${tagName}`,
|
||||
mandatory: parseMandatoryFromNotes(notes, tagName),
|
||||
publishedAt: release.published_at ?? new Date().toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -92,3 +107,55 @@ export async function markPrompted(tagName: string): Promise<void> {
|
||||
// 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