224 行
7.3 KiB
TypeScript
224 行
7.3 KiB
TypeScript
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<void>
|
|
checkAppUpdate(userId: string | null, bypassIgnore: boolean): Promise<NativeUpdateCheckResult>
|
|
download(
|
|
downloadUrl: string,
|
|
versionCode: number,
|
|
sha256: string | null,
|
|
retryCount: number,
|
|
destination: UpdateDownloadDestination,
|
|
): Promise<DownloadedAppUpdate>
|
|
downloadAndInstall(
|
|
downloadUrl: string,
|
|
versionCode: number,
|
|
sha256: string | null,
|
|
retryCount: number,
|
|
destination: UpdateDownloadDestination,
|
|
): Promise<void>
|
|
installDownloaded(versionCode: number, sha256: string | null): Promise<void>
|
|
openStore(marketUrl: string | null, appStoreUrl: string | null): Promise<boolean>
|
|
cancelDownload(): Promise<void>
|
|
openInstallPermissionSettings(): Promise<boolean>
|
|
}
|
|
|
|
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<void> {
|
|
if (Platform.OS !== 'android') return Promise.resolve()
|
|
return getModule().syncUser(userId)
|
|
},
|
|
|
|
check(userId: string | null, bypassIgnore = false): Promise<NativeUpdateCheckResult> {
|
|
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<DownloadedAppUpdate> {
|
|
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<void> {
|
|
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<void> {
|
|
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<boolean> {
|
|
if (Platform.OS !== 'android') return Promise.resolve(false)
|
|
return getModule().openStore(input.marketUrl ?? null, input.appStoreUrl ?? null)
|
|
},
|
|
|
|
async cancel(): Promise<void> {
|
|
await getModule().cancelDownload()
|
|
},
|
|
|
|
openInstallPermissionSettings(): Promise<boolean> {
|
|
return getModule().openInstallPermissionSettings()
|
|
},
|
|
}
|