mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
fix: harden offline APK sharing
This commit is contained in:
parent
dec6aca465
commit
e2759d78ac
@ -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 {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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?
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user