fix: harden offline APK sharing

This commit is contained in:
Moe Hamade 2026-07-24 19:19:28 +03:00
parent dec6aca465
commit e2759d78ac
18 changed files with 1127 additions and 218 deletions

View File

@ -5,6 +5,21 @@ plugins {
alias(libs.plugins.kotlin.compose)
}
val githubReleaseCertSha256 = providers
.environmentVariable("BITCHAT_GITHUB_RELEASE_CERT_SHA256")
.orElse(providers.gradleProperty("BITCHAT_GITHUB_RELEASE_CERT_SHA256"))
.orElse("")
val normalizedGithubReleaseCertSha256 = githubReleaseCertSha256.get()
.replace(":", "")
.trim()
.lowercase()
require(
normalizedGithubReleaseCertSha256.isEmpty() ||
normalizedGithubReleaseCertSha256.matches(Regex("[a-f0-9]{64}"))
) {
"BITCHAT_GITHUB_RELEASE_CERT_SHA256 must be a SHA-256 certificate fingerprint"
}
android {
namespace = "com.bitchat.android"
compileSdk = libs.versions.compileSdk.get().toInt()
@ -15,6 +30,11 @@ android {
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 36
versionName = "1.7.5"
buildConfigField(
"String",
"GITHUB_RELEASE_CERT_SHA256",
"\"$normalizedGithubReleaseCertSha256\""
)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -73,6 +93,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {

View File

@ -23,8 +23,10 @@ class ApkWebServer(
private val appVersion: String by lazy {
try {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
packageInfo.versionName ?: "Unknown"
context.packageManager
.getPackageArchiveInfo(apkFile.absolutePath, 0)
?.versionName
?: "Unknown"
} catch (e: Exception) {
"Unknown"
}

View File

@ -95,10 +95,6 @@ class HotspotActivity : ComponentActivity() {
}
}
override fun onDestroy() {
super.onDestroy()
viewModel.stopHotspot()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@ -296,11 +292,10 @@ fun IntroScreen(onStartHotspot: () -> Unit) {
shape = RoundedCornerShape(16.dp)
) {
Text(
text = if (permissionState != null && !permissionState.status.isGranted) {
"Grant Permission"
} else {
"Start Hotspot"
},
// Starting the hotspot is the user's action. Android will ask
// for the required permission only when it has not already
// been granted.
text = "Start Hotspot",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)

View File

@ -1,9 +1,12 @@
package com.bitchat.android.hotspot
import android.Manifest
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.wifi.p2p.WifiP2pConfig
import android.net.wifi.p2p.WifiP2pGroup
import android.net.wifi.p2p.WifiP2pManager
@ -13,6 +16,7 @@ import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import androidx.core.content.ContextCompat
import java.net.NetworkInterface
import java.security.SecureRandom
import kotlin.random.Random
@ -96,6 +100,15 @@ class HotspotManager(private val context: Context) {
return
}
val missingPermission = requiredRuntimePermission()?.takeUnless {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
if (missingPermission != null) {
Log.w(TAG, "Cannot start hotspot without $missingPermission")
callback.onError("Nearby Wi-Fi permission is required to start the hotspot")
return
}
this.callback = callback
isStarting = true
@ -215,43 +228,47 @@ class HotspotManager(private val context: Context) {
/**
* Create Wi-Fi P2P group.
*/
@SuppressLint("MissingPermission")
private fun createGroup(attempt: Int) {
val ch = channel ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10+: Custom SSID and password
val config = WifiP2pConfig.Builder()
.setNetworkName(savedSsid!!)
.setPassphrase(savedPassword!!)
.setGroupOperatingBand(WifiP2pConfig.GROUP_OWNER_BAND_2GHZ) // Force 2.4GHz for compatibility
.build()
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10+: Custom SSID and password
val config = WifiP2pConfig.Builder()
.setNetworkName(savedSsid!!)
.setPassphrase(savedPassword!!)
.setGroupOperatingBand(WifiP2pConfig.GROUP_OWNER_BAND_2GHZ) // Force 2.4GHz for compatibility
.build()
wifiP2pManager?.createGroup(ch, config, object : ActionListener {
override fun onSuccess() {
Log.d(TAG, "P2P group created successfully")
isStarting = false
// Don't call onHotspotStarted() yet - wait for group info
startGroupInfoPolling()
}
wifiP2pManager?.createGroup(ch, config, groupActionListener(attempt, ch))
} else {
// Android 9 and below: System-generated SSID/password
wifiP2pManager?.createGroup(ch, groupActionListener(attempt, ch))
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while creating the group", e)
failStartup("Nearby Wi-Fi permission was revoked. Grant it and try again.")
}
}
override fun onFailure(reason: Int) {
handleGroupCreationFailure(reason, attempt)
}
})
} else {
// Android 9 and below: System-generated SSID/password
wifiP2pManager?.createGroup(ch, object : ActionListener {
override fun onSuccess() {
Log.d(TAG, "P2P group created successfully")
isStarting = false
// Don't call onHotspotStarted() yet - wait for group info
startGroupInfoPolling()
}
private fun groupActionListener(attempt: Int, requestChannel: Channel) = object : ActionListener {
override fun onSuccess() {
if (channel !== requestChannel) {
Log.w(TAG, "Removing group created after hotspot was stopped")
wifiP2pManager?.removeGroup(requestChannel, null)
return
}
Log.d(TAG, "P2P group created successfully")
isStarting = false
// Don't call onHotspotStarted() yet - wait for group info
startGroupInfoPolling()
}
override fun onFailure(reason: Int) {
handleGroupCreationFailure(reason, attempt)
}
})
override fun onFailure(reason: Int) {
if (channel != null) {
handleGroupCreationFailure(reason, attempt)
}
}
}
@ -322,31 +339,47 @@ class HotspotManager(private val context: Context) {
/**
* Request current group information.
*/
@SuppressLint("MissingPermission")
private fun requestGroupInfo() {
val ch = channel ?: return
wifiP2pManager?.requestGroupInfo(ch) { group ->
if (group != null) {
currentGroup = group
try {
wifiP2pManager?.requestGroupInfo(ch) { group ->
if (group != null) {
currentGroup = group
// Update saved credentials if using system-generated ones
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
savedSsid = group.networkName
savedPassword = group.passphrase
}
// Update saved credentials if using system-generated ones
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
savedSsid = group.networkName
savedPassword = group.passphrase
}
// Notify callback on FIRST successful group info retrieval
if (!hasNotifiedStarted) {
hasNotifiedStarted = true
Log.d(TAG, "Group info received, notifying callback")
callback?.onHotspotStarted()
// Notify callback on FIRST successful group info retrieval
if (!hasNotifiedStarted) {
hasNotifiedStarted = true
Log.d(TAG, "Group info received, notifying callback")
callback?.onHotspotStarted()
} else {
// Subsequent updates
callback?.onConnectionInfoUpdated(getConnectionInfo())
}
} else {
// Subsequent updates
callback?.onConnectionInfoUpdated(getConnectionInfo())
Log.w(TAG, "requestGroupInfo returned null group")
}
} else {
Log.w(TAG, "requestGroupInfo returned null group")
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while reading group info", e)
failStartup("Nearby Wi-Fi permission was revoked. Grant it and try again.")
}
}
private fun requiredRuntimePermission(): String? {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ->
Manifest.permission.NEARBY_WIFI_DEVICES
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
Manifest.permission.ACCESS_FINE_LOCATION
else -> null
}
}

View File

@ -52,6 +52,7 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
// Get connection info
val info = manager.getConnectionInfo()
if (info == null) {
manager.stopHotspot()
_state.value = HotspotState.Error("Failed to get hotspot connection info")
return@launch
}
@ -100,6 +101,7 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
} catch (e: Exception) {
Log.e(TAG, "Error starting hotspot", e)
hotspotManager?.stopHotspot()
_state.value = HotspotState.Error(e.message ?: "Unknown error")
}
}

View File

@ -168,6 +168,27 @@ class ArtiTorManager private constructor() {
fun currentSocksAddress(): InetSocketAddress? = socksAddr
/**
* Wait until the currently selected HTTP route can be used.
*
* When Tor mode is enabled, [socksAddr] is intentionally published before
* bootstrap completes so clients fail closed instead of leaking traffic
* directly. Callers that initiate one-shot HTTP work should wait here rather
* than repeatedly connecting to a SOCKS port that is not listening yet.
*/
suspend fun awaitSelectedRoute(timeoutMs: Long): Boolean {
if (currentSocksAddress() == null || isProxyEnabled()) {
return true
}
return withTimeoutOrNull(timeoutMs) {
statusFlow.first {
currentSocksAddress() == null || isProxyEnabled()
}
true
} ?: false
}
suspend fun applyMode(application: Application, mode: TorMode) {
applyMutex.withLock {
try {

View File

@ -85,6 +85,7 @@ import com.bitchat.android.net.TorMode
import com.bitchat.android.net.TorPreferenceManager
import com.bitchat.android.nostr.NostrProofOfWork
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.util.UniversalApkManager
/**
* Feature row for displaying app capabilities
@ -554,7 +555,11 @@ fun AboutSheet(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.CloudDownload,
imageVector = if (apkStatus is ApkPreparationStatus.Ready) {
Icons.Default.Share
} else {
Icons.Default.CloudDownload
},
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(22.dp)
@ -567,7 +572,11 @@ fun AboutSheet(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.prepare_apk_title),
text = if (apkStatus is ApkPreparationStatus.Ready) {
stringResource(R.string.prepare_apk_ready_title)
} else {
stringResource(R.string.prepare_apk_title)
},
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
@ -576,7 +585,15 @@ fun AboutSheet(
text = when (val status = apkStatus) {
is ApkPreparationStatus.Loading -> stringResource(R.string.checking)
is ApkPreparationStatus.NotDownloaded -> stringResource(R.string.prepare_apk_status_not_downloaded)
is ApkPreparationStatus.Ready -> stringResource(R.string.prepare_apk_status_ready) + "${status.version}${status.sizeMB} MB"
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)
}
stringResource(R.string.prepare_apk_status_ready) +
"${status.version}${status.sizeMB} MB\n$source"
}
is ApkPreparationStatus.UpdateAvailable -> stringResource(R.string.prepare_apk_status_update_available) + " (${status.newVersion})"
is ApkPreparationStatus.Downloading -> stringResource(R.string.prepare_apk_status_downloading, downloadProgress)
is ApkPreparationStatus.Resumable -> "Tap to resume • ${status.progressPercent}% downloaded"
@ -601,7 +618,22 @@ fun AboutSheet(
strokeWidth = 2.dp
)
}
is ApkPreparationStatus.Ready, is ApkPreparationStatus.UpdateAvailable -> {
is ApkPreparationStatus.Ready -> {
if (apkStatus.source == UniversalApkManager.ApkSource.GITHUB) {
androidx.compose.material3.IconButton(
onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) },
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
tint = colorScheme.error,
modifier = Modifier.size(20.dp)
)
}
}
}
is ApkPreparationStatus.UpdateAvailable -> {
androidx.compose.material3.IconButton(
onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) },
modifier = Modifier.size(32.dp)
@ -621,10 +653,10 @@ fun AboutSheet(
// Prepare Dialog
if (apkUiState.showPrepareDialog) {
val status = apkStatus
val sizeMB = when (status) {
val sizeMB: Int? = when (status) {
is ApkPreparationStatus.NotDownloaded -> status.sizeMB
is ApkPreparationStatus.UpdateAvailable -> status.newSizeMB
else -> 47
else -> null
}
AlertDialog(
onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) },
@ -642,8 +674,10 @@ fun AboutSheet(
Text(
text = if (status is ApkPreparationStatus.UpdateAvailable) {
stringResource(R.string.prepare_apk_update_dialog_message, status.newVersion, status.currentVersion)
} else {
} else if (sizeMB != null) {
stringResource(R.string.prepare_apk_dialog_message, sizeMB)
} else {
stringResource(R.string.prepare_apk_dialog_message_unknown_size)
},
style = MaterialTheme.typography.bodyMedium
)
@ -1198,4 +1232,3 @@ private fun ApkShareExplanationDialog(
)
}
}

View File

@ -23,8 +23,12 @@ import kotlinx.coroutines.withContext
sealed class ApkPreparationStatus {
object Loading : ApkPreparationStatus()
data class NotDownloaded(val sizeMB: Int) : ApkPreparationStatus()
data class Ready(val version: String, val sizeMB: Int) : ApkPreparationStatus()
data class NotDownloaded(val sizeMB: Int?) : ApkPreparationStatus()
data class Ready(
val version: String,
val sizeMB: Int,
val source: UniversalApkManager.ApkSource
) : ApkPreparationStatus()
data class UpdateAvailable(
val currentVersion: String,
val newVersion: String,
@ -192,20 +196,21 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
private fun checkStatus() {
viewModelScope.launch {
val currentStatus = _state.value.apkStatus
// If we think we're downloading but cache was wiped (e.g., user cleared
// cache from Android Settings), cancel the orphaned work
if (currentStatus is ApkPreparationStatus.Downloading) {
if (apkManager.getPartialDownloadProgress() == null) {
downloader.cancelDownload()
// Fall through to resolve fresh status
} else {
return@launch
}
// WorkManager is the source of truth for active work. A queued or
// newly started job legitimately has no partial file yet, so never
// infer that it is orphaned from cache contents.
if (_state.value.apkStatus is ApkPreparationStatus.Downloading) {
return@launch
}
_state.update { it.copy(apkStatus = resolveApkStatus()) }
val resolvedStatus = resolveApkStatus()
_state.update { current ->
if (current.apkStatus is ApkPreparationStatus.Downloading) {
current
} else {
current.copy(apkStatus = resolvedStatus)
}
}
}
}
@ -225,11 +230,13 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
}
}
is ApkDownloader.DownloadState.Success -> {
val info = apkManager.getCachedApkInfo()
_state.update {
it.copy(
apkStatus = ApkPreparationStatus.Ready(
version = downloadState.version,
sizeMB = downloadState.sizeMB
sizeMB = downloadState.sizeMB,
source = info?.source ?: UniversalApkManager.ApkSource.GITHUB
),
downloadProgress = 100
)
@ -287,7 +294,8 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
if (info != null) {
ApkPreparationStatus.Ready(
version = info.version,
sizeMB = (info.size / 1024 / 1024).toInt()
sizeMB = (info.size / 1024 / 1024).toInt(),
source = info.source
)
} else {
ApkPreparationStatus.Error("Cached APK info not found")
@ -302,10 +310,11 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
}
is UniversalApkManager.UpdateStatus.Error -> {
val info = apkManager.getCachedApkInfo()
if (info != null) {
if (info != null && apkManager.isCompatibleWithInstalledVersion(info.version)) {
ApkPreparationStatus.Ready(
version = info.version,
sizeMB = (info.size / 1024 / 1024).toInt()
sizeMB = (info.size / 1024 / 1024).toInt(),
source = info.source
)
} else {
val partial = apkManager.getPartialDownloadProgress()
@ -315,18 +324,16 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
message = getString(R.string.prepare_apk_download_interrupted)
)
} else {
// Show as "not downloaded" so user can still tap to try.
// The actual download will re-fetch release info and fail
// with a clear message if network is still unavailable.
ApkPreparationStatus.NotDownloaded(sizeMB = 0)
ApkPreparationStatus.Error(updateStatus.message)
}
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error checking APK status", e)
// Don't show a scary error on initial load — let user try manually
ApkPreparationStatus.NotDownloaded(sizeMB = 0)
ApkPreparationStatus.Error(
e.message ?: getString(R.string.prepare_apk_error_github)
)
}
}
}
}

View File

@ -1,5 +1,8 @@
package com.bitchat.android.ui.debug
import android.content.ClipData
import android.content.ClipboardManager
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@ -40,6 +43,9 @@ import androidx.compose.ui.platform.LocalContext
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
import com.bitchat.android.util.DistributionInfoProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun MeshTopologySection(
@ -97,6 +103,95 @@ fun MeshTopologySection(
}
}
@Composable
private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionInfo?) {
val context = LocalContext.current
val colorScheme = MaterialTheme.colorScheme
Surface(
shape = RoundedCornerShape(12.dp),
color = colorScheme.surfaceVariant.copy(alpha = 0.2f)
) {
Column(
Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF5856D6))
Text(
"Distribution info",
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
}
if (info == null) {
Text(
"Inspecting installed package…",
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
} else {
DistributionInfoRow("Install source", info.installSource)
info.installerPackage?.let {
DistributionInfoRow("Installer package", it)
}
DistributionInfoRow("Package format", info.packageFormat)
DistributionInfoRow("APK architecture", info.architecture)
DistributionInfoRow("Sharing source", info.sharingSource)
DistributionInfoRow("Version", "${info.versionName} (${info.versionCode})")
DistributionInfoRow("Signing channel", info.signingChannel)
DistributionInfoRow(
label = "Certificate SHA-256",
value = info.certificateSha256 ?: "Unavailable"
)
if (info.certificateSha256 != null) {
TextButton(
onClick = {
val clipboard = context.getSystemService(ClipboardManager::class.java)
clipboard?.setPrimaryClip(
ClipData.newPlainText(
"BitChat signing certificate SHA-256",
info.certificateSha256
)
)
Toast.makeText(context, "Certificate fingerprint copied", Toast.LENGTH_SHORT).show()
},
contentPadding = PaddingValues(horizontal = 0.dp)
) {
Text("Copy certificate fingerprint", fontFamily = FontFamily.Monospace)
}
}
}
}
}
}
@Composable
private fun DistributionInfoRow(label: String, value: String) {
val colorScheme = MaterialTheme.colorScheme
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
label,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.55f)
)
Text(
value,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.9f)
)
}
}
private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@ -124,6 +219,9 @@ fun DebugSettingsSheet(
val gcsMaxBytes by manager.gcsMaxBytes.collectAsState()
val gcsFpr by manager.gcsFprPercent.collectAsState()
val context = LocalContext.current
var distributionInfo by remember {
mutableStateOf<DistributionInfoProvider.DistributionInfo?>(null)
}
val bleEnabled by manager.bleEnabled.collectAsState()
val wifiAwareEnabled by manager.wifiAwareEnabled.collectAsState()
@ -181,6 +279,14 @@ fun DebugSettingsSheet(
}
}
LaunchedEffect(isPresented) {
if (isPresented) {
distributionInfo = withContext(Dispatchers.IO) {
runCatching { DistributionInfoProvider.inspect(context) }.getOrNull()
}
}
}
val scope = rememberCoroutineScope()
if (!isPresented) return
@ -210,6 +316,9 @@ fun DebugSettingsSheet(
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
}
item {
DistributionInfoSection(distributionInfo)
}
// Verbose logging toggle
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {

View File

@ -51,7 +51,12 @@ class ApkDownloadWorker(
// Retry transient network errors with backoff; the partial file
// is kept on disk, so the retry resumes where it left off.
if (error is java.io.IOException && runAttemptCount < MAX_RETRIES) {
val isRetryable = when (error) {
is GitHubReleaseClient.ReleaseFetchException -> error.retryable
is java.io.IOException -> true
else -> false
}
if (isRetryable && runAttemptCount < MAX_RETRIES) {
Log.w(TAG, "Transient download error (attempt $runAttemptCount), retrying", error)
return Result.retry()
}
@ -64,4 +69,4 @@ class ApkDownloadWorker(
Result.failure(outputData)
}
}
}
}

View File

@ -0,0 +1,191 @@
package com.bitchat.android.util
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import com.bitchat.android.BuildConfig
import java.io.File
import java.security.MessageDigest
import java.util.zip.ZipFile
/**
* Read-only diagnostics describing how the currently running app was packaged
* and installed. These values are facts about the installed artifact, not
* settings that can be changed at runtime.
*/
object DistributionInfoProvider {
private val UNIVERSAL_RELEASE_ABIS = setOf(
"arm64-v8a",
"armeabi-v7a",
"x86_64",
"x86"
)
fun inspect(context: Context): DistributionInfo {
val packageInfo = context.packageManager.getPackageInfo(
context.packageName,
signingFlags()
)
val applicationInfo = context.applicationInfo
val splitApks = applicationInfo.splitSourceDirs.orEmpty()
val installerPackage = installerPackageName(context)
val certificateSha256 = signingCertificateSha256(packageInfo)
val installedApkCanBeSharedUniversally = splitApks.isEmpty() &&
isUniversalApk(File(applicationInfo.sourceDir))
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"
},
versionName = packageInfo.versionName ?: BuildConfig.VERSION_NAME,
versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode
} else {
@Suppress("DEPRECATION")
packageInfo.versionCode.toLong()
},
signingChannel = signingChannel(installerPackage, certificateSha256),
certificateSha256 = certificateSha256
)
}
private fun installerPackageName(context: Context): String? {
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.packageManager
.getInstallSourceInfo(context.packageName)
.installingPackageName
} else {
@Suppress("DEPRECATION")
context.packageManager.getInstallerPackageName(context.packageName)
}
} catch (_: Exception) {
null
}
}
private fun installSourceLabel(installerPackage: String?): String {
return when (installerPackage) {
"com.android.vending" -> "Google Play"
"com.amazon.venezia" -> "Amazon Appstore"
"org.fdroid.fdroid" -> "F-Droid"
"com.android.packageinstaller",
"com.google.android.packageinstaller",
"com.android.permissioncontroller" -> "Android package installer"
null -> if (BuildConfig.DEBUG) "ADB / local install" else "Unknown / local install"
else -> installerPackage
}
}
private fun architectureLabel(baseApkPath: String, splitApkPaths: Array<out String>): String {
val apkPaths = listOf(baseApkPath) + splitApkPaths
val packagedAbis = buildSet {
apkPaths.forEach { path ->
addAll(nativeAbisInApk(File(path)))
addAll(abisInSplitName(File(path).name))
}
}
return when {
packagedAbis.containsAll(UNIVERSAL_RELEASE_ABIS) ->
"Universal (${packagedAbis.joinToString()})"
packagedAbis.size > 1 -> "Multi-ABI (${packagedAbis.joinToString()})"
packagedAbis.size == 1 -> packagedAbis.single()
splitApkPaths.isNotEmpty() -> "Device ABI (${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"})"
else -> "Universal (no native ABI payload)"
}
}
/**
* An APK with no native payload works across ABIs. When native libraries
* are present, require every ABI produced by the release workflow.
*/
fun isUniversalApk(apk: File): Boolean {
val packagedAbis = nativeAbisInApk(apk)
return packagedAbis.isEmpty() || packagedAbis.containsAll(UNIVERSAL_RELEASE_ABIS)
}
internal fun nativeAbisInApk(apk: File): Set<String> {
if (!apk.isFile) return emptySet()
return try {
ZipFile(apk).use { zip ->
buildSet {
val entries = zip.entries()
while (entries.hasMoreElements()) {
val path = entries.nextElement().name
if (path.startsWith("lib/")) {
path.split('/').getOrNull(1)
?.takeIf { it.isNotBlank() }
?.let(::add)
}
}
}
}
} catch (_: Exception) {
emptySet()
}
}
private fun abisInSplitName(fileName: String): Set<String> {
val normalizedName = fileName.replace('_', '-')
return Build.SUPPORTED_ABIS
.filter { abi -> normalizedName.contains(abi.replace('_', '-'), ignoreCase = true) }
.toSet()
}
private fun signingChannel(installerPackage: String?, certificateSha256: String?): String {
if (BuildConfig.DEBUG) return "Debug"
if (installerPackage == "com.android.vending") return "Google Play"
val pinnedGitHubCert = BuildConfig.GITHUB_RELEASE_CERT_SHA256
.replace(":", "")
.lowercase()
.takeIf { it.matches(Regex("[a-f0-9]{64}")) }
return if (certificateSha256 != null && certificateSha256 == pinnedGitHubCert) {
"GitHub release"
} else {
"Release / unknown channel"
}
}
private fun signingCertificateSha256(packageInfo: PackageInfo): String? {
val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.signingInfo?.apkContentsSigners
} else {
@Suppress("DEPRECATION")
packageInfo.signatures
}
val signature = signatures?.firstOrNull() ?: return null
return MessageDigest.getInstance("SHA-256")
.digest(signature.toByteArray())
.joinToString("") { "%02x".format(it) }
}
private fun signingFlags(): Int {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
PackageManager.GET_SIGNING_CERTIFICATES
} else {
@Suppress("DEPRECATION")
PackageManager.GET_SIGNATURES
}
}
data class DistributionInfo(
val installSource: String,
val installerPackage: String?,
val packageFormat: String,
val architecture: String,
val sharingSource: String,
val versionName: String,
val versionCode: Long,
val signingChannel: String,
val certificateSha256: String?
)
}

View File

@ -1,12 +1,17 @@
package com.bitchat.android.util
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.OkHttpProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.Request
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Client for fetching BitChat release information from GitHub API.
@ -15,50 +20,156 @@ object GitHubReleaseClient {
private const val TAG = "GitHubAPI"
private const val GITHUB_API_URL = "https://api.github.com/repos/permissionlesstech/bitchat-android/releases/latest"
private const val USER_AGENT = "BitChat-Android"
private const val CACHE_TTL_MILLIS = 10 * 60 * 1000L
private const val MAX_FETCH_ATTEMPTS = 3
private const val ROUTE_READY_TIMEOUT_MILLIS = 60_000L
private val client get() = OkHttpProvider.httpClient()
private val fetchMutex = Mutex()
@Volatile
private var cachedRelease: CachedRelease? = null
private val client
get() = OkHttpProvider.httpClient().newBuilder()
// GitHub requests may travel through Tor, where a 15-second total
// timeout is too aggressive during circuit establishment.
.callTimeout(45, TimeUnit.SECONDS)
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/**
* Fetch the latest release information from GitHub.
* @return Release object with details, or null if fetch fails
* Successful metadata is cached briefly so the status screen and download
* worker use the same release snapshot instead of making duplicate calls.
*/
suspend fun fetchLatestRelease(): Release? = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Fetching latest release from GitHub API")
suspend fun fetchLatestRelease(forceRefresh: Boolean = false): Result<Release> =
withContext(Dispatchers.IO) {
fetchMutex.withLock {
if (!forceRefresh) {
cachedRelease
?.takeIf { System.currentTimeMillis() - it.fetchedAtMillis < CACHE_TTL_MILLIS }
?.let { return@withLock Result.success(it.release) }
}
if (!awaitSelectedNetworkRoute()) {
return@withLock Result.failure(
ReleaseFetchException(
message = "Tor is still connecting. Try again when Tor is ready.",
retryable = true
)
)
}
var lastFailure: Throwable = ReleaseFetchException(
"Failed to fetch the latest release from GitHub"
)
repeat(MAX_FETCH_ATTEMPTS) { attempt ->
val result = fetchLatestReleaseOnce()
result.onSuccess { release ->
cachedRelease = CachedRelease(release, System.currentTimeMillis())
return@withLock Result.success(release)
}
lastFailure = result.exceptionOrNull() ?: lastFailure
if (!isRetryable(lastFailure) || attempt == MAX_FETCH_ATTEMPTS - 1) {
return@withLock Result.failure(lastFailure)
}
delay(1_000L shl attempt)
}
Result.failure(lastFailure)
}
}
/**
* Wait for Tor when it is the selected route. This deliberately does not
* fall back to a direct connection because doing so would violate the
* user's Tor preference.
*/
suspend fun awaitSelectedNetworkRoute(): Boolean {
return ArtiTorManager.getInstance()
.awaitSelectedRoute(ROUTE_READY_TIMEOUT_MILLIS)
}
private fun fetchLatestReleaseOnce(): Result<Release> {
return try {
Log.d(TAG, "Fetching latest release from GitHub API")
val request = Request.Builder()
.url(GITHUB_API_URL)
.addHeader("User-Agent", USER_AGENT)
.addHeader("Accept", "application/vnd.github.v3+json")
.addHeader("Accept", "application/vnd.github+json")
.addHeader("X-GitHub-Api-Version", "2022-11-28")
.build()
val response = client.newCall(request).execute()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
val remaining = response.header("X-RateLimit-Remaining")
val resetAt = response.header("X-RateLimit-Reset")
val message = when {
response.code == 403 && remaining == "0" ->
"GitHub API rate limit exceeded. Try again after reset time $resetAt."
response.code == 429 ->
"GitHub API rate limit exceeded. Please try again later."
else ->
"GitHub release request failed: HTTP ${response.code} ${response.message}"
}
Log.e(TAG, message)
return Result.failure(
ReleaseFetchException(
message = message,
httpCode = response.code,
retryable = response.code == 403 ||
response.code == 408 ||
response.code == 429 ||
response.code >= 500
)
)
}
if (!response.isSuccessful) {
Log.e(TAG, "GitHub API request failed: ${response.code} ${response.message}")
return@withContext null
val body = response.body?.string()
if (body.isNullOrBlank()) {
return Result.failure(
ReleaseFetchException(
message = "GitHub returned an empty response",
retryable = true
)
)
}
val release = parseRelease(body)
?: return Result.failure(
ReleaseFetchException(
message = "GitHub's latest release has no universal APK asset",
retryable = false
)
)
Result.success(release)
}
val body = response.body?.string()
if (body.isNullOrBlank()) {
Log.e(TAG, "Empty response body from GitHub API")
return@withContext null
}
parseRelease(body)
} catch (e: IOException) {
Log.e(TAG, "Network error fetching release", e)
null
Result.failure(
ReleaseFetchException(
"Could not reach GitHub${e.message?.let { ": $it" } ?: ""}",
cause = e
)
)
} catch (e: Exception) {
Log.e(TAG, "Error fetching release", e)
null
Result.failure(ReleaseFetchException("Invalid GitHub release response", cause = e))
}
}
private fun isRetryable(error: Throwable): Boolean {
return error !is ReleaseFetchException || error.retryable
}
/**
* Parse GitHub API JSON response into Release object.
*/
private fun parseRelease(jsonString: String): Release? {
internal fun parseRelease(jsonString: String): Release? {
try {
val json = JSONObject(jsonString)
val tagName = json.optString("tag_name", "")
@ -92,9 +203,15 @@ object GitHubReleaseClient {
continue
}
// Try to extract SHA256 from release body or notes
// Prefer GitHub's asset digest when available, then fall
// back to release notes used by older releases.
val body = json.optString("body", "")
val sha256 = extractSha256FromBody(body, name)
val assetDigest = asset.optString("digest", "")
.takeIf { it.startsWith("sha256:", ignoreCase = true) }
?.substringAfter(":")
?.takeIf { it.matches(Regex("[a-fA-F0-9]{64}")) }
?.lowercase()
val sha256 = assetDigest ?: extractSha256FromBody(body, name)
Log.d(TAG, "Found universal APK: $name (${size / 1024 / 1024}MB)")
@ -157,11 +274,15 @@ object GitHubReleaseClient {
* @return true if latestRelease is newer
*/
fun isNewerVersion(currentVersion: String, latestRelease: Release): Boolean {
return isNewerVersion(currentVersion, latestRelease.versionName)
}
internal fun isNewerVersion(currentVersion: String, candidateVersion: String): Boolean {
return try {
// Simple version comparison (assumes semantic versioning)
// Remove any non-numeric prefixes
val current = currentVersion.removePrefix("v").trim()
val latest = latestRelease.versionName.removePrefix("v").trim()
val latest = candidateVersion.removePrefix("v").trim()
if (current == latest) {
return false
@ -202,4 +323,16 @@ object GitHubReleaseClient {
val universalApkSize: Long,
val universalApkName: String
)
class ReleaseFetchException(
message: String,
val httpCode: Int? = null,
val retryable: Boolean = true,
cause: Throwable? = null
) : IOException(message, cause)
private data class CachedRelease(
val release: Release,
val fetchedAtMillis: Long
)
}

View File

@ -4,6 +4,7 @@ import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import com.bitchat.android.BuildConfig
import com.bitchat.android.net.OkHttpProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -12,6 +13,9 @@ import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
/**
@ -38,12 +42,11 @@ class UniversalApkManager(private val context: Context) {
// Download client: inherits Tor proxy settings but with no call timeout
// for large file downloads that can take minutes
private val downloadClient by lazy {
OkHttpProvider.httpClient().newBuilder()
private val downloadClient
get() = OkHttpProvider.httpClient().newBuilder()
.callTimeout(0, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
.build()
}
/**
* Get information about the cached universal APK, if it exists.
@ -60,6 +63,9 @@ class UniversalApkManager(private val context: Context) {
val downloadDate = json.optLong("downloadDate", 0L)
val size = json.optLong("size", 0L)
val fileName = json.optString("fileName", "")
val source = runCatching {
ApkSource.valueOf(json.optString("source", ApkSource.GITHUB.name))
}.getOrDefault(ApkSource.GITHUB)
if (version.isBlank() || fileName.isBlank()) {
return null
@ -76,7 +82,8 @@ class UniversalApkManager(private val context: Context) {
checksum = checksum,
downloadDate = downloadDate,
size = size,
file = apkFile
file = apkFile,
source = source
)
} catch (e: Exception) {
Log.e(TAG, "Error reading cached APK info", e)
@ -113,11 +120,33 @@ class UniversalApkManager(private val context: Context) {
*/
suspend fun checkForUpdate(): UpdateStatus = withContext(Dispatchers.IO) {
try {
val cachedInfo = getCachedApkInfo()
val latestRelease = GitHubReleaseClient.fetchLatestRelease()
// A genuinely universal standalone APK is already an installable
// sharing artifact. Architecture-specific standalone APKs and split
// installs still need the universal GitHub artifact.
val installedApkInfo = cacheInstalledApkIfPreferred()
if (installedApkInfo != null) {
return@withContext UpdateStatus.UpToDate(installedApkInfo.version)
}
if (latestRelease == null) {
return@withContext UpdateStatus.Error("Failed to fetch latest release from GitHub")
val cachedInfo = getCachedApkInfo()
val cachedApkIsOlder = cachedInfo?.let {
isOlderThanInstalledVersion(it.version)
} == true
val latestRelease = GitHubReleaseClient.fetchLatestRelease().getOrElse { error ->
return@withContext UpdateStatus.Error(
if (cachedApkIsOlder) {
"Cached sharing APK ${cachedInfo?.version} is older than installed app " +
"${installedVersionName()}, and a newer GitHub release could not be checked."
} else {
error.message ?: "Failed to fetch latest release from GitHub"
}
)
}
if (isOlderThanInstalledVersion(latestRelease.versionName)) {
return@withContext UpdateStatus.Error(
"GitHub universal APK ${latestRelease.versionName} is older than installed app " +
"${installedVersionName()}. Wait for the matching GitHub release."
)
}
if (cachedInfo == null) {
@ -173,8 +202,27 @@ class UniversalApkManager(private val context: Context) {
Log.d(TAG, "Starting universal APK download")
// Fetch latest release info
val release = GitHubReleaseClient.fetchLatestRelease()
?: return@withContext Result.failure(Exception("Failed to fetch release info"))
// Reuses the short-lived release metadata cache populated by the
// status check. If this worker is running after process death, the
// client performs a retried network fetch instead.
val release = GitHubReleaseClient.fetchLatestRelease().getOrElse { error ->
return@withContext Result.failure(error)
}
if (isOlderThanInstalledVersion(release.versionName)) {
return@withContext Result.failure(
GitHubReleaseClient.ReleaseFetchException(
message = "GitHub universal APK ${release.versionName} is older than " +
"installed app ${installedVersionName()}",
retryable = false
)
)
}
if (!GitHubReleaseClient.awaitSelectedNetworkRoute()) {
return@withContext Result.failure(
IOException("Tor is still connecting. Try the download again when Tor is ready.")
)
}
val url = release.universalApkUrl
val expectedSize = release.universalApkSize
@ -284,36 +332,34 @@ class UniversalApkManager(private val context: Context) {
Log.w(TAG, "No checksum available for verification")
}
// Verify the downloaded APK is signed with the same certificate as this app
// Verify the downloaded APK against trusted signing certificates.
Log.d(TAG, "Verifying APK signature...")
if (!verifyApkSignature(tempFile)) {
tempFile.delete()
progressFile.delete()
return@withContext Result.failure(
Exception("APK signature verification failed. The downloaded APK is not signed with the same key as this app.")
Exception("APK signature verification failed. The downloaded APK is not signed by a trusted BitChat release key.")
)
}
Log.d(TAG, "Signature verified successfully")
// Move to final location
if (!DistributionInfoProvider.isUniversalApk(tempFile)) {
tempFile.delete()
progressFile.delete()
return@withContext Result.failure(
Exception(
"GitHub asset is architecture-specific, not universal. " +
"Release packaging must be corrected."
)
)
}
// Move to final location without deleting the currently usable APK
// first. Old versions are removed only after the replacement and
// metadata have both been committed.
val finalFileName = "$APK_FILE_PREFIX${release.versionName}.apk"
val finalFile = File(cacheDir, finalFileName)
// Clean up old APK files
cleanupOldApks()
// Move temp file to final location
if (finalFile.exists()) {
finalFile.delete()
}
// Try rename first (fast), fallback to copy if it fails (different partitions/filesystems)
val moved = tempFile.renameTo(finalFile)
if (!moved) {
Log.w(TAG, "Rename failed, falling back to copy")
tempFile.copyTo(finalFile, overwrite = true)
tempFile.delete()
}
replaceFileSafely(tempFile, finalFile)
// Clean up resume metadata on success
progressFile.delete()
@ -323,8 +369,10 @@ class UniversalApkManager(private val context: Context) {
version = release.versionName,
checksum = release.universalApkSha256 ?: "",
size = finalFile.length(),
fileName = finalFileName
fileName = finalFileName,
source = ApkSource.GITHUB
)
cleanupOldApks(except = finalFile)
Log.d(TAG, "Universal APK downloaded successfully: ${finalFile.path}")
Result.success(finalFile)
@ -339,28 +387,107 @@ class UniversalApkManager(private val context: Context) {
}
/**
* Verify the downloaded APK is signed with the same certificate as the running app.
* No hardcoded fingerprint needed: if the certs match, receivers of the shared APK
* end up in the same signature lineage as this installation.
*
* Debug-signed installations (Android Debug keystore) skip enforcement so the
* feature stays testable during development.
* 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.
*/
private fun cacheInstalledApkIfPreferred(): ApkInfo? {
return try {
val applicationInfo = context.applicationInfo
if (!applicationInfo.splitSourceDirs.isNullOrEmpty()) {
return null
}
val installedApk = File(applicationInfo.sourceDir)
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()
return null
}
val installedVersion = installedVersionName()
val cachedInfo = getCachedApkInfo()
// 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.
if (cachedInfo != null &&
!GitHubReleaseClient.isNewerVersion(cachedInfo.version, installedVersion)
) {
return cachedInfo
}
checkDiskSpace(installedApk.length())
val safeVersion = installedVersion.replace(Regex("[^A-Za-z0-9._-]"), "_")
val finalFileName = "$APK_FILE_PREFIX$safeVersion.apk"
val finalFile = File(cacheDir, finalFileName)
val pendingFile = File(cacheDir, "$finalFileName.new")
installedApk.inputStream().use { input ->
FileOutputStream(pendingFile).use { output ->
input.copyTo(output, BUFFER_SIZE)
}
}
replaceFileSafely(pendingFile, finalFile)
val checksum = calculateChecksum(finalFile)
saveMetadata(
version = installedVersion,
checksum = checksum,
size = finalFile.length(),
fileName = finalFileName,
source = ApkSource.INSTALLED
)
cleanupOldApks(except = finalFile)
Log.d(TAG, "Cached running standalone APK for offline sharing")
getCachedApkInfo()
} catch (e: Exception) {
Log.w(TAG, "Running APK cannot be used as a standalone sharing artifact", e)
null
}
}
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)
.versionName
?.takeIf { it.isNotBlank() }
?: BuildConfig.VERSION_NAME
}
private fun isOlderThanInstalledVersion(candidateVersion: String): Boolean {
return GitHubReleaseClient.isNewerVersion(candidateVersion, installedVersionName())
}
fun isCompatibleWithInstalledVersion(candidateVersion: String): Boolean {
return !isOlderThanInstalledVersion(candidateVersion)
}
/**
* Verify the downloaded APK against either the running app's signing lineage
* or the pinned GitHub release certificate. The latter supports Play installs
* when GitHub distribution uses a separate, explicitly trusted release key.
* Debug builds without a configured pin accept any signed (never unsigned) APK.
*/
private fun verifyApkSignature(apkFile: File): Boolean {
return try {
val ownCerts = signatureDigests(
context.packageManager.getPackageInfo(context.packageName, signingFlags())
)
if (ownCerts.isEmpty()) {
Log.w(TAG, "Could not determine own signing certificate, skipping verification")
return true
}
if (isDebugSigned()) {
Log.w(TAG, "App is debug-signed, skipping signature enforcement")
return true
}
val packageInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, signingFlags())
?: run {
Log.e(TAG, "Could not parse APK for signature verification")
@ -372,10 +499,32 @@ class UniversalApkManager(private val context: Context) {
return false
}
val matches = apkCerts.intersect(ownCerts).isNotEmpty()
val ownCerts = signatureDigests(
context.packageManager.getPackageInfo(context.packageName, signingFlags())
)
val pinnedReleaseCert = normalizeCertificateDigest(
BuildConfig.GITHUB_RELEASE_CERT_SHA256
)
val trustedCerts = ownCerts + listOfNotNull(pinnedReleaseCert)
// Debug builds may use a different local signing key, but still
// require the downloaded artifact itself to be signed. Production
// builds must match either this installation's signing lineage or
// the explicitly pinned GitHub release certificate.
if (BuildConfig.DEBUG && pinnedReleaseCert == null) {
Log.w(TAG, "Debug build has no pinned release certificate; accepting signed APK")
return true
}
if (trustedCerts.isEmpty()) {
Log.e(TAG, "No trusted APK signing certificates are configured")
return false
}
val matches = apkCerts.intersect(trustedCerts).isNotEmpty()
if (!matches) {
Log.e(TAG, "Signature mismatch!")
Log.e(TAG, "Own cert(s): $ownCerts")
Log.e(TAG, "Trusted cert(s): $trustedCerts")
Log.e(TAG, "APK cert(s): $apkCerts")
}
matches
@ -414,25 +563,12 @@ class UniversalApkManager(private val context: Context) {
}.toSet()
}
private fun isDebugSigned(): Boolean {
return try {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, signingFlags())
val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.signingInfo?.apkContentsSigners
} else {
@Suppress("DEPRECATION")
packageInfo.signatures
} ?: return false
val certFactory = java.security.cert.CertificateFactory.getInstance("X.509")
signatures.any { sig ->
val cert = certFactory.generateCertificate(sig.toByteArray().inputStream())
as java.security.cert.X509Certificate
cert.subjectX500Principal.name.contains("Android Debug")
}
} catch (e: Exception) {
false
}
private fun normalizeCertificateDigest(value: String): String? {
return value
.replace(":", "")
.trim()
.lowercase()
.takeIf { it.matches(Regex("[a-f0-9]{64}")) }
}
/**
@ -440,16 +576,7 @@ class UniversalApkManager(private val context: Context) {
*/
suspend fun verifyChecksum(file: File, expectedSha256: String): Boolean = withContext(Dispatchers.IO) {
try {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(BUFFER_SIZE)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
digest.update(buffer, 0, bytesRead)
}
}
val checksum = digest.digest().joinToString("") { "%02x".format(it) }
val checksum = calculateChecksum(file)
val matches = checksum.equals(expectedSha256, ignoreCase = true)
if (!matches) {
@ -465,6 +592,18 @@ class UniversalApkManager(private val context: Context) {
}
}
private fun calculateChecksum(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(BUFFER_SIZE)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
digest.update(buffer, 0, bytesRead)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
/**
* Delete the cached universal APK.
*/
@ -490,10 +629,13 @@ class UniversalApkManager(private val context: Context) {
/**
* Clean up old APK files (keep only the current one).
*/
private fun cleanupOldApks() {
private fun cleanupOldApks(except: File) {
try {
cacheDir.listFiles()?.forEach { file ->
if (file.name.startsWith(APK_FILE_PREFIX) && file.name.endsWith(".apk")) {
if (file != except &&
file.name.startsWith(APK_FILE_PREFIX) &&
file.name.endsWith(".apk")
) {
file.delete()
Log.d(TAG, "Cleaned up old APK: ${file.name}")
}
@ -506,21 +648,26 @@ class UniversalApkManager(private val context: Context) {
/**
* Save metadata about the downloaded APK.
*/
private fun saveMetadata(version: String, checksum: String, size: Long, fileName: String) {
try {
val json = JSONObject().apply {
put("version", version)
put("checksum", checksum)
put("downloadDate", System.currentTimeMillis())
put("size", size)
put("fileName", fileName)
}
metadataFile.writeText(json.toString())
Log.d(TAG, "Saved metadata: $version")
} catch (e: Exception) {
Log.e(TAG, "Error saving metadata", e)
private fun saveMetadata(
version: String,
checksum: String,
size: Long,
fileName: String,
source: ApkSource
) {
val json = JSONObject().apply {
put("version", version)
put("checksum", checksum)
put("downloadDate", System.currentTimeMillis())
put("size", size)
put("fileName", fileName)
put("source", source.name)
}
val pendingMetadata = File(cacheDir, "$METADATA_FILE_NAME.new")
pendingMetadata.writeText(json.toString())
replaceFileSafely(pendingMetadata, metadataFile)
Log.d(TAG, "Saved metadata: $version")
}
private fun saveResumeInfo(url: String, expectedSize: Long, versionName: String) {
@ -547,6 +694,38 @@ class UniversalApkManager(private val context: Context) {
}
}
/**
* Commit [source] to [target] without removing a valid target first.
* Both files live in the same cache directory, so ATOMIC_MOVE is available
* on normal Android filesystems. The fallback still uses REPLACE_EXISTING
* and leaves the old target intact if preparing the candidate fails.
*/
private fun replaceFileSafely(source: File, target: File) {
val candidate = File(target.parentFile, "${target.name}.new")
if (source != candidate) {
source.copyTo(candidate, overwrite = true)
}
try {
Files.move(
candidate.toPath(),
target.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(
candidate.toPath(),
target.toPath(),
StandardCopyOption.REPLACE_EXISTING
)
}
if (source != target && source.exists()) {
source.delete()
}
}
/**
* Information about a cached APK.
*/
@ -555,9 +734,15 @@ class UniversalApkManager(private val context: Context) {
val checksum: String,
val downloadDate: Long,
val size: Long,
val file: File
val file: File,
val source: ApkSource
)
enum class ApkSource {
INSTALLED,
GITHUB
}
/**
* Update check status.
*/

View File

@ -2,6 +2,7 @@ package com.bitchat.android.util
import android.content.Context
import androidx.work.Constraints
import androidx.work.BackoffPolicy
import com.bitchat.android.R
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
@ -10,6 +11,7 @@ import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.concurrent.TimeUnit
/**
* WorkManager-backed implementation of [ApkDownloader].
@ -32,6 +34,11 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader {
val request = OneTimeWorkRequestBuilder<ApkDownloadWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
15,
TimeUnit.SECONDS
)
.addTag(ApkDownloadWorker.TAG)
.build()
@ -83,4 +90,4 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader {
}
}
}
}
}

View File

@ -154,9 +154,12 @@
<!-- Universal APK Preparation -->
<string name="prepare_apk_title">Prepare App for Sharing</string>
<string name="prepare_apk_ready_title" translatable="false">App Ready for Offline Sharing</string>
<string name="prepare_apk_subtitle">Download universal APK for offline sharing</string>
<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_github" translatable="false">Sharing source: verified GitHub universal APK</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>
@ -165,6 +168,7 @@
<string name="prepare_apk_info">Version %1$s • %2$d MB</string>
<string name="prepare_apk_dialog_title">Download Universal APK?</string>
<string name="prepare_apk_dialog_message">This will download the universal APK (~%1$d MB) from GitHub releases. You only need to do this once.</string>
<string name="prepare_apk_dialog_message_unknown_size" translatable="false">The release size is temporarily unavailable. BitChat will retry the GitHub request before downloading.</string>
<string name="prepare_apk_dialog_confirm">Download</string>
<string name="prepare_apk_downloading_title">Downloading Universal APK</string>
<string name="prepare_apk_downloading_message">Downloading %1$d MB…</string>

View File

@ -0,0 +1,57 @@
package com.bitchat.android.util
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@RunWith(RobolectricTestRunner::class)
class DistributionInfoProviderTest {
@get:Rule
val temporaryFolder = TemporaryFolder()
@Test
fun `arm64-only APK is not universal`() {
val apk = createApk("lib/arm64-v8a/libbitchat.so")
assertFalse(DistributionInfoProvider.isUniversalApk(apk))
}
@Test
fun `APK containing every release ABI is universal`() {
val apk = createApk(
"lib/arm64-v8a/libbitchat.so",
"lib/armeabi-v7a/libbitchat.so",
"lib/x86_64/libbitchat.so",
"lib/x86/libbitchat.so"
)
assertTrue(DistributionInfoProvider.isUniversalApk(apk))
}
@Test
fun `APK without native libraries is architecture independent`() {
val apk = createApk("classes.dex")
assertTrue(DistributionInfoProvider.isUniversalApk(apk))
}
private fun createApk(vararg entries: String): File {
val apk = temporaryFolder.newFile("test-${System.nanoTime()}.apk")
ZipOutputStream(apk.outputStream()).use { zip ->
entries.forEach { path ->
zip.putNextEntry(ZipEntry(path))
zip.write(byteArrayOf(1))
zip.closeEntry()
}
}
return apk
}
}

View File

@ -0,0 +1,99 @@
package com.bitchat.android.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class GitHubReleaseClientTest {
@Test
fun `parses universal apk and GitHub asset digest`() {
val digest = "a".repeat(64)
val release = GitHubReleaseClient.parseRelease(
"""
{
"tag_name": "v1.7.6",
"body": "",
"assets": [
{
"name": "bitchat-android-universal.apk",
"browser_download_url": "https://example.test/bitchat.apk",
"size": 49283072,
"digest": "sha256:$digest"
}
]
}
""".trimIndent()
)
requireNotNull(release)
assertEquals("1.7.6", release.versionName)
assertEquals(49_283_072L, release.universalApkSize)
assertEquals(digest, release.universalApkSha256)
}
@Test
fun `falls back to checksum in release notes`() {
val digest = "b".repeat(64)
val release = GitHubReleaseClient.parseRelease(
"""
{
"tag_name": "1.7.6",
"body": "bitchat-android-universal.apk: $digest",
"assets": [
{
"name": "bitchat-android-universal.apk",
"browser_download_url": "https://example.test/bitchat.apk",
"size": 10
}
]
}
""".trimIndent()
)
assertEquals(digest, requireNotNull(release).universalApkSha256)
}
@Test
fun `rejects releases without a universal apk`() {
val release = GitHubReleaseClient.parseRelease(
"""
{
"tag_name": "v1.7.6",
"assets": [
{
"name": "bitchat-android-arm64.apk",
"browser_download_url": "https://example.test/arm64.apk",
"size": 10
}
]
}
""".trimIndent()
)
assertNull(release)
}
@Test
fun `compares release versions`() {
val release = GitHubReleaseClient.Release(
tagName = "v1.7.6",
versionName = "1.7.6",
universalApkUrl = "https://example.test/bitchat.apk",
universalApkSha256 = null,
universalApkSize = 10,
universalApkName = "bitchat-android-universal.apk"
)
assertTrue(GitHubReleaseClient.isNewerVersion("1.7.5", release))
assertFalse(GitHubReleaseClient.isNewerVersion("1.7.6", release))
assertFalse(GitHubReleaseClient.isNewerVersion("1.8.0", release))
assertTrue(GitHubReleaseClient.isNewerVersion("1.7.4", "1.7.5"))
assertFalse(GitHubReleaseClient.isNewerVersion("1.7.5", "1.7.4"))
}
}

View File

@ -25,5 +25,10 @@ android.nonTransitiveRClass=false
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Public SHA-256 fingerprint of the certificate used by the existing GitHub
# universal APK releases. This is not a secret; it lets the app reject an APK
# signed by an unexpected publisher.
BITCHAT_GITHUB_RELEASE_CERT_SHA256=3b03fa66a5451321100792f5b55a7b4966d5c8dc10c6daa40aa95ea489531bca
# JVM heap size configuration to prevent OutOfMemoryError
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError