This commit is contained in:
hafilrazz 2026-07-28 23:50:36 +05:30
commit 8971c538e9
6 changed files with 183 additions and 61 deletions

View File

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

View File

@ -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()

View File

@ -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<String> {
if (!apk.isFile) return emptySet()
return try {
@ -189,3 +207,8 @@ object DistributionInfoProvider {
val certificateSha256: String?
)
}
enum class ShareableApkVariant {
UNIVERSAL,
ARM64
}

View File

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

View File

@ -192,7 +192,9 @@
<string name="prepare_apk_status_not_downloaded">Not ready • Tap to download</string>
<string name="prepare_apk_status_ready">Ready to share</string>
<string name="prepare_apk_source_installed" translatable="false">Sharing source: this installed APK</string>
<string name="prepare_apk_source_installed_arm64" translatable="false">Sharing source: this installed APK • ARM64 devices only</string>
<string name="prepare_apk_source_github" translatable="false">Sharing source: verified GitHub universal APK</string>
<string name="prepare_apk_get_universal" translatable="false">Get universal</string>
<string name="prepare_apk_status_downloading">Downloading… %1$d%%</string>
<string name="prepare_apk_status_update_available">Update available</string>
<string name="prepare_apk_button_prepare">Prepare</string>

View File

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