diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 67065182..4e42e141 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -70,6 +70,7 @@ import com.bitchat.android.nostr.NostrProofOfWork import com.bitchat.android.nostr.PoWPreferenceManager import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.util.ShareableApkVariant import com.bitchat.android.util.UniversalApkManager /** @@ -512,10 +513,13 @@ fun AboutSheet( is ApkPreparationStatus.Loading -> stringResource(R.string.checking) is ApkPreparationStatus.NotDownloaded -> stringResource(R.string.prepare_apk_status_not_downloaded) is ApkPreparationStatus.Ready -> { - val source = if (status.source == UniversalApkManager.ApkSource.INSTALLED) { - stringResource(R.string.prepare_apk_source_installed) - } else { - stringResource(R.string.prepare_apk_source_github) + val source = when { + status.source == UniversalApkManager.ApkSource.GITHUB -> + stringResource(R.string.prepare_apk_source_github) + status.variant == ShareableApkVariant.ARM64 -> + stringResource(R.string.prepare_apk_source_installed_arm64) + else -> + stringResource(R.string.prepare_apk_source_installed) } stringResource(R.string.prepare_apk_status_ready) + " • ${status.version} • ${status.sizeMB} MB\n$source" @@ -545,14 +549,36 @@ fun AboutSheet( ) } is ApkPreparationStatus.Ready -> { - if (apkStatus.source == UniversalApkManager.ApkSource.GITHUB) { + if (apkStatus.variant == ShareableApkVariant.ARM64) { + TextButton( + onClick = { + apkViewModel.onEvent( + ApkUiEvent.DownloadUniversalClicked + ) + } + ) { + Icon( + imageVector = Icons.Default.CloudDownload, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + stringResource( + R.string.prepare_apk_get_universal + ) + ) + } + } else if (apkStatus.source == UniversalApkManager.ApkSource.GITHUB) { androidx.compose.material3.IconButton( onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) }, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(48.dp) ) { Icon( imageVector = Icons.Default.Delete, - contentDescription = "Delete", + contentDescription = stringResource( + R.string.prepare_apk_delete_confirm + ), tint = colorScheme.error, modifier = Modifier.size(20.dp) ) @@ -562,11 +588,13 @@ fun AboutSheet( is ApkPreparationStatus.UpdateAvailable -> { androidx.compose.material3.IconButton( onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) }, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(48.dp) ) { Icon( imageVector = Icons.Default.Delete, - contentDescription = "Delete", + contentDescription = stringResource( + R.string.prepare_apk_delete_confirm + ), tint = colorScheme.error, modifier = Modifier.size(20.dp) ) diff --git a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt index 0b4e0005..aea1afac 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.bitchat.android.R import com.bitchat.android.util.ApkDownloader +import com.bitchat.android.util.ShareableApkVariant import com.bitchat.android.util.UniversalApkManager import com.bitchat.android.util.WorkManagerApkDownloader import kotlinx.coroutines.Dispatchers @@ -27,7 +28,8 @@ sealed class ApkPreparationStatus { data class Ready( val version: String, val sizeMB: Int, - val source: UniversalApkManager.ApkSource + val source: UniversalApkManager.ApkSource, + val variant: ShareableApkVariant ) : ApkPreparationStatus() data class UpdateAvailable( val currentVersion: String, @@ -52,6 +54,7 @@ data class ApkUiState( sealed class ApkUiEvent { object CheckStatus : ApkUiEvent() object PrepareRowClicked : ApkUiEvent() + object DownloadUniversalClicked : ApkUiEvent() object ConfirmDownload : ApkUiEvent() object DismissPrepareDialog : ApkUiEvent() object DeleteClicked : ApkUiEvent() @@ -99,6 +102,7 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat when (event) { is ApkUiEvent.CheckStatus -> checkStatus() is ApkUiEvent.PrepareRowClicked -> onPrepareRowClicked() + is ApkUiEvent.DownloadUniversalClicked -> onDownloadUniversalClicked() is ApkUiEvent.ConfirmDownload -> onConfirmDownload() is ApkUiEvent.DismissPrepareDialog -> _state.update { it.copy(showPrepareDialog = false) } is ApkUiEvent.DeleteClicked -> _state.update { it.copy(showDeleteDialog = true) } @@ -131,6 +135,15 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat startDownload() } + private fun onDownloadUniversalClicked() { + val status = _state.value.apkStatus + if (status is ApkPreparationStatus.Ready && + status.variant == ShareableApkVariant.ARM64 + ) { + _state.update { it.copy(showPrepareDialog = true) } + } + } + private fun onConfirmDelete() { _state.update { it.copy(showDeleteDialog = false) } downloader.cancelDownload() @@ -234,26 +247,47 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat _state.update { it.copy( apkStatus = ApkPreparationStatus.Ready( - version = downloadState.version, - sizeMB = downloadState.sizeMB, - source = info?.source ?: UniversalApkManager.ApkSource.GITHUB + version = info?.version ?: downloadState.version, + sizeMB = info?.let { cached -> + (cached.size / 1024 / 1024).toInt() + } ?: downloadState.sizeMB, + source = info?.source ?: UniversalApkManager.ApkSource.GITHUB, + variant = info?.variant ?: ShareableApkVariant.UNIVERSAL ), downloadProgress = 100 ) } } is ApkDownloader.DownloadState.Failed -> { - _state.update { - if (downloadState.resumablePercent != null) { + val localArm64 = apkManager.getCachedApkInfo() + ?.takeIf { it.variant == ShareableApkVariant.ARM64 } + if (localArm64 != null) { + _state.update { it.copy( - apkStatus = ApkPreparationStatus.Resumable( - progressPercent = downloadState.resumablePercent, - message = downloadState.message - ), - downloadProgress = downloadState.resumablePercent + apkStatus = ApkPreparationStatus.Ready( + version = localArm64.version, + sizeMB = (localArm64.size / 1024 / 1024).toInt(), + source = localArm64.source, + variant = localArm64.variant + ) ) - } else { - it.copy(apkStatus = ApkPreparationStatus.Error(downloadState.message)) + } + _effect.send(ApkUiEffect.ShowToast(downloadState.message)) + } else { + _state.update { + if (downloadState.resumablePercent != null) { + it.copy( + apkStatus = ApkPreparationStatus.Resumable( + progressPercent = downloadState.resumablePercent, + message = downloadState.message + ), + downloadProgress = downloadState.resumablePercent + ) + } else { + it.copy( + apkStatus = ApkPreparationStatus.Error(downloadState.message) + ) + } } } } @@ -295,7 +329,8 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat ApkPreparationStatus.Ready( version = info.version, sizeMB = (info.size / 1024 / 1024).toInt(), - source = info.source + source = info.source, + variant = info.variant ) } else { ApkPreparationStatus.Error("Cached APK info not found") @@ -316,7 +351,8 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat ApkPreparationStatus.Ready( version = info.version, sizeMB = (info.size / 1024 / 1024).toInt(), - source = info.source + source = info.source, + variant = info.variant ) } else { val partial = apkManager.getPartialDownloadProgress() diff --git a/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt b/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt index 87fe3fb7..82d51033 100644 --- a/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt +++ b/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt @@ -31,18 +31,21 @@ object DistributionInfoProvider { val splitApks = applicationInfo.splitSourceDirs.orEmpty() val installerPackage = installerPackageName(context) val certificateSha256 = signingCertificateSha256(packageInfo) - val installedApkCanBeSharedUniversally = splitApks.isEmpty() && - isUniversalApk(File(applicationInfo.sourceDir)) + val installedApkVariant = if (splitApks.isEmpty()) { + shareableApkVariant(File(applicationInfo.sourceDir)) + } else { + null + } return DistributionInfo( installSource = installSourceLabel(installerPackage), installerPackage = installerPackage, packageFormat = if (splitApks.isEmpty()) "Standalone APK" else "Split APK set", architecture = architectureLabel(applicationInfo.sourceDir, splitApks), - sharingSource = if (installedApkCanBeSharedUniversally) { - "Current installed APK" - } else { - "Verified GitHub universal APK" + sharingSource = when (installedApkVariant) { + ShareableApkVariant.UNIVERSAL -> "Current installed APK" + ShareableApkVariant.ARM64 -> "Current installed APK (ARM64)" + null -> "Verified GitHub universal APK" }, versionName = packageInfo.versionName ?: BuildConfig.VERSION_NAME, versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -112,6 +115,21 @@ object DistributionInfoProvider { return packagedAbis.isEmpty() || packagedAbis.containsAll(UNIVERSAL_RELEASE_ABIS) } + /** + * Returns the compatibility of an APK that is safe to offer for sharing. + * ARM64 is intentionally the only architecture-limited release variant + * supported because it is the project's primary per-ABI build. + */ + fun shareableApkVariant(apk: File): ShareableApkVariant? { + val packagedAbis = nativeAbisInApk(apk) + return when { + packagedAbis.isEmpty() || packagedAbis.containsAll(UNIVERSAL_RELEASE_ABIS) -> + ShareableApkVariant.UNIVERSAL + packagedAbis == setOf("arm64-v8a") -> ShareableApkVariant.ARM64 + else -> null + } + } + internal fun nativeAbisInApk(apk: File): Set { if (!apk.isFile) return emptySet() return try { @@ -189,3 +207,8 @@ object DistributionInfoProvider { val certificateSha256: String? ) } + +enum class ShareableApkVariant { + UNIVERSAL, + ARM64 +} diff --git a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt index ab1e97f1..1427e0e0 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -24,7 +24,7 @@ import java.nio.file.StandardCopyOption import java.security.MessageDigest /** - * Manages downloading, caching, and verifying the universal APK for offline sharing. + * Manages local and downloaded APK artifacts for offline sharing. */ class UniversalApkManager(private val context: Context) { @@ -54,7 +54,7 @@ class UniversalApkManager(private val context: Context) { .build() /** - * Get information about the cached universal APK, if it exists. + * Get information about the cached sharing APK, if it exists. */ fun getCachedApkInfo(): ApkInfo? { return try { @@ -81,6 +81,13 @@ class UniversalApkManager(private val context: Context) { Log.w(TAG, "Metadata exists but APK file not found: ${apkFile.path}") return null } + val variant = runCatching { + ShareableApkVariant.valueOf(json.optString("variant")) + }.getOrNull() ?: DistributionInfoProvider.shareableApkVariant(apkFile) + if (variant == null) { + Log.w(TAG, "Cached APK is not a supported sharing variant") + return null + } ApkInfo( version = version, @@ -88,7 +95,8 @@ class UniversalApkManager(private val context: Context) { downloadDate = downloadDate, size = size, file = apkFile, - source = source + source = source, + variant = variant ) } catch (e: Exception) { Log.e(TAG, "Error reading cached APK info", e) @@ -125,11 +133,10 @@ class UniversalApkManager(private val context: Context) { */ suspend fun checkForUpdate(): UpdateStatus = withContext(Dispatchers.IO) { try { - // A genuinely universal standalone APK is already an installable - // sharing artifact. Architecture-specific standalone APKs and split - // installs still need the universal GitHub artifact. + // A supported standalone APK is already an installable sharing + // artifact. Split installs still need the universal GitHub artifact. val installedApkInfo = cacheInstalledApkIfPreferred() - if (installedApkInfo != null) { + if (installedApkInfo?.source == ApkSource.INSTALLED) { return@withContext UpdateStatus.UpToDate(installedApkInfo.version) } @@ -330,7 +337,8 @@ class UniversalApkManager(private val context: Context) { checksum = release.universalApkSha256 ?: "", size = finalFile.length(), fileName = finalFileName, - source = ApkSource.GITHUB + source = ApkSource.GITHUB, + variant = ShareableApkVariant.UNIVERSAL ) cleanupOldApks(except = finalFile) @@ -467,9 +475,8 @@ class UniversalApkManager(private val context: Context) { } /** - * Cache the APK this process was installed from only when it is both - * standalone and universal. A base APK from a split install is incomplete, - * while an ABI-specific APK would unnecessarily limit recipients. + * Cache the APK this process was installed from when it is a standalone + * universal or ARM64 artifact. A base APK from a split install is incomplete. */ private fun cacheInstalledApkIfPreferred(): ApkInfo? { return try { @@ -482,15 +489,25 @@ class UniversalApkManager(private val context: Context) { if (!installedApk.isFile || installedApk.length() <= 0L) { return null } - if (!DistributionInfoProvider.isUniversalApk(installedApk)) { - Log.d(TAG, "Installed APK is architecture-specific; using GitHub universal APK") - discardArchitectureLimitedInstalledCache() + val installedVariant = DistributionInfoProvider.shareableApkVariant(installedApk) + if (installedVariant == null) { + Log.d(TAG, "Installed APK is not a supported sharing variant") return null } val installedVersion = installedVersionName() val cachedInfo = getCachedApkInfo() + // Downloading the universal release is an explicit compatibility + // choice. Keep it even when the running ARM64 build is newer; the + // user can delete it from the UI to return to the local artifact. + if (installedVariant == ShareableApkVariant.ARM64 && + cachedInfo?.source == ApkSource.GITHUB && + cachedInfo.variant == ShareableApkVariant.UNIVERSAL + ) { + return cachedInfo + } + // Keep an already cached artifact if it is the same version or // newer. Otherwise prefer the running build so sharing cannot // silently downgrade recipients to an older GitHub release. @@ -502,7 +519,11 @@ class UniversalApkManager(private val context: Context) { checkDiskSpace(installedApk.length()) val safeVersion = installedVersion.replace(Regex("[^A-Za-z0-9._-]"), "_") - val finalFileName = "$APK_FILE_PREFIX$safeVersion.apk" + val variantSuffix = when (installedVariant) { + ShareableApkVariant.UNIVERSAL -> "" + ShareableApkVariant.ARM64 -> "-arm64-v8a" + } + val finalFileName = "$APK_FILE_PREFIX$safeVersion$variantSuffix.apk" val finalFile = File(cacheDir, finalFileName) val pendingFile = File(cacheDir, "$finalFileName.new") @@ -519,7 +540,8 @@ class UniversalApkManager(private val context: Context) { checksum = checksum, size = finalFile.length(), fileName = finalFileName, - source = ApkSource.INSTALLED + source = ApkSource.INSTALLED, + variant = installedVariant ) cleanupOldApks(except = finalFile) @@ -531,19 +553,6 @@ class UniversalApkManager(private val context: Context) { } } - private fun discardArchitectureLimitedInstalledCache() { - val cachedInfo = getCachedApkInfo() ?: return - if (cachedInfo.source != ApkSource.INSTALLED || - DistributionInfoProvider.isUniversalApk(cachedInfo.file) - ) { - return - } - - cachedInfo.file.delete() - metadataFile.delete() - Log.d(TAG, "Removed architecture-specific installed APK from universal sharing cache") - } - private fun installedVersionName(): String { return context.packageManager .getPackageInfo(context.packageName, 0) @@ -729,7 +738,8 @@ class UniversalApkManager(private val context: Context) { checksum: String, size: Long, fileName: String, - source: ApkSource + source: ApkSource, + variant: ShareableApkVariant ) { val json = JSONObject().apply { put("version", version) @@ -738,6 +748,7 @@ class UniversalApkManager(private val context: Context) { put("size", size) put("fileName", fileName) put("source", source.name) + put("variant", variant.name) } val pendingMetadata = File(cacheDir, "$METADATA_FILE_NAME.new") @@ -802,7 +813,8 @@ class UniversalApkManager(private val context: Context) { val downloadDate: Long, val size: Long, val file: File, - val source: ApkSource + val source: ApkSource, + val variant: ShareableApkVariant ) enum class ApkSource { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a12482ca..c54a2273 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -192,7 +192,9 @@ Not ready • Tap to download Ready to share Sharing source: this installed APK + Sharing source: this installed APK • ARM64 devices only Sharing source: verified GitHub universal APK + Get universal Downloading… %1$d%% Update available Prepare diff --git a/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt b/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt index c30054fa..c9efe3d6 100644 --- a/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt @@ -1,6 +1,8 @@ package com.bitchat.android.util import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -22,6 +24,10 @@ class DistributionInfoProviderTest { val apk = createApk("lib/arm64-v8a/libbitchat.so") assertFalse(DistributionInfoProvider.isUniversalApk(apk)) + assertEquals( + ShareableApkVariant.ARM64, + DistributionInfoProvider.shareableApkVariant(apk) + ) } @Test @@ -34,6 +40,10 @@ class DistributionInfoProviderTest { ) assertTrue(DistributionInfoProvider.isUniversalApk(apk)) + assertEquals( + ShareableApkVariant.UNIVERSAL, + DistributionInfoProvider.shareableApkVariant(apk) + ) } @Test @@ -41,6 +51,17 @@ class DistributionInfoProviderTest { val apk = createApk("classes.dex") assertTrue(DistributionInfoProvider.isUniversalApk(apk)) + assertEquals( + ShareableApkVariant.UNIVERSAL, + DistributionInfoProvider.shareableApkVariant(apk) + ) + } + + @Test + fun `other architecture-only APK is not offered for sharing`() { + val apk = createApk("lib/x86_64/libbitchat.so") + + assertNull(DistributionInfoProvider.shareableApkVariant(apk)) } private fun createApk(vararg entries: String): File {