From a4548c69e9b9fc3b8a009c6bcc83ed1d220011c0 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:59:13 +0530 Subject: [PATCH] added inbuit update downloader for android --- App.tsx | 45 ++++++++- android/app/src/main/AndroidManifest.xml | 1 + src/components/MajorUpdateOverlay.tsx | 21 ++++- src/hooks/useGithubMajorUpdate.ts | 7 +- src/screens/UpdateScreen.tsx | 56 +++++++++++- src/services/androidUpdateService.ts | 111 +++++++++++++++++++++++ src/services/githubReleaseService.ts | 16 +++- 7 files changed, 244 insertions(+), 13 deletions(-) create mode 100644 src/services/androidUpdateService.ts diff --git a/App.tsx b/App.tsx index 5ba73d2ea..4be43b960 100644 --- a/App.tsx +++ b/App.tsx @@ -11,7 +11,8 @@ import { StyleSheet, I18nManager, Platform, - LogBox + LogBox, + Linking } from 'react-native'; import './src/i18n'; // Initialize i18n import { NavigationContainer } from '@react-navigation/native'; @@ -104,6 +105,45 @@ const ThemedApp = () => { // GitHub major/minor release overlay const githubUpdate = useGithubMajorUpdate(); + const [isDownloadingGitHub, setIsDownloadingGitHub] = useState(false); + const [downloadProgress, setDownloadProgress] = useState(0); + + const handleGithubUpdateAction = async () => { + console.log('handleGithubUpdateAction triggered. Release data exists:', !!githubUpdate.releaseData); + if (Platform.OS === 'android') { + setIsDownloadingGitHub(true); + setDownloadProgress(0); + try { + const { default: AndroidUpdateService } = await import('./src/services/androidUpdateService'); + if (githubUpdate.releaseData) { + console.log('Calling AndroidUpdateService with:', githubUpdate.releaseData.tag_name); + const success = await AndroidUpdateService.downloadAndInstallUpdate( + githubUpdate.releaseData, + (progress) => { + setDownloadProgress(progress); + } + ); + console.log('AndroidUpdateService result:', success); + if (!success) { + console.log('Update failed, falling back to browser'); + // If download fails or no APK found, fallback to browser + if (githubUpdate.releaseUrl) Linking.openURL(githubUpdate.releaseUrl); + } + } else if (githubUpdate.releaseUrl) { + console.log('No release data, falling back to browser'); + Linking.openURL(githubUpdate.releaseUrl); + } + } catch (error) { + console.error('Failed to update via Android service', error); + if (githubUpdate.releaseUrl) Linking.openURL(githubUpdate.releaseUrl); + } finally { + setIsDownloadingGitHub(false); + setDownloadProgress(0); + } + } else { + if (githubUpdate.releaseUrl) Linking.openURL(githubUpdate.releaseUrl); + } + }; // Check onboarding status and initialize services useEffect(() => { @@ -202,6 +242,9 @@ const ThemedApp = () => { releaseUrl={githubUpdate.releaseUrl} onDismiss={githubUpdate.onDismiss} onLater={githubUpdate.onLater} + onUpdateAction={handleGithubUpdateAction} + isDownloading={isDownloadingGitHub} + downloadProgress={downloadProgress} /> diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8478405be..810d51117 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + diff --git a/src/components/MajorUpdateOverlay.tsx b/src/components/MajorUpdateOverlay.tsx index cd3cc194e..bd21d9632 100644 --- a/src/components/MajorUpdateOverlay.tsx +++ b/src/components/MajorUpdateOverlay.tsx @@ -10,13 +10,18 @@ interface Props { releaseUrl?: string; onDismiss: () => void; onLater: () => void; + onUpdateAction?: () => void; + isDownloading?: boolean; + downloadProgress?: number; } -const MajorUpdateOverlay: React.FC = ({ visible, latestTag, releaseNotes, releaseUrl, onDismiss, onLater }) => { +const MajorUpdateOverlay: React.FC = ({ visible, latestTag, releaseNotes, releaseUrl, onDismiss, onLater, onUpdateAction, isDownloading, downloadProgress }) => { const { currentTheme } = useTheme(); if (!visible) return null; + const progressPercent = downloadProgress ? Math.round(downloadProgress * 100) : 0; + return ( @@ -40,10 +45,16 @@ const MajorUpdateOverlay: React.FC = ({ visible, latestTag, releaseNotes, )} - {releaseUrl ? ( - Linking.openURL(releaseUrl)}> - - View release + {releaseUrl || onUpdateAction ? ( + releaseUrl && Linking.openURL(releaseUrl))} + disabled={isDownloading} + > + + + {isDownloading ? `Downloading... ${progressPercent}%` : (onUpdateAction ? 'Update Now' : 'View release')} + ) : null} diff --git a/src/hooks/useGithubMajorUpdate.ts b/src/hooks/useGithubMajorUpdate.ts index 00e0e4ba3..ef4b042c3 100644 --- a/src/hooks/useGithubMajorUpdate.ts +++ b/src/hooks/useGithubMajorUpdate.ts @@ -7,11 +7,14 @@ import { fetchLatestGithubRelease, isAnyUpgrade } from '../services/githubReleas const DISMISSED_KEY = '@github_major_update_dismissed_version'; +import { GithubReleaseInfo } from '../services/githubReleaseService'; + export interface MajorUpdateData { visible: boolean; latestTag?: string; releaseNotes?: string; releaseUrl?: string; + releaseData?: GithubReleaseInfo; onDismiss: () => void; onLater: () => void; refresh: () => void; @@ -22,6 +25,7 @@ export function useGithubMajorUpdate(): MajorUpdateData { const [latestTag, setLatestTag] = useState(); const [releaseNotes, setReleaseNotes] = useState(); const [releaseUrl, setReleaseUrl] = useState(); + const [releaseData, setReleaseData] = useState(); const check = useCallback(async () => { if (Platform.OS === 'ios') return; @@ -47,6 +51,7 @@ export function useGithubMajorUpdate(): MajorUpdateData { setLatestTag(info.tag_name); setReleaseNotes(info.body); setReleaseUrl(info.html_url); + setReleaseData(info); setVisible(true); } } catch { @@ -67,7 +72,7 @@ export function useGithubMajorUpdate(): MajorUpdateData { setVisible(false); }, []); - return { visible, latestTag, releaseNotes, releaseUrl, onDismiss, onLater, refresh: check }; + return { visible, latestTag, releaseNotes, releaseUrl, releaseData, onDismiss, onLater, refresh: check }; } diff --git a/src/screens/UpdateScreen.tsx b/src/screens/UpdateScreen.tsx index 3fffe275d..fcf11576b 100644 --- a/src/screens/UpdateScreen.tsx +++ b/src/screens/UpdateScreen.tsx @@ -230,13 +230,48 @@ const UpdateScreen: React.FC = () => { } }, []); - const installUpdate = async () => { + const installUpdate = async (options?: { forceGithub?: boolean }) => { try { setIsInstalling(true); setUpdateStatus('downloading'); setUpdateProgress(0); setLastOperation(t('updates.status_downloading')); + const forceGithub = options?.forceGithub === true; + + // If it's a GitHub release update + if ((updateInfo?.source === 'github' || forceGithub) && Platform.OS === 'android') { + const { default: AndroidUpdateService } = await import('../services/androidUpdateService'); + + // We need the full release info with assets + const fullRelease = await import('../services/githubReleaseService').then(m => m.fetchLatestGithubRelease()); + + if (!fullRelease || !fullRelease.assets) { + throw new Error('Could not fetch release assets'); + } + + setLastOperation('Downloading APK...'); + // Note: Progress is not currently supported by FileSystem.downloadAsync in the simple way + // We'll simulate it for now or implement a more complex downloader later if needed + const success = await AndroidUpdateService.downloadAndInstallUpdate( + fullRelease, + (progress) => { + setUpdateProgress(progress * 100); + } + ); + + if (success) { + setUpdateProgress(100); + setUpdateStatus('success'); + setLastOperation(t('updates.status_success')); + // No alert needed, system installer takes over + } else { + throw new Error('Download or installation failed'); + } + return; + } + + // Fallback for OTA / Expo Updates // Simulate progress updates const progressInterval = setInterval(() => { setUpdateProgress(prev => { @@ -509,7 +544,7 @@ const UpdateScreen: React.FC = () => { { backgroundColor: currentTheme.colors.success || '#34C759' }, (isInstalling) && styles.disabledAction ]} - onPress={installUpdate} + onPress={() => installUpdate()} disabled={isInstalling} activeOpacity={0.8} > @@ -634,6 +669,23 @@ const UpdateScreen: React.FC = () => { + {Platform.OS === 'android' && ( + installUpdate({ forceGithub: true })} + disabled={isInstalling} + activeOpacity={0.8} + > + {isInstalling ? ( + + ) : ( + + )} + + {isInstalling ? `${t('updates.status_downloading')}...` : t('updates.action_install')} + + + )} github.releaseUrl ? Linking.openURL(github.releaseUrl as string) : null} diff --git a/src/services/androidUpdateService.ts b/src/services/androidUpdateService.ts new file mode 100644 index 000000000..089a4960b --- /dev/null +++ b/src/services/androidUpdateService.ts @@ -0,0 +1,111 @@ +import * as FileSystem from 'expo-file-system/legacy'; +import * as IntentLauncher from 'expo-intent-launcher'; +import * as Device from 'expo-device'; +import { Platform } from 'react-native'; +import { GithubReleaseInfo } from './githubReleaseService'; + +class AndroidUpdateService { + /** + * Downloads and installs the APK from the given GitHub release. + * Matches the device architecture to the correct APK asset. + * + * @param release The GitHub release info containing assets + * @returns Promise true if installation started, false otherwise + */ + /** + * Downloads and installs the APK from the given GitHub release. + * Matches the device architecture to the correct APK asset. + * + * @param release The GitHub release info containing assets + * @param onProgress Optional callback for download progress (0-1) + * @returns Promise true if installation started, false otherwise + */ + async downloadAndInstallUpdate(release: GithubReleaseInfo, onProgress?: (progress: number) => void): Promise { + if (Platform.OS !== 'android') return false; + + const apkUrl = this.getBestApkUrl(release); + if (!apkUrl) { + console.warn('No suitable APK found for this device architecture'); + return false; + } + + try { + // Create a temporary file path + const filename = `nuvio-update-${release.tag_name}.apk`; + // @ts-ignore + const downloadDest = `${FileSystem.cacheDirectory}${filename}`; + + // Create a resumable download to track progress + const callback = (downloadProgress: FileSystem.DownloadProgressData) => { + const progress = downloadProgress.totalBytesWritten / downloadProgress.totalBytesExpectedToWrite; + if (onProgress) onProgress(progress); + }; + + const downloadResumable = FileSystem.createDownloadResumable( + apkUrl, + downloadDest, + {}, + callback + ); + + // Download the APK + const downloadRes = await downloadResumable.downloadAsync(); + + if (!downloadRes || downloadRes.status !== 200) { + console.error('Failed to download APK', downloadRes?.status); + return false; + } + + // Get Content URI using Expo's FileSystem + const contentUri = await FileSystem.getContentUriAsync(downloadDest); + + // Launch the intent to install + console.log('AndroidUpdateService: Starting installation intent with content URI:', contentUri); + await IntentLauncher.startActivityAsync('android.intent.action.VIEW', { + data: contentUri, + flags: 1 | 268435456, // FLAG_GRANT_READ_URI_PERMISSION (1) | FLAG_ACTIVITY_NEW_TASK (0x10000000) + type: 'application/vnd.android.package-archive', + }); + + console.log('AndroidUpdateService: Installation intent started successfully'); + return true; + } catch (error) { + console.error('Error downloading or installing update:', error); + return false; + } + } + + /** + * Selects the best APK URL based on device architecture. + * Priority: Specific Arch > Universal > First APK found + */ + private getBestApkUrl(release: GithubReleaseInfo): string | null { + console.log('AndroidUpdateService: Finding best APK for release assets:', release.assets?.length); + if (!release.assets || release.assets.length === 0) return null; + + const supportedArchs = Device.supportedCpuArchitectures; // e.g. ['arm64-v8a', 'armeabi-v7a'] + console.log('Device architectures:', supportedArchs); + + // Helper to find asset containing string (case-insensitive) + const findAsset = (keyword: string) => + release.assets?.find(a => + a.name.toLowerCase().includes(keyword.toLowerCase()) && + a.name.toLowerCase().endsWith('.apk') + ); + + // 1. Try to match supported architectures in order + if (supportedArchs) { + for (const arch of supportedArchs) { + const match = findAsset(arch); + if (match) return match.browser_download_url; + } + } + + // 2. No fallback: If no specific architecture match is found, return null. + // User requested strict matching to avoid downloading incompatible APKs. + console.warn('AndroidUpdateService: No matching APK found for device architectures:', supportedArchs); + return null; + } +} + +export default new AndroidUpdateService(); diff --git a/src/services/githubReleaseService.ts b/src/services/githubReleaseService.ts index b2696b7c7..109dda9cd 100644 --- a/src/services/githubReleaseService.ts +++ b/src/services/githubReleaseService.ts @@ -6,6 +6,13 @@ export interface GithubReleaseInfo { body?: string; html_url?: string; published_at?: string; + assets?: Array<{ + name: string; + browser_download_url: string; + content_type: string; + size: number; + download_count: number; + }>; } const GITHUB_LATEST_RELEASE_URL = 'https://api.github.com/repos/tapframe/NuvioStreaming/releases/latest'; @@ -27,6 +34,7 @@ export async function fetchLatestGithubRelease(): Promise { }); if (!res.ok) return null; const releases = await res.json(); - + let total = 0; releases.forEach((release: any) => { if (release.assets && Array.isArray(release.assets)) { @@ -78,7 +86,7 @@ export async function fetchTotalDownloads(): Promise { }); } }); - + return total; } catch { return null; @@ -102,12 +110,12 @@ export async function fetchContributors(): Promise { 'User-Agent': `Nuvio/${Platform.OS}`, }, }); - + if (!res.ok) { if (__DEV__) console.error('GitHub API error:', res.status, res.statusText); return null; } - + const contributors = await res.json(); return contributors; } catch (error) {