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) {