diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 0b9fef4..0d952d7 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/carry-your-live/package-lock.json b/carry-your-live/package-lock.json index 348fc8b..fd3465a 100644 --- a/carry-your-live/package-lock.json +++ b/carry-your-live/package-lock.json @@ -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", diff --git a/carry-your-live/package.json b/carry-your-live/package.json index 427ce99..59a07e0 100644 --- a/carry-your-live/package.json +++ b/carry-your-live/package.json @@ -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", diff --git a/carry-your-live/src/components/UpdateNotifier.tsx b/carry-your-live/src/components/UpdateNotifier.tsx index e70d3d5..4288887 100644 --- a/carry-your-live/src/components/UpdateNotifier.tsx +++ b/carry-your-live/src/components/UpdateNotifier.tsx @@ -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(null); + const [visible, setVisible] = useState(false); + const [downloading, setDownloading] = useState(false); + const [downloadProgress, setDownloadProgress] = useState(0); + const [downloadError, setDownloadError] = useState(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; -} \ No newline at end of file + 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 ( + {} : handleLater}> + + + + Update Available + Version {update.version} + + + + What's New + + {update.notes || 'No release notes provided.'} + + + {isMandatory && ( + + Mandatory Update + + )} + + {downloading && ( + + + + + + Downloading... {Math.round(downloadProgress * 100)}% + + + )} + + {downloadError && ( + + {downloadError} + + Retry Download + + + )} + + + + {!isMandatory && ( + + Later + + )} + + + + {downloading ? 'Downloading...' : update.apkUrl ? 'Download & Install' : 'View Release'} + + + + + Release Notes + + + + + + ); +} + +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', + }, +}); \ No newline at end of file diff --git a/carry-your-live/src/services/updates.ts b/carry-your-live/src/services/updates.ts index 15ba33d..c5c9beb 100644 --- a/carry-your-live/src/services/updates.ts +++ b/carry-your-live/src/services/updates.ts @@ -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 lowerNotes.includes(kw) || lowerTag.includes(kw)); +} + export async function fetchLatestRelease(): Promise { try { const response = await fetchWithTimeout( @@ -40,14 +52,17 @@ export async function fetchLatestRelease(): Promise { (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; @@ -91,4 +106,56 @@ export async function markPrompted(tagName: string): Promise { } catch { // ignore } +} + +export async function downloadAPK( + url: string, + onProgress?: (progress: number) => void +): Promise { + 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 { + 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 { + const { openURL } = await import('expo-linking'); + await openURL(url); } \ No newline at end of file