added inbuit update downloader for android

This commit is contained in:
tapframe 2026-01-21 15:59:13 +05:30
parent 0ee748cd10
commit a4548c69e9
7 changed files with 244 additions and 13 deletions

45
App.tsx
View file

@ -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}
/>
<CampaignManager />
</View>

View file

@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>

View file

@ -10,13 +10,18 @@ interface Props {
releaseUrl?: string;
onDismiss: () => void;
onLater: () => void;
onUpdateAction?: () => void;
isDownloading?: boolean;
downloadProgress?: number;
}
const MajorUpdateOverlay: React.FC<Props> = ({ visible, latestTag, releaseNotes, releaseUrl, onDismiss, onLater }) => {
const MajorUpdateOverlay: React.FC<Props> = ({ 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 (
<Modal visible={visible} transparent animationType="fade" statusBarTranslucent presentationStyle="overFullScreen" supportedOrientations={['portrait', 'landscape', 'landscape-left', 'landscape-right']}>
<View style={styles.backdrop}>
@ -40,10 +45,16 @@ const MajorUpdateOverlay: React.FC<Props> = ({ visible, latestTag, releaseNotes,
)}
<View style={styles.actions}>
{releaseUrl ? (
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: currentTheme.colors.primary }]} onPress={() => Linking.openURL(releaseUrl)}>
<MaterialIcons name="open-in-new" size={18} color="#fff" />
<Text style={styles.primaryText}>View release</Text>
{releaseUrl || onUpdateAction ? (
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: currentTheme.colors.primary, opacity: isDownloading ? 0.7 : 1 }]}
onPress={onUpdateAction || (() => releaseUrl && Linking.openURL(releaseUrl))}
disabled={isDownloading}
>
<MaterialIcons name={isDownloading ? "downloading" : "system-update"} size={18} color="#fff" />
<Text style={styles.primaryText}>
{isDownloading ? `Downloading... ${progressPercent}%` : (onUpdateAction ? 'Update Now' : 'View release')}
</Text>
</TouchableOpacity>
) : null}

View file

@ -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<string | undefined>();
const [releaseNotes, setReleaseNotes] = useState<string | undefined>();
const [releaseUrl, setReleaseUrl] = useState<string | undefined>();
const [releaseData, setReleaseData] = useState<GithubReleaseInfo | undefined>();
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 };
}

View file

@ -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 = () => {
<View style={[styles.actionSection, { marginTop: 8 }]}>
<View style={{ flexDirection: 'row', gap: 10 }}>
{Platform.OS === 'android' && (
<TouchableOpacity
style={[styles.modernButton, { backgroundColor: currentTheme.colors.success || '#34C759', flex: 1 }]}
onPress={() => installUpdate({ forceGithub: true })}
disabled={isInstalling}
activeOpacity={0.8}
>
{isInstalling ? (
<MaterialIcons name="downloading" size={18} color="white" />
) : (
<MaterialIcons name="system-update" size={18} color="white" />
)}
<Text style={styles.modernButtonText}>
{isInstalling ? `${t('updates.status_downloading')}...` : t('updates.action_install')}
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.modernButton, { backgroundColor: currentTheme.colors.primary, flex: 1 }]}
onPress={() => github.releaseUrl ? Linking.openURL(github.releaseUrl as string) : null}

View file

@ -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<boolean> 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<boolean> true if installation started, false otherwise
*/
async downloadAndInstallUpdate(release: GithubReleaseInfo, onProgress?: (progress: number) => void): Promise<boolean> {
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();

View file

@ -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<GithubReleaseInfo | nu
body: json.body,
html_url: json.html_url,
published_at: json.published_at,
assets: json.assets,
};
} catch {
return null;
@ -69,7 +77,7 @@ export async function fetchTotalDownloads(): Promise<number | null> {
});
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<number | null> {
});
}
});
return total;
} catch {
return null;
@ -102,12 +110,12 @@ export async function fetchContributors(): Promise<GitHubContributor[] | null> {
'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) {