diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db14845a..f8ec6c77 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt b/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt index 5d763fee..140f2971 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt @@ -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" } diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt index fe64bfad..795cce34 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt @@ -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 ) diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt index 3b3b51da..bf2f3a93 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt @@ -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 } } diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt index 9c8870b4..50b04855 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt @@ -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") } } diff --git a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt index 8dc0cad8..e3490576 100644 --- a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt +++ b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt @@ -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 { 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 539ce95f..7a64ff50 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -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( ) } } - 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 ba9d387e..73393a30 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -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) + ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 65b19182..deeafb0a 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -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(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)) { diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt index 87e41c87..8b968923 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt @@ -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) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt b/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt new file mode 100644 index 00000000..87fe3fb7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt @@ -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): 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 { + 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 { + 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? + ) +} diff --git a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt index 28e2ec50..699637bd 100644 --- a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -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 = + 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 { + 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 + ) } 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 a49daf67..b5ad3133 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -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. */ diff --git a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt index 55d9245e..feb31172 100644 --- a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt @@ -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() .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 15, + TimeUnit.SECONDS + ) .addTag(ApkDownloadWorker.TAG) .build() @@ -83,4 +90,4 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader { } } } -} \ No newline at end of file +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8e1aac70..54500cfb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -154,9 +154,12 @@ Prepare App for Sharing + App Ready for Offline Sharing Download universal APK for offline sharing Not ready • Tap to download Ready to share + Sharing source: this installed APK + Sharing source: verified GitHub universal APK Downloading… %1$d%% Update available Prepare @@ -165,6 +168,7 @@ Version %1$s • %2$d MB Download Universal APK? This will download the universal APK (~%1$d MB) from GitHub releases. You only need to do this once. + The release size is temporarily unavailable. BitChat will retry the GitHub request before downloading. Download Downloading Universal APK Downloading %1$d MB… diff --git a/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt b/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt new file mode 100644 index 00000000..c30054fa --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt @@ -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 + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt new file mode 100644 index 00000000..e51990d9 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt @@ -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")) + } +} diff --git a/gradle.properties b/gradle.properties index fce5240f..a441b269 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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