import { NativeEventEmitter, NativeModules, Platform } from 'react-native' import type { AppUpdateInfo, UpdateDownloadProgress } from './UpdateSDK' export type AppInstallErrorCode = | 'DOWNLOAD_FAILED' | 'DOWNLOAD_CANCELLED' | 'HASH_MISMATCH' | 'PACKAGE_MISMATCH' | 'SIGNATURE_MISMATCH' | 'AUTHORIZATION_EXPIRED' | 'INSTALL_PERMISSION_REQUIRED' | 'INSTALLER_UNAVAILABLE' | 'ANDROID_SDK_MISSING' | 'INSTALL_FAILED' export class AppInstallError extends Error { readonly name = 'AppInstallError' constructor( public readonly code: AppInstallErrorCode, message: string, options?: ErrorOptions, ) { super(message, options) } } interface XuqmAppUpdateModuleInterface { syncUser(userId: string | null): Promise checkAppUpdate(userId: string | null, bypassIgnore: boolean): Promise download( downloadUrl: string, versionCode: number, sha256: string | null, retryCount: number, destination: UpdateDownloadDestination, ): Promise downloadAndInstall( downloadUrl: string, versionCode: number, sha256: string | null, retryCount: number, destination: UpdateDownloadDestination, ): Promise installDownloaded(versionCode: number, sha256: string | null): Promise openStore(marketUrl: string | null, appStoreUrl: string | null): Promise cancelDownload(): Promise openInstallPermissionSettings(): Promise } function getModule(): XuqmAppUpdateModuleInterface { const module = NativeModules.XuqmAppUpdateModule if (!module) { throw new AppInstallError( 'ANDROID_SDK_MISSING', '[UpdateSDK] Xuqm Android update bridge is not linked', ) } return module as XuqmAppUpdateModuleInterface } function asInstallError(error: unknown): AppInstallError { if (error instanceof AppInstallError) return error const native = error as { code?: string; message?: string } const code = native.code as AppInstallErrorCode | undefined const known: AppInstallErrorCode[] = [ 'DOWNLOAD_FAILED', 'DOWNLOAD_CANCELLED', 'HASH_MISMATCH', 'PACKAGE_MISMATCH', 'SIGNATURE_MISMATCH', 'AUTHORIZATION_EXPIRED', 'INSTALL_PERMISSION_REQUIRED', 'INSTALLER_UNAVAILABLE', 'ANDROID_SDK_MISSING', 'INSTALL_FAILED', ] return new AppInstallError( code && known.includes(code) ? code : 'INSTALL_FAILED', native.message ?? '[UpdateSDK] App installation failed', { cause: error }, ) } export type UpdateDownloadDestination = 'SDK_PRIVATE' | 'PUBLIC_DOWNLOADS' export interface DownloadedAppUpdate { localPath: string publicUri: string | null } export type NativeUpdateCheckStatus = 'UPDATE_AVAILABLE' | 'NO_UPDATE' | 'LOGIN_REQUIRED' | 'SERVICE_DISABLED' | 'FAILED' export type NativeNetworkType = 'NONE' | 'WIFI' | 'CELLULAR' | 'CELLULAR_4G' | 'CELLULAR_5G' | 'ETHERNET' | 'OTHER' export interface NativeUpdateCheckResult { status: NativeUpdateCheckStatus networkType: NativeNetworkType isConnected: boolean isMetered: boolean update: AppUpdateInfo | null errorCode: string | null message: string | null } export const NativeAppUpdate = { syncUser(userId: string | null): Promise { if (Platform.OS !== 'android') return Promise.resolve() return getModule().syncUser(userId) }, check(userId: string | null, bypassIgnore = false): Promise { if (Platform.OS !== 'android') { throw new AppInstallError('INSTALLER_UNAVAILABLE', 'Native app update is Android-only') } return getModule().checkAppUpdate(userId, bypassIgnore) }, async download( input: { downloadUrl: string; versionCode: number; sha256?: string }, options: { signal?: AbortSignal retryCount?: number destination?: UpdateDownloadDestination onProgress?: (progress: UpdateDownloadProgress) => void } = {}, ): Promise { if (Platform.OS !== 'android') { throw new AppInstallError('INSTALLER_UNAVAILABLE', 'App download is Android-only') } const module = getModule() if (!input.sha256) throw new AppInstallError('HASH_MISMATCH', 'APK SHA-256 is required') const emitter = new NativeEventEmitter(NativeModules.XuqmAppUpdateModule) const subscription = emitter.addListener( 'XuqmAppUpdateProgress', (progress: UpdateDownloadProgress) => options.onProgress?.(progress), ) const cancel = () => void module.cancelDownload() options.signal?.addEventListener('abort', cancel, { once: true }) try { if (options.signal?.aborted) throw new AppInstallError('DOWNLOAD_CANCELLED', 'Cancelled') return await module.download( input.downloadUrl, input.versionCode, input.sha256, Math.max(0, Math.trunc(options.retryCount ?? 1)), options.destination ?? 'SDK_PRIVATE', ) } catch (error) { throw asInstallError(error) } finally { subscription.remove() options.signal?.removeEventListener('abort', cancel) } }, async downloadAndInstall( input: { downloadUrl: string; versionCode: number; sha256?: string }, options: { signal?: AbortSignal retryCount?: number destination?: UpdateDownloadDestination onProgress?: (progress: UpdateDownloadProgress) => void } = {}, ): Promise { if (Platform.OS !== 'android') { throw new AppInstallError('INSTALLER_UNAVAILABLE', 'App installation is Android-only') } const module = getModule() if (!input.sha256) { throw new AppInstallError('HASH_MISMATCH', 'APK SHA-256 is required') } const emitter = new NativeEventEmitter(NativeModules.XuqmAppUpdateModule) const subscription = emitter.addListener( 'XuqmAppUpdateProgress', (progress: UpdateDownloadProgress) => options.onProgress?.(progress), ) const cancel = () => void module.cancelDownload() options.signal?.addEventListener('abort', cancel, { once: true }) try { if (options.signal?.aborted) throw new AppInstallError('DOWNLOAD_CANCELLED', 'Cancelled') await module.downloadAndInstall( input.downloadUrl, input.versionCode, input.sha256 ?? null, Math.max(0, Math.trunc(options.retryCount ?? 1)), options.destination ?? 'SDK_PRIVATE', ) } catch (error) { throw asInstallError(error) } finally { subscription.remove() options.signal?.removeEventListener('abort', cancel) } }, async installDownloaded(input: { versionCode: number; sha256?: string }): Promise { if (Platform.OS !== 'android') { throw new AppInstallError('INSTALLER_UNAVAILABLE', 'App installation is Android-only') } if (!input.sha256) throw new AppInstallError('HASH_MISMATCH', 'APK SHA-256 is required') try { await getModule().installDownloaded(input.versionCode, input.sha256) } catch (error) { throw asInstallError(error) } }, openStore(input: { marketUrl?: string; appStoreUrl?: string }): Promise { if (Platform.OS !== 'android') return Promise.resolve(false) return getModule().openStore(input.marketUrl ?? null, input.appStoreUrl ?? null) }, async cancel(): Promise { await getModule().cancelDownload() }, openInstallPermissionSettings(): Promise { return getModule().openInstallPermissionSettings() }, }