From d615fc9cc07faa8ba243c25bb769fb617ecc38ea Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:17:44 +0200 Subject: [PATCH 1/2] Gate nearby notes behind tap-to-reveal consent (#771) * Add nearby notes tap-to-reveal consent * Stop nearby notes while app is backgrounded --- .../android/nostr/LocationNotesManager.kt | 39 ++- .../android/nostr/NearbyNotesController.kt | 130 ++++++++ .../java/com/bitchat/android/ui/ChatScreen.kt | 277 ++++++++++++++---- .../android/ui/LocationChannelsSheet.kt | 11 +- .../bitchat/android/ui/LocationNotesSheet.kt | 29 +- app/src/main/res/values-ar/strings.xml | 3 + app/src/main/res/values-bn/strings.xml | 3 + app/src/main/res/values-de/strings.xml | 3 + app/src/main/res/values-es/strings.xml | 3 + app/src/main/res/values-fa/strings.xml | 3 + app/src/main/res/values-fil/strings.xml | 3 + app/src/main/res/values-fr/strings.xml | 3 + app/src/main/res/values-he/strings.xml | 3 + app/src/main/res/values-hi/strings.xml | 3 + app/src/main/res/values-id/strings.xml | 3 + app/src/main/res/values-it/strings.xml | 3 + app/src/main/res/values-ja/strings.xml | 3 + app/src/main/res/values-ka/strings.xml | 3 + app/src/main/res/values-ko/strings.xml | 3 + app/src/main/res/values-mg/strings.xml | 3 + app/src/main/res/values-ms/strings.xml | 3 + app/src/main/res/values-ne/strings.xml | 3 + app/src/main/res/values-nl/strings.xml | 3 + app/src/main/res/values-pa-rPK/strings.xml | 3 + app/src/main/res/values-pl/strings.xml | 3 + app/src/main/res/values-pt-rBR/strings.xml | 3 + app/src/main/res/values-pt/strings.xml | 3 + app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values-sv/strings.xml | 3 + app/src/main/res/values-ta/strings.xml | 3 + app/src/main/res/values-th/strings.xml | 3 + app/src/main/res/values-tr/strings.xml | 3 + app/src/main/res/values-uk/strings.xml | 3 + app/src/main/res/values-ur/strings.xml | 3 + app/src/main/res/values-vi/strings.xml | 3 + app/src/main/res/values-zh-rCN/strings.xml | 4 +- app/src/main/res/values-zh-rTW/strings.xml | 4 +- app/src/main/res/values-zh/strings.xml | 3 + app/src/main/res/values/strings.xml | 4 + .../nostr/NearbyNotesControllerTest.kt | 159 ++++++++++ 40 files changed, 678 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt index dc1a8e85..8873c271 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow */ @MainThread class LocationNotesManager private constructor() { - + companion object { private const val TAG = "LocationNotesManager" private const val MAX_NOTES_IN_MEMORY = 500 @@ -27,7 +27,7 @@ class LocationNotesManager private constructor() { } } } - + /** * Note data class matching iOS implementation */ @@ -94,6 +94,8 @@ class LocationNotesManager private constructor() { // Coroutine scope for background operations private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var subscribeRetryJob: Job? = null + private var initialLoadJob: Job? = null /** * Initialize dependencies @@ -289,6 +291,11 @@ class LocationNotesManager private constructor() { * Subscribe to location notes for current geohash */ private fun subscribeAll() { + subscribeRetryJob?.cancel() + subscribeRetryJob = null + initialLoadJob?.cancel() + initialLoadJob = null + val currentGeohash = _geohash.value if (currentGeohash == null) { Log.w(TAG, "Cannot subscribe - no geohash set") @@ -301,7 +308,7 @@ class LocationNotesManager private constructor() { Log.e(TAG, "Cannot subscribe - subscribe function not initialized; will retry shortly") _state.value = State.LOADING // Retry a few times in case initialization is racing the sheet open - scope.launch { + subscribeRetryJob = scope.launch { var attempts = 0 while (attempts < 10 && subscribeFunc == null) { delay(300) @@ -342,9 +349,9 @@ class LocationNotesManager private constructor() { } // Mark initial load complete after brief delay to allow relay responses - scope.launch { + initialLoadJob = scope.launch { delay(2000) // Wait 2 seconds for initial batch - if (!_initialLoadComplete.value!!) { + if (_geohash.value == currentGeohash && !_initialLoadComplete.value) { _initialLoadComplete.value = true _state.value = State.READY Log.d(TAG, "Initial load complete for geohash: $currentGeohash (${noteIDs.size} notes)") @@ -441,6 +448,11 @@ class LocationNotesManager private constructor() { * Cancel subscription and clear state */ fun cancel() { + subscribeRetryJob?.cancel() + subscribeRetryJob = null + initialLoadJob?.cancel() + initialLoadJob = null + if (subscriptionIDs.isNotEmpty()) { subscriptionIDs.values.forEach { subId -> try { @@ -453,17 +465,26 @@ class LocationNotesManager private constructor() { subscribedGeohashes = emptySet() _state.value = State.IDLE } - + /** - * Cleanup resources + * End the nearby-notes session and discard location-correlated UI state. + * Unlike [cancel], this also clears the target so a later activation can + * safely subscribe to the same building geohash again. */ - fun cleanup() { + fun stop() { cancel() - scope.cancel() _notes.value = emptyList() noteIDs.clear() _geohash.value = null _initialLoadComplete.value = false _errorMessage.value = null } + + /** + * Cleanup resources + */ + fun cleanup() { + stop() + scope.cancel() + } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt b/app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt new file mode 100644 index 00000000..a5f1a8ec --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt @@ -0,0 +1,130 @@ +package com.bitchat.android.nostr + +import androidx.annotation.MainThread +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Session-scoped consent gate for nearby location notes. + * + * Merely rendering the mesh timeline must not open a building-precision Nostr + * subscription. A subscription is eligible only after an explicit reveal and + * while the app is foregrounded and at least one nearby-notes surface is active. + */ +@MainThread +class NearbyNotesController internal constructor( + private val subscribe: (String) -> Unit, + private val unsubscribe: () -> Unit, +) { + private val _revealed = MutableStateFlow(false) + val revealed: StateFlow = _revealed.asStateFlow() + + private var activeHolders = 0 + private var locationEnabled = false + private var locationAuthorized = false + private var appForeground = false + private var buildingGeohash: String? = null + private var subscribedGeohash: String? = null + + /** + * Unlocks nearby notes for this process session. Deactivation deliberately + * does not reset consent, matching the iOS privacy model. + */ + fun reveal() { + if (_revealed.value) return + _revealed.value = true + reconcileSubscription() + } + + /** Holds the subscription while a nearby-notes surface is visible. */ + fun activate() { + activeHolders += 1 + reconcileSubscription() + } + + /** Releases a matching [activate] hold and unsubscribes after the last one. */ + fun deactivate() { + activeHolders = (activeHolders - 1).coerceAtLeast(0) + reconcileSubscription() + } + + /** Closes the live subscription whenever the process leaves the foreground. */ + fun updateAppForeground(isForeground: Boolean) { + appForeground = isForeground + reconcileSubscription() + } + + /** + * Updates the privacy-sensitive inputs independently of view activation. + * Permission revocation, location disable, or loss of the building cell + * immediately closes any live subscription. + */ + fun updateAvailability( + locationEnabled: Boolean, + locationAuthorized: Boolean, + buildingGeohash: String?, + ) { + this.locationEnabled = locationEnabled + this.locationAuthorized = locationAuthorized + this.buildingGeohash = buildingGeohash + ?.trim() + ?.lowercase() + ?.takeIf { it.isNotEmpty() } + reconcileSubscription() + } + + fun offersRevealHint(): Boolean = + !_revealed.value && + locationEnabled && + locationAuthorized && + buildingGeohash != null + + private fun reconcileSubscription() { + val target = buildingGeohash.takeIf { + activeHolders > 0 && + appForeground && + _revealed.value && + locationEnabled && + locationAuthorized + } + + if (subscribedGeohash != null && subscribedGeohash != target) { + unsubscribe() + subscribedGeohash = null + } + + if (target != null && subscribedGeohash == null) { + subscribe(target) + subscribedGeohash = target + } + } + + companion object { + val shared: NearbyNotesController by lazy { + val manager = LocationNotesManager.getInstance() + NearbyNotesController( + subscribe = manager::setGeohash, + unsubscribe = manager::stop, + ) + } + } +} + +/** + * Building precision is location-notes precision and remains private before a + * reveal. Explicit bookmarks remain eligible because saving one is itself an + * intentional location act. + */ +internal fun geohashesForSampling( + availableChannels: List, + bookmarks: Collection, + notesRevealed: Boolean, +): List = buildSet { + availableChannels + .filter { notesRevealed || it.level != GeohashChannelLevel.BUILDING } + .mapTo(this) { it.geohash } + addAll(bookmarks) +}.toList() diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 78d5b571..0514217d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -12,19 +12,35 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.IconButton import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.zIndex +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.nostr.LocationNotesManager +import com.bitchat.android.nostr.NearbyNotesController import com.bitchat.android.ui.media.FullScreenImageViewer /** @@ -86,6 +102,67 @@ fun ChatScreen(viewModel: ChatViewModel) { // Get location channel info for timeline switching val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val context = LocalContext.current + val locationManager = remember { LocationChannelManager.getInstance(context) } + val nearbyNotesController = remember { NearbyNotesController.shared } + val nearbyNotesRevealed by nearbyNotesController.revealed.collectAsStateWithLifecycle() + val locationPermissionState by locationManager.permissionState.collectAsStateWithLifecycle() + val locationEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false) + val availableLocationChannels by locationManager.availableChannels.collectAsStateWithLifecycle() + val nearbyNotes by remember { LocationNotesManager.getInstance() } + .notes + .collectAsStateWithLifecycle() + val buildingGeohash = availableLocationChannels + .firstOrNull { it.level == GeohashChannelLevel.BUILDING } + ?.geohash + val isMeshTimeline = + currentChannel == null && + selectedLocationChannel is ChannelID.Mesh && + selectedPrivatePeer == null && + privateChatSheetPeer == null + + val processLifecycleOwner = remember { ProcessLifecycleOwner.get() } + DisposableEffect(processLifecycleOwner, nearbyNotesController) { + val lifecycle = processLifecycleOwner.lifecycle + val observer = object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + nearbyNotesController.updateAppForeground(true) + } + + override fun onStop(owner: LifecycleOwner) { + nearbyNotesController.updateAppForeground(false) + } + } + + lifecycle.addObserver(observer) + nearbyNotesController.updateAppForeground( + lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED), + ) + + onDispose { + lifecycle.removeObserver(observer) + nearbyNotesController.updateAppForeground(false) + } + } + + DisposableEffect( + isMeshTimeline, + locationEnabled, + locationPermissionState, + buildingGeohash, + nearbyNotesController, + ) { + nearbyNotesController.updateAvailability( + locationEnabled = locationEnabled, + locationAuthorized = + locationPermissionState == LocationChannelManager.PermissionState.AUTHORIZED, + buildingGeohash = buildingGeohash, + ) + if (isMeshTimeline) nearbyNotesController.activate() + onDispose { + if (isMeshTimeline) nearbyNotesController.deactivate() + } + } // Determine what messages to show based on current context (unified timelines) // Legacy private chat timeline removed - private chats now exclusively use PrivateChatSheet @@ -131,58 +208,91 @@ fun ChatScreen(viewModel: ChatViewModel) { ) // Messages area - takes up available space, will compress when keyboard appears - MessagesList( - messages = displayMessages, - currentUserNickname = nickname, - meshService = viewModel.meshServiceFacade, - modifier = Modifier.weight(1f), - forceScrollToBottom = forceScrollToBottom, - onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, - onNicknameClick = { fullSenderName -> - // Single click - mention user in text input - val currentText = messageText.text - - // Extract base nickname and hash suffix from full sender name - val (baseName, hashSuffix) = splitSuffix(fullSenderName) - - // Check if we're in a geohash channel to include hash suffix - val selectedLocationChannel = viewModel.selectedLocationChannel.value - val mentionText = if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location && hashSuffix.isNotEmpty()) { - // In geohash chat - include the hash suffix from the full display name - "@$baseName$hashSuffix" - } else { - // Regular chat - just the base nickname - "@$baseName" - } - - val newText = when { - currentText.isEmpty() -> "$mentionText " - currentText.endsWith(" ") -> "$currentText$mentionText " - else -> "$currentText $mentionText " - } - - messageText = TextFieldValue( - text = newText, - selection = TextRange(newText.length) + Column(modifier = Modifier.weight(1f)) { + if (isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty()) { + NearbyNotesStrip( + noteCount = nearbyNotes.size, + onClick = { showLocationNotesSheet = true }, ) - }, - onMessageLongPress = { message -> - // Message long press - open user action sheet with message context - // Extract base nickname from message sender (contains all necessary info) - val (baseName, _) = splitSuffix(message.sender) - selectedUserForSheet = baseName - selectedMessageForSheet = message - showUserSheet = true - }, - onCancelTransfer = { msg -> - viewModel.cancelMediaSend(msg.id) - }, - onImageClick = { currentPath, allImagePaths, initialIndex -> - viewerImagePaths = allImagePaths - initialViewerIndex = initialIndex - showFullScreenImageViewer = true } - ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + MessagesList( + messages = displayMessages, + currentUserNickname = nickname, + meshService = viewModel.meshServiceFacade, + modifier = Modifier.fillMaxSize(), + forceScrollToBottom = forceScrollToBottom, + onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, + onNicknameClick = { fullSenderName -> + // Single click - mention user in text input + val currentText = messageText.text + + // Extract base nickname and hash suffix from full sender name + val (baseName, hashSuffix) = splitSuffix(fullSenderName) + + // Check if we're in a geohash channel to include hash suffix + val selectedLocationChannel = viewModel.selectedLocationChannel.value + val mentionText = if ( + selectedLocationChannel is ChannelID.Location && + hashSuffix.isNotEmpty() + ) { + // In geohash chat - include the hash suffix from the full display name + "@$baseName$hashSuffix" + } else { + // Regular chat - just the base nickname + "@$baseName" + } + + val newText = when { + currentText.isEmpty() -> "$mentionText " + currentText.endsWith(" ") -> "$currentText$mentionText " + else -> "$currentText $mentionText " + } + + messageText = TextFieldValue( + text = newText, + selection = TextRange(newText.length), + ) + }, + onMessageLongPress = { message -> + // Message long press - open user action sheet with message context + // Extract base nickname from message sender (contains all necessary info) + val (baseName, _) = splitSuffix(message.sender) + selectedUserForSheet = baseName + selectedMessageForSheet = message + showUserSheet = true + }, + onCancelTransfer = { msg -> + viewModel.cancelMediaSend(msg.id) + }, + onImageClick = { currentPath, allImagePaths, initialIndex -> + viewerImagePaths = allImagePaths + initialViewerIndex = initialIndex + showFullScreenImageViewer = true + }, + ) + + if ( + displayMessages.isEmpty() && + isMeshTimeline && + !nearbyNotesRevealed && + locationEnabled && + locationPermissionState == + LocationChannelManager.PermissionState.AUTHORIZED && + buildingGeohash != null + ) { + NearbyNotesRevealHint( + onClick = nearbyNotesController::reveal, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } // Input area - stays at bottom // Bridge file share from lower-level input to ViewModel androidx.compose.runtime.LaunchedEffect(Unit) { @@ -253,7 +363,10 @@ fun ChatScreen(viewModel: ChatViewModel) { onShowAppInfo = { viewModel.showAppInfo() }, onPanicClear = { viewModel.panicClearAllData() }, onLocationChannelsClick = { showLocationChannelsSheet = true }, - onLocationNotesClick = { showLocationNotesSheet = true } + onLocationNotesClick = { + nearbyNotesController.reveal() + showLocationNotesSheet = true + } ) // Divider under header - positioned after status bar + header height @@ -374,6 +487,68 @@ fun ChatScreen(viewModel: ChatViewModel) { } } +@Composable +private fun NearbyNotesRevealHint( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val actionLabel = stringResource(R.string.nearby_notes_reveal) + TextButton( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = 24.dp) + .semantics { contentDescription = actionLabel }, + ) { + Text( + text = "📍 $actionLabel", + modifier = Modifier.clearAndSetSemantics { }, + color = MaterialTheme.colorScheme.primary, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + ) + } +} + +@Composable +private fun NearbyNotesStrip( + noteCount: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "📍 " + if (noteCount == 1) { + stringResource(R.string.nearby_notes_one) + } else { + stringResource(R.string.nearby_notes_many, noteCount) + }, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.primary, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + ) + Text( + text = "›", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 18.sp, + ) + } + } +} + @Composable fun ChatInputSection( messageText: TextFieldValue, diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 99ed10cc..0732d912 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -34,6 +34,8 @@ import com.bitchat.android.geohash.GeohashChannel import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.geohash.GeohashBookmarksStore +import com.bitchat.android.nostr.NearbyNotesController +import com.bitchat.android.nostr.geohashesForSampling import com.bitchat.android.ui.theme.BASE_FONT_SIZE import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -61,6 +63,7 @@ fun LocationChannelsSheet( // Observe location manager state val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() + val notesRevealed by NearbyNotesController.shared.revealed.collectAsStateWithLifecycle() val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle() val locationNames by locationManager.locationNames.collectAsStateWithLifecycle() val appLocationEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle() @@ -534,9 +537,13 @@ fun LocationChannelsSheet( } // Sampling management: update sampling when channels/bookmarks change - LaunchedEffect(isPresented, availableChannels, bookmarks) { + LaunchedEffect(isPresented, availableChannels, bookmarks, notesRevealed) { if (isPresented) { - val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList() + val geohashes = geohashesForSampling( + availableChannels = availableChannels, + bookmarks = bookmarks, + notesRevealed = notesRevealed, + ) viewModel.beginGeohashSampling(geohashes) } else { viewModel.endGeohashSampling() diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index 29ead6a6..6a224cf2 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -32,6 +32,7 @@ import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.nostr.LocationNotesManager +import com.bitchat.android.nostr.NearbyNotesController import java.text.SimpleDateFormat import java.util.* import java.util.Calendar @@ -58,12 +59,15 @@ fun LocationNotesSheet( // Managers val notesManager = remember { LocationNotesManager.getInstance() } val locationManager = remember { LocationChannelManager.getInstance(context) } + val nearbyNotesController = remember { NearbyNotesController.shared } // State val notes by notesManager.notes.collectAsStateWithLifecycle() val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE) val errorMessage by notesManager.errorMessage.collectAsStateWithLifecycle() val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(false) + val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() + val locationEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false) // SIMPLIFIED: Get count directly from notes list (no separate counter needed) val count = notes.size @@ -94,15 +98,24 @@ fun LocationNotesSheet( locationManager.refreshChannels() } - // Effect to set geohash when sheet opens - LaunchedEffect(geohash) { - notesManager.setGeohash(geohash) - } - - // Cleanup when sheet closes - DisposableEffect(Unit) { + // Opening the notes sheet is an explicit reveal. The balanced hold lets + // the mesh timeline keep the shared subscription alive after dismissal. + DisposableEffect( + geohash, + locationEnabled, + permissionState, + nearbyNotesController, + ) { + nearbyNotesController.updateAvailability( + locationEnabled = locationEnabled, + locationAuthorized = + permissionState == LocationChannelManager.PermissionState.AUTHORIZED, + buildingGeohash = geohash, + ) + nearbyNotesController.activate() + nearbyNotesController.reveal() onDispose { - notesManager.cancel() + nearbyNotesController.deactivate() } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 4d2ffec0..c81bdb26 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -401,4 +401,7 @@ You verified %1$s verified %1$s فتح قسم حول + تحقّق من الملاحظات المتروكة هنا + تُركت ملاحظة واحدة هنا — انقر للقراءة + تُركت %d ملاحظات هنا — انقر للقراءة diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index c5902ee2..95b3662a 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s পরিচিতি খুলুন + এখানে রাখা নোট আছে কি না দেখুন + এখানে 1টি নোট রাখা আছে — পড়তে ট্যাপ করুন + এখানে %dটি নোট রাখা আছে — পড়তে ট্যাপ করুন diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9002f9bd..a80dc4c4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -402,4 +402,7 @@ Du hast %1$s verifiziert verifiziert %1$s Info öffnen + nachsehen, ob hier notizen hinterlassen wurden + 1 notiz hier hinterlassen — tippen zum lesen + %d notizen hier hinterlassen — tippen zum lesen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index cf6b336e..157ea1b4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -401,4 +401,7 @@ Verificaste a %1$s verificado %1$s Abrir Acerca de + buscar notas dejadas aquí + 1 nota dejada aquí — toca para leer + %d notas dejadas aquí — toca para leer diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 84273529..8d7d6ae9 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s باز کردن درباره + یادداشت‌های باقی‌مانده در اینجا را بررسی کنید + ۱ یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید + %d یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 65e4cbd2..829079a6 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -400,4 +400,7 @@ You verified %1$s verified %1$s Buksan ang Tungkol + tingnan kung may mga note na naiwan dito + 1 note ang naiwan dito — i-tap para basahin + %d note ang naiwan dito — i-tap para basahin diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index aee12bf1..46e9c023 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -414,4 +414,7 @@ Vous avez vérifié %1$s vérifié %1$s Ouvrir À propos + vérifier s\'il y a des notes laissées ici + 1 note laissée ici — appuyez pour lire + %d notes laissées ici — appuyez pour lire diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index a78d83b6..3793942b 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -54,4 +54,7 @@ You verified %1$s verified %1$s פתיחת אודות + בדיקה אם הושארו כאן פתקים + פתק אחד הושאר כאן — הקש לקריאה + %d פתקים הושארו כאן — הקש לקריאה diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 926cc3a4..ffb4dac0 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -401,4 +401,7 @@ You verified %1$s verified %1$s परिचय खोलें + देखें कि यहाँ नोट छोड़े गए हैं या नहीं + यहाँ 1 नोट छोड़ा गया है — पढ़ने के लिए टैप करें + यहाँ %d नोट छोड़े गए हैं — पढ़ने के लिए टैप करें diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index bb15e3bb..e67caded 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -401,4 +401,7 @@ You verified %1$s verified %1$s Buka Tentang + periksa catatan yang ditinggalkan di sini + 1 catatan ditinggalkan di sini — ketuk untuk membaca + %d catatan ditinggalkan di sini — ketuk untuk membaca diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c10a8575..92bf4ad0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -434,4 +434,7 @@ Hai verificato %1$s verificato %1$s Apri Informazioni + controlla se ci sono note lasciate qui + 1 nota lasciata qui — tocca per leggere + %d note lasciate qui — tocca per leggere diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index eb9d6ba1..1b25aa95 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -401,4 +401,7 @@ %1$s を検証しました %1$s を検証しました このアプリについてを開く + ここに残されたメモを確認 + ここに1件のメモがあります — タップして読む + ここに%d件のメモがあります — タップして読む diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 1e3187d0..c7620e19 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s აპის შესახებ გახსნა + აქ დატოვებული ჩანაწერების შემოწმება + აქ 1 ჩანაწერია დატოვებული — წასაკითხად შეეხეთ + აქ %d ჩანაწერია დატოვებული — წასაკითხად შეეხეთ diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 829cd1f5..8c0cc624 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -401,4 +401,7 @@ You verified %1$s verified %1$s 정보 열기 + 여기 남겨진 쪽지 확인 + 여기 남겨진 쪽지 1개 — 탭하여 읽기 + 여기 남겨진 쪽지 %d개 — 탭하여 읽기 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index a7edd747..ddff38ad 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -414,4 +414,7 @@ You verified %1$s verified %1$s Sokafy ny momba + hizaha raha misy naoty navela teto + naoty 1 no navela teto — tsindrio raha hamaky + naoty %d no navela teto — tsindrio raha hamaky diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 9b6f2bb8..add7b467 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -41,4 +41,7 @@ You verified %1$s verified %1$s Buka Perihal + semak nota yang ditinggalkan di sini + 1 nota ditinggalkan di sini — ketik untuk baca + %d nota ditinggalkan di sini — ketik untuk baca diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index f1dbb3c6..3e50146a 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -400,4 +400,7 @@ You verified %1$s verified %1$s परिचय खोल्नुहोस् + यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस् + यहाँ 1 नोट छोडिएको छ — पढ्न ट्याप गर्नुहोस् + यहाँ %d नोटहरू छोडिएका छन् — पढ्न ट्याप गर्नुहोस् diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 6a014f87..38385885 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -432,4 +432,7 @@ You verified %1$s verified %1$s Info openen + kijk of hier notities zijn achtergelaten + 1 notitie hier achtergelaten — tik om te lezen + %d notities hier achtergelaten — tik om te lezen diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 3b94bd8c..e92d2d51 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s ایپ بارے کھولو + ایتھے چھڈے نوٹس ویکھو + ایتھے 1 نوٹ چھڈیا گیا — پڑھݨ لئی ٹیپ کرو + ایتھے %d نوٹس چھڈے گئے — پڑھݨ لئی ٹیپ کرو diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 1579f9b3..f1a67349 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -54,4 +54,7 @@ You verified %1$s verified %1$s Otwórz informacje + sprawdź, czy zostawiono tutaj notatki + 1 notatka zostawiona tutaj — stuknij, aby przeczytać + %d notatek zostawionych tutaj — stuknij, aby przeczytać diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 45f41512..67503cc6 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -400,4 +400,7 @@ Verificado Você verificou %1$s verificou %1$s + ver se há notas deixadas aqui + 1 nota deixada aqui — toque para ler + %d notas deixadas aqui — toque para ler diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 274bbaae..3bcad4ab 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -401,4 +401,7 @@ Você verificou %1$s verificou %1$s Abrir Sobre + ver se há notas deixadas aqui + 1 nota deixada aqui — toque para ler + %d notas deixadas aqui — toque para ler diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d1d27acd..9ce4a560 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -390,4 +390,7 @@ Вы проверили %1$s проверен %1$s Открыть раздел «О приложении» + проверить, есть ли здесь заметки + здесь оставлена 1 заметка — нажмите, чтобы прочитать + здесь оставлено заметок: %d — нажмите, чтобы прочитать diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index aa3194ed..566f940e 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s Öppna Om + kolla om anteckningar lämnats här + 1 anteckning lämnad här — tryck för att läsa + %d anteckningar lämnade här — tryck för att läsa diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index a47d4dbd..05bd4750 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -41,4 +41,7 @@ You verified %1$s verified %1$s அறிமுகத்தைத் திற + இங்கே விடப்பட்ட குறிப்புகள் உள்ளதா எனப் பார்க்கவும் + இங்கே 1 குறிப்பு விடப்பட்டுள்ளது — படிக்க தட்டவும் + இங்கே %d குறிப்புகள் விடப்பட்டுள்ளன — படிக்க தட்டவும் diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 5b2ba927..817cd335 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s เปิดเกี่ยวกับ + ดูว่ามีโน้ตทิ้งไว้ที่นี่หรือไม่ + มี 1 โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน + มี %d โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index d27d1b59..93888483 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s Hakkında’yı aç + buraya bırakılan notlara bak + buraya 1 not bırakıldı — okumak için dokun + buraya %d not bırakıldı — okumak için dokun diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 9ce840ac..9ca2d2d0 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -41,4 +41,7 @@ You verified %1$s verified %1$s Відкрити розділ «Про застосунок» + перевірити, чи залишено тут нотатки + тут залишено 1 нотатку — торкніться, щоб прочитати + тут залишено %d нотаток — торкніться, щоб прочитати diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 1d8399de..3f1c735a 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -401,4 +401,7 @@ You verified %1$s verified %1$s تعارف کھولیں + دیکھیں کہ یہاں نوٹ چھوڑے گئے ہیں یا نہیں + یہاں 1 نوٹ چھوڑا گیا ہے — پڑھنے کے لیے تھپتھپائیں + یہاں %d نوٹ چھوڑے گئے ہیں — پڑھنے کے لیے تھپتھپائیں diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 6ab72813..4dd4ad37 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s Mở phần Giới thiệu + kiểm tra ghi chú để lại ở đây + có 1 ghi chú để lại ở đây — chạm để đọc + có %d ghi chú để lại ở đây — chạm để đọc diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 69bff70c..438131b0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -53,5 +53,7 @@ 已验证 你已验证 %1$s 已验证 %1$s + 查看这里留下的留言 + 这里留有 1 条留言 — 点按阅读 + 这里留有 %d 条留言 — 点按阅读 - diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 1dcf6b27..328696a0 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -53,5 +53,7 @@ 已验证 你已验证 %1$s 已验证 %1$s + 查看這裡留下的留言 + 這裡留有 1 則留言 — 點按閱讀 + 這裡留有 %d 則留言 — 點按閱讀 - diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 8a28e5f7..f8879cc8 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -413,4 +413,7 @@ 你已验证 %1$s 已验证 %1$s 打开“关于” + 查看这里留下的留言 + 这里留有 1 条留言 — 点按阅读 + 这里留有 %d 条留言 — 点按阅读 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e674112c..b8086f0a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -234,6 +234,10 @@ region + check for notes left here + 1 note left here — tap to read + + %d notes left here — tap to read #%1$s ± 1 • %2$d note #%1$s ± 1 • %2$d notes diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt new file mode 100644 index 00000000..6bd86042 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt @@ -0,0 +1,159 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NearbyNotesControllerTest { + private val subscriptions = mutableListOf() + private var unsubscribeCount = 0 + + private fun controller() = NearbyNotesController( + subscribe = subscriptions::add, + unsubscribe = { unsubscribeCount += 1 }, + ) + + private fun foregroundController() = controller().also { + it.updateAppForeground(true) + } + + @Test + fun `active mesh timeline does not subscribe before explicit reveal`() { + val controller = foregroundController() + + controller.updateAvailability( + locationEnabled = true, + locationAuthorized = true, + buildingGeohash = "u4pruydq", + ) + controller.activate() + + assertTrue(controller.offersRevealHint()) + assertTrue(subscriptions.isEmpty()) + + controller.reveal() + + assertFalse(controller.offersRevealHint()) + assertEquals(listOf("u4pruydq"), subscriptions) + } + + @Test + fun `reveal remains dormant until a nearby notes surface is active`() { + val controller = foregroundController() + controller.updateAvailability(true, true, "u4pruydq") + + controller.reveal() + + assertTrue(subscriptions.isEmpty()) + + controller.activate() + + assertEquals(listOf("u4pruydq"), subscriptions) + } + + @Test + fun `last deactivate unsubscribes exactly once`() { + val controller = foregroundController() + controller.updateAvailability(true, true, "u4pruydq") + controller.reveal() + controller.activate() + controller.activate() + + controller.deactivate() + assertEquals(0, unsubscribeCount) + + controller.deactivate() + controller.deactivate() + + assertEquals(1, unsubscribeCount) + } + + @Test + fun `backgrounding closes the subscription and foregrounding restores it`() { + val controller = foregroundController() + controller.updateAvailability(true, true, "u4pruydq") + controller.activate() + controller.reveal() + + controller.updateAppForeground(false) + + assertEquals(1, unsubscribeCount) + assertTrue(controller.revealed.value) + + controller.updateAppForeground(false) + assertEquals(1, unsubscribeCount) + + controller.updateAppForeground(true) + assertEquals(listOf("u4pruydq", "u4pruydq"), subscriptions) + } + + @Test + fun `disable and permission revocation close the live subscription`() { + val controller = foregroundController() + controller.updateAvailability(true, true, "u4pruydq") + controller.activate() + controller.reveal() + + controller.updateAvailability(false, true, "u4pruydq") + assertEquals(1, unsubscribeCount) + + controller.updateAvailability(true, true, "u4pruydq") + assertEquals(listOf("u4pruydq", "u4pruydq"), subscriptions) + + controller.updateAvailability(true, false, "u4pruydq") + assertEquals(2, unsubscribeCount) + } + + @Test + fun `moving building cells releases old subscription before retargeting`() { + val events = mutableListOf() + val controller = NearbyNotesController( + subscribe = { events += "subscribe:$it" }, + unsubscribe = { events += "unsubscribe" }, + ) + controller.updateAppForeground(true) + controller.updateAvailability(true, true, "u4pruydq") + controller.activate() + controller.reveal() + + controller.updateAvailability(true, true, "u4pruydr") + + assertEquals( + listOf( + "subscribe:u4pruydq", + "unsubscribe", + "subscribe:u4pruydr", + ), + events, + ) + } + + @Test + fun `building sampling is excluded until reveal while bookmarks remain eligible`() { + val channels = listOf( + GeohashChannel(GeohashChannelLevel.BUILDING, "u4pruydq"), + GeohashChannel(GeohashChannelLevel.BLOCK, "u4pruyd"), + GeohashChannel(GeohashChannelLevel.CITY, "u4pru"), + ) + + assertEquals( + listOf("u4pruyd", "u4pru", "saved123"), + geohashesForSampling( + availableChannels = channels, + bookmarks = listOf("saved123"), + notesRevealed = false, + ), + ) + assertEquals( + listOf("u4pruydq", "u4pruyd", "u4pru", "saved123"), + geohashesForSampling( + availableChannels = channels, + bookmarks = listOf("saved123"), + notesRevealed = true, + ), + ) + } +} From 4a34408db9b3d1fd04b7a0880148d5ea885e2024 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:45:07 +0300 Subject: [PATCH 2/2] Enhancement/apk sharing (#632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add QR code generator for Wi-Fi and URLs This commit introduces a `QrCodeGenerator` utility object to create QR code bitmaps for both Wi-Fi credentials and URLs. Key features: - **`generateWifiQr`**: Creates a QR code using the standard `WIFI:` format, allowing other devices to connect to a hotspot by scanning the code. It properly escapes special characters in the SSID and password. - **`generateUrlQr`**: Generates a standard QR code for any given URL. - **Implementation**: Uses the `zxing` library to encode the data and converts the resulting `BitMatrix` into an Android `Bitmap`. * feat: Add manager for universal APK sharing This commit introduces a comprehensive system for fetching, downloading, caching, and managing a "universal" APK of the app, intended for offline sharing with new users. The core components are: - `GitHubReleaseClient`: A new client to fetch the latest release information from the project's GitHub repository. It specifically looks for a universal APK asset in the release, parses its download URL, and attempts to extract its SHA256 checksum from the release notes. - `UniversalApkManager`: Manages the entire lifecycle of the universal APK. It handles: - Checking for new versions by comparing the cached APK version against the latest GitHub release. - Downloading the APK with progress reporting. - Verifying the downloaded file against the SHA256 checksum, if available. - Caching the APK and its metadata (version, checksum, size) locally. - Cleaning up old APK versions to conserve space. * feat: Add offline APK sharing via Wi-Fi hotspot This commit introduces a comprehensive feature for sharing the BitChat application offline using a self-hosted Wi-Fi Direct hotspot. This enables mesh network expansion by allowing users to distribute the app without requiring an internet connection. Key components: - **`HotspotManager`**: A new class that manages the creation and lifecycle of a Wi-Fi P2P (Wi-Fi Direct) group. It handles generating secure credentials (SSID/password), acquiring WakeLocks, and monitoring connected peers. It supports custom credentials on Android 10+ and falls back to system-generated ones on older versions. - **`ApkWebServer`**: A lightweight HTTP server based on `NanoHTTPD` that serves the APK file and a user-friendly HTML landing page to connected devices. - **`ApkSharingUtils`**: A utility to detect whether the app is installed as a single or split APK, collect the necessary files, and copy them to a cache directory for sharing. - **`ApkInstaller`**: A utility using the `PackageInstaller` API to handle the installation of single or split APKs received from another user. - **`HotspotActivity`**: A new Compose-based UI that guides the user through starting the hotspot, displays connection details (Wi-Fi credentials, QR codes for Wi-Fi and the download URL), and shows the number of connected peers. It also handles the necessary runtime permissions (`NEARBY_WIFI_DEVICES` or `ACCESS_FINE_LOCATION`). - **Configuration**: - Adds necessary Wi-Fi and P2P permissions to `AndroidManifest.xml`. - Defines a `FileProvider` path for APK sharing in `file_paths.xml`. - Adds numerous string resources for the new UI. * feat: Add offline and online app sharing features This commit introduces a comprehensive feature set for sharing the application, both offline via a Wi-Fi hotspot and online through standard Android sharing mechanisms. Key additions: - **Prepare for Sharing UI:** - Adds a "Prepare App for Sharing" option in the settings sheet. - This feature downloads a universal APK from a remote source, suitable for all Android devices. - The UI displays the status: not downloaded, downloading (with progress), ready, or if an update is available. - Users can download, update, or delete the cached universal APK. - **Offline Sharing via Wi-Fi Hotspot:** - Adds a "Share via Wi-Fi Hotspot" option. - This launches a new `HotspotActivity` to share the prepared universal APK with nearby devices without an internet connection. - A dialog informs the user if the APK hasn't been prepared yet. - **Online & Local Sharing:** - Adds an option to share via Bluetooth, email, etc., using the standard Android share sheet. - This method shares the *installed* version of the app, which may be a split APK. - An explanatory dialog is shown first, instructing the receiver on how to install split APKs if necessary. - Implements logic to correctly package and share single or multiple split APK files using `FileProvider`. * feat: Add NanoHTTPD for hotspot APK sharing This commit introduces the `nanohttpd` library, which will be used to implement an HTTP server for sharing the application's APK over a local hotspot. The specific dependency added is `org.nanohttpd:nanohttpd:2.3.1`. * refactor: Improve hotspot and APK sharing stability This commit introduces several fixes and refinements to the hotspot sharing and APK handling features, improving stability, user experience, and robustness. Key changes: - **Hotspot Flow:** - Automatically starts the hotspot after the user grants the required Wi-Fi permission, removing the need for a second button press. - Ensures all `HotspotManager` callbacks in `HotspotViewModel` are executed within `viewModelScope` to prevent threading issues and ensure safe UI updates. - Fixes a potential `BroadcastReceiver` leak in `HotspotManager` by tracking its registration state, preventing crashes and resource leaks when stopping the hotspot. - Changes the hotspot `WakeLock` to be non-expiring to prevent the CPU from sleeping while the hotspot is active. - **APK Handling & Installation:** - Adds a pre-download disk space check in `UniversalApkManager` to prevent download failures on devices with insufficient storage. - Improves the file move logic after download by falling back to a copy-and-delete strategy if a direct rename fails, making it more robust across different filesystems. - Introduces `InstallResultReceiver` to provide clear Toast notifications to the user about the success or failure of an APK installation, including specific error reasons (e.g., "Not enough storage"). - **Performance & UI:** - Caches the generated HTML in `ApkWebServer` to improve performance by avoiding regeneration on every request. - Throttles the APK download progress updates to prevent UI jankiness from too-frequent state changes. - Moves hardcoded strings in the "Share App" UI to `strings.xml` for better localization and maintenance. * feat: Refactor APK sharing to use universal APK This commit refactors the "Share App" functionality to exclusively use the new universal APK system, removing the previous logic that shared the installed split APKs. This simplifies the sharing process and ensures a consistent, single-file sharing experience for all users. Key changes: - Deletes `ApkSharingUtils.kt`, which was responsible for detecting and copying split APKs from the device's installation directory. - Updates `AboutSheet.kt` to use `UniversalApkManager` for all sharing actions (Hotspot and "Quick Share"). - Simplifies the sharing intent logic, as it now only needs to handle a single APK file (`ACTION_SEND`) instead of multiple files (`ACTION_SEND_MULTIPLE`). - The UI for sharing options (Hotspot, Quick Share) is now dynamically hidden until the universal APK is prepared, preventing user confusion. - Replaces hardcoded strings with string resources for better localization. * refactor: Remove InstallResultReceiver Deletes the `InstallResultReceiver` broadcast receiver. This component was responsible for handling the results of an APK installation initiated via `PackageInstaller`, but it is no longer used in the current implementation. * feat: Move APK download to resumable WorkManager pipeline Replaces the ViewModel-scoped coroutine download with a WorkManager-backed downloader so downloads survive app backgrounding and process death: - New ApkDownloader interface with WorkManagerApkDownloader implementation and ApkDownloadWorker (CoroutineWorker); transient IO errors return Result.retry() and resume via HTTP Range requests from the partial file. - UniversalApkManager gains resume support (Range header + persisted resume metadata) and verifies the downloaded APK is signed with the same certificate as the running app (no hardcoded fingerprint; debug-signed builds skip enforcement). - APK downloads now go through the shared OkHttpProvider so they respect the app's Tor proxy configuration instead of leaking the direct IP. - AboutSheet logic extracted into ApkDownloadViewModel (MVI: state/event/ effect), removing ~240 lines of UI-embedded logic. - Removes unused ApkInstaller (receivers install via the system installer). Co-Authored-By: Claude Fable 5 * fix: Harden hotspot server and release metadata fetching - ApkWebServer only serves the exact /bitchat.apk path instead of any *.apk-suffixed URI. - GitHubReleaseClient uses the shared OkHttpProvider (respects Tor proxy) and drops the loose 'any lone 64-hex string in the release notes is the checksum' fallback, which was spoofable. Co-Authored-By: Claude Fable 5 * fix: Address review findings in HotspotManager - Keep polling for group info while the group is still forming instead of silently stopping when the first requestGroupInfo() returns null, with a 15s formation timeout (Codex P1). - Release wake/wifi locks and unregister the broadcast receiver on terminal startup failures via failStartup(), so a failed attempt no longer leaks resources or blocks subsequent attempts (Codex P2). - Use PARTIAL_WAKE_LOCK with a 30-minute timeout instead of the deprecated FULL_WAKE_LOCK held indefinitely. Co-Authored-By: Claude Fable 5 * fix: harden offline APK sharing * fix: Handle fully-downloaded temp file before adding Range header If the process died after download_temp.apk was fully written but before verification/promotion, the next attempt sent "Range: bytes=-", GitHub answered 416, and the worker retried the same request forever, leaving the user stuck on an unresumable download. - Skip the network entirely when the temp file already holds the full asset and go straight to checksum/signature verification. - Treat an HTTP 416 response as an invalid resume offset: discard the partial state so the retry restarts from scratch instead of looping. Addresses the Codex review finding on UniversalApkManager. Co-Authored-By: Claude Fable 5 * fix: Allow sharing when the GitHub release lags the installed version Upstream bumps versionName in main before tagging the GitHub release, so there is a recurring window where the installed app is newer than the latest published universal APK. The hard version guard disabled the whole sharing feature during that window (including for reviewers building this branch at 1.7.5 while GitHub's latest is 1.7.4). An older release is still a genuine, signed, checksum-verified universal artifact, and Android already refuses downgrade installs on receivers, so: - checkForUpdate now logs (instead of erroring) when the latest release is older than the installed app and proceeds normally. - downloadUniversalApk no longer fails for an older-than-installed release. - A cached artifact stays shareable regardless of the installed version. The cached-artifact preference (never replace a newer cached APK with an older one) and all signature/checksum verification are unchanged. Co-Authored-By: Claude Fable 5 * feat: Run APK download as foreground (dataSync) work Long transfers — especially over Tor — can exceed WorkManager's ~10-minute background execution window, getting the worker stopped and rescheduled repeatedly. Promote the download to foreground work with a progress notification (cancel action included) so it can run to completion. - setForeground() with FOREGROUND_SERVICE_TYPE_DATA_SYNC; the manifest already holds the FOREGROUND_SERVICE_DATA_SYNC permission, and the WorkManager SystemForegroundService is merged with type dataSync. - If Android 12+ rejects the promotion (app backgrounded), the worker logs and continues as regular background work, relying on Range-resume. - Notification updates are throttled to 5% steps and degrade gracefully without POST_NOTIFICATIONS. Addresses the Codex review finding on ApkDownloadWorker. Co-Authored-By: Claude Fable 5 * fix: Move temp APK into place instead of copying it replaceFileSafely copied the source into a .new candidate before the atomic move, doubling peak disk usage: with free space between 1.5x and 2x the APK size, the download completed and then promotion failed on the copy, retrying against the same full temp file. Source and target always live in the same cache directory, so a direct ATOMIC_MOVE (rename) needs no extra space and keeps the same guarantee: it either fully succeeds or leaves both files intact. The existing 1.5x margin in checkDiskSpace is now genuinely sufficient. Addresses the Codex review finding on UniversalApkManager. Co-Authored-By: Claude Fable 5 * fix: Base the disk space check on remaining bytes when resuming The check ran before resume state was read and always demanded 1.5x the full APK size. Bytes already sitting in download_temp.apk have already consumed storage, so on a low-storage device an interrupted download could fail every resume with "Insufficient storage" even when only a small tail was left to fetch. Read the resume state first and check space for the remaining bytes only. A fresh download still checks the full size, and a complete temp file needs no extra space since promotion is a rename. Addresses the Codex review finding on UniversalApkManager. Co-Authored-By: Claude Fable 5 * fix: cancel APK downloads promptly --------- Co-authored-by: Claude Fable 5 Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> --- app/build.gradle.kts | 27 + app/src/main/AndroidManifest.xml | 26 +- .../bitchat/android/hotspot/ApkWebServer.kt | 323 +++++++ .../android/hotspot/HotspotActivity.kt | 684 +++++++++++++++ .../bitchat/android/hotspot/HotspotManager.kt | 503 +++++++++++ .../android/hotspot/HotspotViewModel.kt | 154 ++++ .../android/hotspot/QrCodeGenerator.kt | 126 +++ .../com/bitchat/android/net/ArtiTorManager.kt | 21 + .../java/com/bitchat/android/ui/AboutSheet.kt | 507 ++++++++++- .../android/ui/ApkDownloadViewModel.kt | 341 ++++++++ .../android/ui/debug/DebugSettingsSheet.kt | 109 +++ .../bitchat/android/util/ApkDownloadWorker.kt | 161 ++++ .../com/bitchat/android/util/ApkDownloader.kt | 36 + .../android/util/DistributionInfoProvider.kt | 191 ++++ .../android/util/GitHubReleaseClient.kt | 338 +++++++ .../android/util/UniversalApkManager.kt | 825 ++++++++++++++++++ .../android/util/WorkManagerApkDownloader.kt | 93 ++ app/src/main/res/values/strings.xml | 67 ++ app/src/main/res/xml/file_paths.xml | 4 + .../util/DistributionInfoProviderTest.kt | 57 ++ .../android/util/GitHubReleaseClientTest.kt | 99 +++ gradle.properties | 8 +- gradle/libs.versions.toml | 12 + 23 files changed, 4700 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt create mode 100644 app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt create mode 100644 app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt create mode 100644 app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt create mode 100644 app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt create mode 100644 app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt create mode 100644 app/src/main/java/com/bitchat/android/util/ApkDownloader.kt create mode 100644 app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt create mode 100644 app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt create mode 100644 app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt create mode 100644 app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7ee54989..84ea0cd9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,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() @@ -16,6 +31,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 { @@ -71,6 +91,7 @@ android { } buildFeatures { compose = true + buildConfig = true } packaging { resources { @@ -134,6 +155,12 @@ dependencies { // WebSocket implementation(libs.okhttp) + // WorkManager for background APK downloads + implementation(libs.androidx.work.runtime.ktx) + + // HTTP Server for hotspot APK sharing + implementation(libs.nanohttpd) + // Arti (Tor in Rust) Android bridge - custom build from latest source // Built with rustls, 16KB page size support, and onio//un service client // Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8c732bbc..0c71b94c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,13 +22,18 @@ - + - - + + + + + @@ -142,5 +147,20 @@ + + + + + + diff --git a/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt b/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt new file mode 100644 index 00000000..140f2971 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt @@ -0,0 +1,323 @@ +package com.bitchat.android.hotspot + +import android.content.Context +import android.util.Log +import fi.iki.elonen.NanoHTTPD +import java.io.File +import java.io.FileInputStream + +/** + * Lightweight HTTP server for serving the universal APK over Wi-Fi P2P hotspot. + * Based on NanoHTTPD. + */ +class ApkWebServer( + private val context: Context, + private val apkFile: File, + private val port: Int = DEFAULT_PORT +) : NanoHTTPD(port) { + + companion object { + private const val TAG = "ApkWebServer" + const val DEFAULT_PORT = 9999 + } + + private val appVersion: String by lazy { + try { + context.packageManager + .getPackageArchiveInfo(apkFile.absolutePath, 0) + ?.versionName + ?: "Unknown" + } catch (e: Exception) { + "Unknown" + } + } + + // Cache the HTML landing page (generated once, reused for all requests) + private val cachedHtml: String by lazy { + generateLandingPageHtml() + } + + override fun serve(session: IHTTPSession): Response { + val uri = session.uri ?: "/" + + Log.d(TAG, "Request: ${session.method} $uri from ${session.remoteIpAddress}") + + return when { + uri == "/bitchat.apk" -> { + serveApk() + } + uri == "/favicon.ico" -> { + newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found") + } + else -> { + serveLandingPage() + } + } + } + + /** + * Serve the APK file. + */ + private fun serveApk(): Response { + return try { + if (!apkFile.exists()) { + Log.e(TAG, "APK file not found: ${apkFile.path}") + return newFixedLengthResponse( + Response.Status.NOT_FOUND, + "text/plain", + "APK file not found" + ) + } + + Log.d(TAG, "Serving APK: ${apkFile.name} (${apkFile.length() / 1024 / 1024}MB)") + + val inputStream = FileInputStream(apkFile) + val response = newFixedLengthResponse( + Response.Status.OK, + "application/vnd.android.package-archive", + inputStream, + apkFile.length() + ) + + response.addHeader("Content-Disposition", "attachment; filename=\"bitchat-${appVersion}.apk\"") + response.addHeader("Accept-Ranges", "bytes") + + response + } catch (e: Exception) { + Log.e(TAG, "Error serving APK", e) + newFixedLengthResponse( + Response.Status.INTERNAL_ERROR, + "text/plain", + "Error serving APK: ${e.message}" + ) + } + } + + /** + * Serve the HTML landing page. + */ + private fun serveLandingPage(): Response { + return newFixedLengthResponse( + Response.Status.OK, + "text/html", + cachedHtml + ) + } + + /** + * Generate HTML landing page. + */ + private fun generateLandingPageHtml(): String { + val apkSizeMb = apkFile.length() / 1024 / 1024 + + return """ + + + + + + Download BitChat + + + +
+ +

BitChat

+

Secure Mesh Messaging

+ +
+
+
Version
+
$appVersion
+
+
+
Size
+
${apkSizeMb} MB
+
+
+ + + 📥 Download BitChat + + +
+

📱 Installation Instructions

+
    +
  1. Tap the download button above
  2. +
  3. Wait for the download to complete
  4. +
  5. Open the downloaded APK file
  6. +
  7. If prompted, enable "Install from unknown sources" for your browser
  8. +
  9. Follow the installation prompts
  10. +
+
+ +
+ ⚠️ Note: + If you already have BitChat installed, you may need to uninstall it first before installing this version. Make sure to backup your data if needed. +
+
+ + + """.trimIndent() + } + + /** + * Start the server. + */ + fun startServer() { + try { + start(NanoHTTPD.SOCKET_READ_TIMEOUT, false) + Log.d(TAG, "Web server started on port $port") + } catch (e: Exception) { + Log.e(TAG, "Failed to start web server", e) + throw e + } + } + + /** + * Stop the server. + */ + fun stopServer() { + try { + stop() + Log.d(TAG, "Web server stopped") + } catch (e: Exception) { + Log.e(TAG, "Error stopping web server", e) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt new file mode 100644 index 00000000..795cce34 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt @@ -0,0 +1,684 @@ +package com.bitchat.android.hotspot + +import android.Manifest +import android.content.Intent +import android.graphics.Bitmap +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatTheme +import com.bitchat.android.util.UniversalApkManager +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import java.io.File + +/** + * Activity for managing Wi-Fi P2P hotspot for offline APK sharing. + * Pure Compose implementation, no fragments. + */ +class HotspotActivity : ComponentActivity() { + + companion object { + const val EXTRA_APK_PATH = "apk_path" + private const val TAG = "HotspotActivity" + } + + private val viewModel: HotspotViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Get APK path from intent + val apkPath = intent.getStringExtra(EXTRA_APK_PATH) + val apkFile = if (apkPath != null) { + File(apkPath) + } else { + // Fallback: Try to get cached APK + UniversalApkManager(this).getCachedApk() + } + + if (apkFile == null || !apkFile.exists()) { + // No APK available, show error and finish + finish() + return + } + + setContent { + BitchatTheme { + HotspotScreen( + viewModel = viewModel, + apkFile = apkFile, + onClose = { finish() } + ) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // Handle notification action to stop hotspot + if (intent.action == "STOP_HOTSPOT") { + viewModel.stopHotspot() + finish() + } + } + +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HotspotScreen( + viewModel: HotspotViewModel, + apkFile: File, + onClose: () -> Unit +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Share BitChat", + fontFamily = FontFamily.Monospace + ) + }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { padding -> + Crossfade( + targetState = state, + label = "HotspotStateCrossfade", + modifier = Modifier.padding(padding) + ) { currentState -> + when (currentState) { + is HotspotViewModel.HotspotState.Intro -> { + IntroScreen( + onStartHotspot = { viewModel.startHotspot(apkFile) } + ) + } + is HotspotViewModel.HotspotState.Starting -> { + LoadingScreen() + } + is HotspotViewModel.HotspotState.Active -> { + ActiveHotspotScreen(state = currentState) + } + is HotspotViewModel.HotspotState.Error -> { + ErrorScreen( + message = currentState.message, + onRetry = { viewModel.resetToIntro() }, + onClose = onClose + ) + } + } + } + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun IntroScreen(onStartHotspot: () -> Unit) { + // Determine which permission to request based on Android version + val requiredPermission = 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 // No runtime permission needed on Android < 10 + } + + val permissionState = requiredPermission?.let { + rememberPermissionState(it) { granted -> + if (granted) { + onStartHotspot() + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Spacer(modifier = Modifier.height(32.dp)) + + Icon( + imageVector = Icons.Default.Wifi, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Text( + text = "Offline App Sharing", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "How it works:", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + InfoItem("1. Your device creates a Wi-Fi hotspot") + InfoItem("2. Others connect to your hotspot") + InfoItem("3. They scan a QR code or enter a URL") + InfoItem("4. BitChat downloads directly to their device") + } + } + + // Permission rationale (if needed) + if (permissionState != null && !permissionState.status.isGranted && permissionState.status.shouldShowRationale) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "ℹ️ Permission Required", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + "BitChat needs nearby devices permission to create a Wi-Fi hotspot for sharing the app offline." + } else { + "BitChat needs location permission to create a Wi-Fi hotspot. This is required by Android for Wi-Fi scanning, but no location data is collected." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + ) + } + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "⚠️ Note", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + Text( + text = "This will create a Wi-Fi hotspot on your device. Your current Wi-Fi connection may be interrupted.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = { + // Check permission before starting hotspot + if (permissionState == null || permissionState.status.isGranted) { + // No permission needed or already granted + onStartHotspot() + } else { + // Request permission (auto-start handled by onPermissionResult callback) + permissionState.launchPermissionRequest() + } + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp) + ) { + Text( + // 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 + ) + } + } +} + +@Composable +fun InfoItem(text: String) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top + ) { + Text( + text = "•", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } +} + +@Composable +fun LoadingScreen() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp) + ) + Text( + text = "Starting hotspot...", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } +} + +@Composable +fun ActiveHotspotScreen(state: HotspotViewModel.HotspotState.Active) { + var selectedTab by remember { mutableStateOf(0) } + val tabs = listOf("Wi-Fi", "Website") + + Column( + modifier = Modifier.fillMaxSize() + ) { + // Status banner + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Hotspot Active", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = "${state.connectedPeers} device(s) connected", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + } + Icon( + imageVector = Icons.Default.Wifi, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + } + } + + // Tabs + TabRow( + selectedTabIndex = selectedTab, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary + ) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { + Text( + text = title, + fontFamily = FontFamily.Monospace, + fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal + ) + } + ) + } + } + + // Tab content + when (selectedTab) { + 0 -> WifiTabContent( + ssid = state.ssid, + password = state.password + ) + 1 -> WebsiteTabContent( + ipAddress = state.ipAddress, + port = state.port + ) + } + } +} + +@Composable +fun WifiTabContent(ssid: String, password: String) { + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Text( + text = "Step 1: Connect to Wi-Fi", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Text( + text = "Have others scan this QR code to connect:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + + // QR Code + val qrSize = with(LocalDensity.current) { 280.dp.toPx().toInt() } + val wifiQr = remember(ssid, password, qrSize) { + QrCodeGenerator.generateWifiQr(ssid, password, qrSize) + } + + if (wifiQr != null) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(16.dp) + ) { + Image( + bitmap = wifiQr.asImageBitmap(), + contentDescription = "Wi-Fi QR Code", + modifier = Modifier.size(280.dp) + ) + } + } + + Text( + text = "Or enter manually:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + // SSID + CredentialCard( + label = "Network Name (SSID)", + value = ssid, + onCopy = { + clipboardManager.setText(AnnotatedString(ssid)) + } + ) + + // Password + CredentialCard( + label = "Password", + value = password, + onCopy = { + clipboardManager.setText(AnnotatedString(password)) + } + ) + } +} + +@Composable +fun WebsiteTabContent(ipAddress: String, port: Int) { + val url = "http://$ipAddress:$port" + val clipboardManager = LocalClipboardManager.current + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Text( + text = "Step 2: Download BitChat", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Text( + text = "After connecting to the Wi-Fi, scan this QR code:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + + // QR Code + val qrSize = with(LocalDensity.current) { 280.dp.toPx().toInt() } + val urlQr = remember(url, qrSize) { + QrCodeGenerator.generateUrlQr(url, qrSize) + } + + if (urlQr != null) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(16.dp) + ) { + Image( + bitmap = urlQr.asImageBitmap(), + contentDescription = "Website URL QR Code", + modifier = Modifier.size(280.dp) + ) + } + } + + Text( + text = "Or open in browser:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + // URL + CredentialCard( + label = "Website URL", + value = url, + onCopy = { + clipboardManager.setText(AnnotatedString(url)) + } + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "📱 Instructions", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = "1. Make sure you're connected to the Wi-Fi network above\n" + + "2. Open a web browser on your device\n" + + "3. Visit the URL above or scan the QR code\n" + + "4. Tap 'Download BitChat'\n" + + "5. Install the downloaded APK", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + } +} + +@Composable +fun CredentialCard( + label: String, + value: String, + onCopy: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + IconButton(onClick = onCopy) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy", + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } +} + +@Composable +fun ErrorScreen( + message: String, + onRetry: () -> Unit, + onClose: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "❌", + fontSize = 64.sp + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Error", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onRetry, + modifier = Modifier.fillMaxWidth() + ) { + Text("Try Again") + } + + Spacer(modifier = Modifier.height(8.dp)) + + TextButton(onClick = onClose) { + Text("Close") + } + } +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt new file mode 100644 index 00000000..bf2f3a93 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt @@ -0,0 +1,503 @@ +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 +import android.net.wifi.p2p.WifiP2pManager.* +import android.os.Build +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 + +/** + * Manages Wi-Fi P2P (Wi-Fi Direct) hotspot for offline APK sharing. + * Based on Briar's implementation. + */ +class HotspotManager(private val context: Context) { + + companion object { + private const val TAG = "HotspotMgr" + + // Retry configuration + private const val MAX_FRAMEWORK_ATTEMPTS = 5 + private const val RETRY_DELAY_MILLIS = 1000L + + // Group info polling interval + private const val GROUP_INFO_POLL_INTERVAL_MILLIS = 1000L + + // Give up if the group never forms within this window after creation succeeded + private const val GROUP_FORMATION_TIMEOUT_MILLIS = 15_000L + + // SSID and password configuration + private const val SSID_PREFIX = "DIRECT-BC-" // BC for BitChat + private const val SSID_SUFFIX_LENGTH = 8 + private const val PASSWORD_LENGTH = 16 + + // Characters to use for random generation (excluding confusing ones) + private const val RANDOM_CHARS = "ABCDEFGHJKLMNPQRTUVWXY34679" // No 0,O,5,S,1,l,I + } + + private val wifiP2pManager: WifiP2pManager? = + context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager + + private var channel: Channel? = null + private var wakeLock: PowerManager.WakeLock? = null + private var wifiLock: android.net.wifi.WifiManager.WifiLock? = null + + private val handler = Handler(Looper.getMainLooper()) + private val random = SecureRandom() + + private var currentGroup: WifiP2pGroup? = null + private var callback: HotspotCallback? = null + private var isStarting = false + private var hasNotifiedStarted = false // Track if we've notified the callback + private var isReceiverRegistered = false // Track receiver registration to prevent leaks + + // Saved credentials for reconnection + private var savedSsid: String? = null + private var savedPassword: String? = null + + // Broadcast receiver for Wi-Fi P2P events + private val broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> { + val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1) + Log.d(TAG, "Wi-Fi P2P state changed: $state") + } + WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> { + Log.d(TAG, "Wi-Fi P2P connection changed") + requestGroupInfo() + } + } + } + } + + /** + * Start the Wi-Fi P2P hotspot. + */ + fun startHotspot(callback: HotspotCallback) { + if (isStarting) { + Log.w(TAG, "Hotspot already starting") + return + } + + if (wifiP2pManager == null) { + Log.e(TAG, "Wi-Fi P2P not available on this device") + callback.onError("Wi-Fi Direct not supported on this device") + 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 + + Log.d(TAG, "Starting Wi-Fi P2P hotspot") + + // Register broadcast receiver (only if not already registered) + if (!isReceiverRegistered) { + val intentFilter = IntentFilter().apply { + addAction(WIFI_P2P_STATE_CHANGED_ACTION) + addAction(WIFI_P2P_CONNECTION_CHANGED_ACTION) + } + context.registerReceiver(broadcastReceiver, intentFilter) + isReceiverRegistered = true + Log.d(TAG, "Broadcast receiver registered") + } + + // Acquire locks + acquireLocks() + + // Load or generate credentials + if (savedSsid == null || savedPassword == null) { + savedSsid = generateSsid() + savedPassword = generatePassword() + Log.d(TAG, "Generated new credentials: SSID=$savedSsid") + } else { + Log.d(TAG, "Using saved credentials: SSID=$savedSsid") + } + + // Start P2P framework with retries + startWifiP2pFramework(1) + } + + /** + * Stop the hotspot. + */ + fun stopHotspot() { + Log.d(TAG, "Stopping hotspot") + + isStarting = false + hasNotifiedStarted = false + + // Stop group info polling + handler.removeCallbacksAndMessages(null) + + // Remove group + channel?.let { ch -> + wifiP2pManager?.removeGroup(ch, object : ActionListener { + override fun onSuccess() { + Log.d(TAG, "Group removed successfully") + } + override fun onFailure(reason: Int) { + Log.w(TAG, "Failed to remove group: $reason") + } + }) + } + + // Release locks + releaseLocks() + + // Unregister receiver (only if registered) + if (isReceiverRegistered) { + try { + context.unregisterReceiver(broadcastReceiver) + isReceiverRegistered = false + Log.d(TAG, "Broadcast receiver unregistered") + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Receiver was not registered", e) + isReceiverRegistered = false + } + } + + currentGroup = null + channel = null + callback = null + } + + /** + * Get current connection information. + */ + fun getConnectionInfo(): ConnectionInfo? { + val group = currentGroup ?: return null + val ipAddress = getAccessPointAddress() + + return ConnectionInfo( + ssid = group.networkName ?: savedSsid ?: "", + password = group.passphrase ?: savedPassword ?: "", + ipAddress = ipAddress ?: "192.168.49.1", // Fallback to standard P2P IP + connectedPeers = group.clientList?.size ?: 0 + ) + } + + /** + * Start Wi-Fi P2P framework with retry logic. + */ + private fun startWifiP2pFramework(attempt: Int) { + if (attempt > MAX_FRAMEWORK_ATTEMPTS) { + Log.e(TAG, "Failed to start P2P framework after $MAX_FRAMEWORK_ATTEMPTS attempts") + failStartup("Failed to start hotspot. Please try again.") + return + } + + Log.d(TAG, "Starting P2P framework (attempt $attempt/$MAX_FRAMEWORK_ATTEMPTS)") + + channel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null) + + if (channel == null) { + Log.e(TAG, "Failed to initialize P2P channel") + handler.postDelayed({ + startWifiP2pFramework(attempt + 1) + }, RETRY_DELAY_MILLIS) + return + } + + createGroup(attempt) + } + + /** + * Create Wi-Fi P2P group. + */ + @SuppressLint("MissingPermission") + private fun createGroup(attempt: Int) { + val ch = channel ?: return + + 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, 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.") + } + } + + 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) { + if (channel != null) { + handleGroupCreationFailure(reason, attempt) + } + } + } + + /** + * Handle group creation failure with retry logic. + */ + private fun handleGroupCreationFailure(reason: Int, attempt: Int) { + val reasonStr = when (reason) { + ERROR -> "ERROR" + P2P_UNSUPPORTED -> "P2P_UNSUPPORTED" + BUSY -> "BUSY" + else -> "UNKNOWN($reason)" + } + + Log.w(TAG, "Failed to create group: $reasonStr") + + if (reason == BUSY && attempt < MAX_FRAMEWORK_ATTEMPTS) { + // Framework is busy, retry + Log.d(TAG, "P2P framework busy, retrying...") + handler.postDelayed({ + startWifiP2pFramework(attempt + 1) + }, RETRY_DELAY_MILLIS) + } else { + failStartup("Failed to create hotspot: $reasonStr") + } + } + + /** + * Terminal startup failure: release all resources (locks, receiver, handler + * callbacks) before notifying the callback, so a failed attempt doesn't leak + * and block subsequent attempts. + */ + private fun failStartup(message: String) { + val cb = callback + stopHotspot() + cb?.onError(message) + } + + /** + * Start polling for group info to track connected clients. + */ + private fun startGroupInfoPolling() { + requestGroupInfo() + + // Keep polling even while the group info is still null — the first + // requestGroupInfo() after createGroup() can legitimately return null + // while the group is forming. Give up only after a timeout. + var elapsedMillis = 0L + handler.postDelayed(object : Runnable { + override fun run() { + if (channel == null) return + + elapsedMillis += GROUP_INFO_POLL_INTERVAL_MILLIS + if (currentGroup == null && !hasNotifiedStarted && + elapsedMillis >= GROUP_FORMATION_TIMEOUT_MILLIS + ) { + Log.e(TAG, "Group never formed within ${GROUP_FORMATION_TIMEOUT_MILLIS}ms") + failStartup("Hotspot failed to start. Please try again.") + return + } + + requestGroupInfo() + handler.postDelayed(this, GROUP_INFO_POLL_INTERVAL_MILLIS) + } + }, GROUP_INFO_POLL_INTERVAL_MILLIS) + } + + /** + * Request current group information. + */ + @SuppressLint("MissingPermission") + private fun requestGroupInfo() { + val ch = channel ?: return + + 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 + } + + // 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 { + 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 + } + } + + /** + * Acquire WakeLock and WifiLock to keep hotspot active. + */ + private fun acquireLocks() { + try { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "BitChat:HotspotWakeLock" + ) + wakeLock?.acquire(30 * 60 * 1000L) + + val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager + val lockType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF + } else { + android.net.wifi.WifiManager.WIFI_MODE_FULL + } + wifiLock = wifiManager.createWifiLock(lockType, "BitChat:HotspotWifiLock") + wifiLock?.acquire() + + Log.d(TAG, "Acquired WakeLock and WifiLock") + } catch (e: Exception) { + Log.e(TAG, "Error acquiring locks", e) + } + } + + /** + * Release WakeLock and WifiLock. + */ + private fun releaseLocks() { + try { + wakeLock?.let { + if (it.isHeld) { + it.release() + } + } + wakeLock = null + + wifiLock?.let { + if (it.isHeld) { + it.release() + } + } + wifiLock = null + + Log.d(TAG, "Released WakeLock and WifiLock") + } catch (e: Exception) { + Log.e(TAG, "Error releasing locks", e) + } + } + + /** + * Get the IP address of the P2P access point. + * Looks for network interface starting with "p2p". + */ + private fun getAccessPointAddress(): String? { + try { + val interfaces = NetworkInterface.getNetworkInterfaces() + while (interfaces.hasMoreElements()) { + val iface = interfaces.nextElement() + if (iface.name.startsWith("p2p")) { + val addresses = iface.interfaceAddresses + for (addr in addresses) { + val address = addr.address + // IPv4 only (4 bytes) + if (address.address.size == 4) { + return address.hostAddress + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error getting access point address", e) + } + return null + } + + /** + * Generate random SSID. + * Format: DIRECT-BC-XXXXXXXX + */ + private fun generateSsid(): String { + val suffix = (1..SSID_SUFFIX_LENGTH) + .map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] } + .joinToString("") + return "$SSID_PREFIX$suffix" + } + + /** + * Generate random password. + * 16 characters, excluding confusing characters. + */ + private fun generatePassword(): String { + return (1..PASSWORD_LENGTH) + .map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] } + .joinToString("") + } + + /** + * Connection information for the hotspot. + */ + data class ConnectionInfo( + val ssid: String, + val password: String, + val ipAddress: String, + val connectedPeers: Int + ) + + /** + * Callback interface for hotspot events. + */ + interface HotspotCallback { + fun onHotspotStarted() + fun onConnectionInfoUpdated(info: ConnectionInfo?) + fun onError(message: String) + } +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt new file mode 100644 index 00000000..50b04855 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt @@ -0,0 +1,154 @@ +package com.bitchat.android.hotspot + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File + +/** + * ViewModel for managing hotspot state and lifecycle. + */ +class HotspotViewModel(application: Application) : AndroidViewModel(application) { + + companion object { + private const val TAG = "HotspotViewModel" + } + + private val _state = MutableStateFlow(HotspotState.Intro) + val state: StateFlow = _state.asStateFlow() + + private var hotspotManager: HotspotManager? = null + private var webServer: ApkWebServer? = null + private val context = application.applicationContext + + /** + * Start the hotspot with the provided APK file. + */ + fun startHotspot(apkFile: File) { + if (_state.value is HotspotState.Starting || _state.value is HotspotState.Active) { + Log.w(TAG, "Hotspot already starting or active") + return + } + + Log.d(TAG, "Starting hotspot with APK: ${apkFile.name}") + _state.value = HotspotState.Starting + + viewModelScope.launch { + try { + // Start hotspot + val manager = HotspotManager(context) + hotspotManager = manager + + manager.startHotspot(object : HotspotManager.HotspotCallback { + override fun onHotspotStarted() { + viewModelScope.launch { + Log.d(TAG, "Hotspot started successfully") + + // Get connection info + val info = manager.getConnectionInfo() + if (info == null) { + manager.stopHotspot() + _state.value = HotspotState.Error("Failed to get hotspot connection info") + return@launch + } + + // Start web server + try { + val server = ApkWebServer(context, apkFile) + server.startServer() + webServer = server + + Log.d(TAG, "Web server started on port ${ApkWebServer.DEFAULT_PORT}") + + // Update state with connection info + _state.value = HotspotState.Active( + ssid = info.ssid, + password = info.password, + ipAddress = info.ipAddress, + port = ApkWebServer.DEFAULT_PORT, + connectedPeers = info.connectedPeers + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to start web server", e) + manager.stopHotspot() + _state.value = HotspotState.Error("Failed to start web server: ${e.message}") + } + } + } + + override fun onConnectionInfoUpdated(info: HotspotManager.ConnectionInfo?) { + viewModelScope.launch { + // Update peer count if we're active + val currentState = _state.value + if (currentState is HotspotState.Active && info != null) { + _state.value = currentState.copy(connectedPeers = info.connectedPeers) + } + } + } + + override fun onError(message: String) { + viewModelScope.launch { + Log.e(TAG, "Hotspot error: $message") + _state.value = HotspotState.Error(message) + } + } + }) + + } catch (e: Exception) { + Log.e(TAG, "Error starting hotspot", e) + hotspotManager?.stopHotspot() + _state.value = HotspotState.Error(e.message ?: "Unknown error") + } + } + } + + /** + * Stop the hotspot and web server. + */ + fun stopHotspot() { + Log.d(TAG, "Stopping hotspot") + + webServer?.stopServer() + webServer = null + + hotspotManager?.stopHotspot() + hotspotManager = null + + _state.value = HotspotState.Intro + } + + /** + * Reset to intro state (for retry after error). + */ + fun resetToIntro() { + stopHotspot() + _state.value = HotspotState.Intro + } + + override fun onCleared() { + super.onCleared() + Log.d(TAG, "ViewModel cleared, stopping hotspot") + stopHotspot() + } + + /** + * Hotspot state sealed class. + */ + sealed class HotspotState { + object Intro : HotspotState() + object Starting : HotspotState() + data class Active( + val ssid: String, + val password: String, + val ipAddress: String, + val port: Int, + val connectedPeers: Int + ) : HotspotState() + data class Error(val message: String) : HotspotState() + } +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt b/app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt new file mode 100644 index 00000000..69c2bdf1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt @@ -0,0 +1,126 @@ +package com.bitchat.android.hotspot + +import android.graphics.Bitmap +import android.util.Log +import androidx.core.graphics.createBitmap +import androidx.core.graphics.set +import com.google.zxing.BarcodeFormat +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.QRCodeWriter + +/** + * Utility for generating QR codes for Wi-Fi connection and URL. + */ +object QrCodeGenerator { + + private const val TAG = "QrCodeGenerator" + + /** + * Generate QR code for Wi-Fi connection. + * Format: WIFI:S:{SSID};T:WPA;P:{PASSWORD};; + * + * This format is recognized by most Android/iOS devices for instant Wi-Fi connection. + * + * @param ssid Wi-Fi network name + * @param password Wi-Fi password + * @param sizePx Size of the QR code in pixels + * @return Bitmap of the QR code, or null on error + */ + fun generateWifiQr(ssid: String, password: String, sizePx: Int): Bitmap? { + if (ssid.isBlank() || password.isBlank()) { + Log.w(TAG, "SSID or password is blank") + return null + } + + // Escape special characters + val escapedSsid = escapeWifiString(ssid) + val escapedPassword = escapeWifiString(password) + + // Format: WIFI:S:{SSID};T:WPA;P:{PASSWORD};; + val wifiString = "WIFI:S:$escapedSsid;T:WPA;P:$escapedPassword;;" + + Log.d(TAG, "Generating Wi-Fi QR code for SSID: $ssid") + + return generateQrBitmap(wifiString, sizePx) + } + + /** + * Generate QR code for URL. + * + * @param url Website URL (e.g., "http://192.168.49.1:9999") + * @param sizePx Size of the QR code in pixels + * @return Bitmap of the QR code, or null on error + */ + fun generateUrlQr(url: String, sizePx: Int): Bitmap? { + if (url.isBlank()) { + Log.w(TAG, "URL is blank") + return null + } + + Log.d(TAG, "Generating URL QR code: $url") + + return generateQrBitmap(url, sizePx) + } + + /** + * Generate QR code bitmap from string data. + * + * @param data String data to encode + * @param sizePx Size of the QR code in pixels + * @return Bitmap of the QR code, or null on error + */ + private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? { + if (data.isBlank() || sizePx <= 0) { + Log.w(TAG, "Invalid data or size: data.length=${data.length}, sizePx=$sizePx") + return null + } + + return try { + val matrix = QRCodeWriter().encode( + data, + BarcodeFormat.QR_CODE, + sizePx, + sizePx + ) + bitmapFromMatrix(matrix) + } catch (e: Exception) { + Log.e(TAG, "Error generating QR code", e) + null + } + } + + /** + * Convert BitMatrix to Bitmap. + * Pattern from VerificationSheet.kt. + */ + private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap { + val width = matrix.width + val height = matrix.height + val bitmap = createBitmap(width, height) + + for (x in 0 until width) { + for (y in 0 until height) { + bitmap[x, y] = if (matrix[x, y]) { + android.graphics.Color.BLACK + } else { + android.graphics.Color.WHITE + } + } + } + + return bitmap + } + + /** + * Escape special characters in Wi-Fi SSID/password for QR code format. + * Special characters that need escaping: \ ; , " : + */ + private fun escapeWifiString(input: String): String { + return input + .replace("\\", "\\\\") // Backslash must be escaped first + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\"", "\\\"") + .replace(":", "\\:") + } +} 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 f137ac63..7a64ff50 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -1,40 +1,91 @@ package com.bitchat.android.ui +import android.content.Intent +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Speed -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.bitchat.android.nostr.NostrProofOfWork -import com.bitchat.android.nostr.PoWPreferenceManager -import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.hotspot.HotspotActivity +import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager -import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.nostr.NostrProofOfWork +import com.bitchat.android.nostr.PoWPreferenceManager +import com.bitchat.android.util.UniversalApkManager /** * Feature row for displaying app capabilities @@ -452,6 +503,358 @@ fun AboutSheet( } } else null ) + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // === Prepare App for Sharing Section === + val apkViewModel: ApkDownloadViewModel = viewModel() + val apkUiState by apkViewModel.state.collectAsStateWithLifecycle() + val apkStatus = apkUiState.apkStatus + val downloadProgress = apkUiState.downloadProgress + + // Handle one-shot effects (navigation, toasts, share intents) + LaunchedEffect(Unit) { + apkViewModel.onEvent(ApkUiEvent.CheckStatus) + apkViewModel.effect.collect { effect -> + when (effect) { + is ApkUiEffect.NavigateToHotspot -> { + val intent = Intent(context, HotspotActivity::class.java) + intent.putExtra(HotspotActivity.EXTRA_APK_PATH, effect.apkPath) + context.startActivity(intent) + } + is ApkUiEffect.ShareApk -> { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/vnd.android.package-archive" + putExtra(Intent.EXTRA_STREAM, effect.apkUri) + clipData = android.content.ClipData.newRawUri("", effect.apkUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val chooser = Intent.createChooser(intent, effect.chooserTitle).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(chooser) + } + is ApkUiEffect.ShowToast -> { + Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() + } + } + } + } + + // Prepare App for Sharing Row + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = apkStatus !is ApkPreparationStatus.Downloading) { + apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked) + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (apkStatus is ApkPreparationStatus.Ready) { + Icons.Default.Share + } else { + Icons.Default.CloudDownload + }, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + 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 + ) + Text( + 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 -> { + 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" + is ApkPreparationStatus.Error -> status.message + }, + style = MaterialTheme.typography.bodySmall, + color = when (apkStatus) { + is ApkPreparationStatus.Error -> colorScheme.error + is ApkPreparationStatus.Resumable -> colorScheme.primary + is ApkPreparationStatus.UpdateAvailable -> colorScheme.primary + else -> colorScheme.onSurface.copy(alpha = 0.6f) + }, + lineHeight = 16.sp + ) + } + + // Action buttons + when (apkStatus) { + is ApkPreparationStatus.Downloading -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + 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) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = colorScheme.error, + modifier = Modifier.size(20.dp) + ) + } + } + else -> {} + } + } + + // Prepare Dialog + if (apkUiState.showPrepareDialog) { + val status = apkStatus + val sizeMB: Int? = when (status) { + is ApkPreparationStatus.NotDownloaded -> status.sizeMB + is ApkPreparationStatus.UpdateAvailable -> status.newSizeMB + else -> null + } + AlertDialog( + onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) }, + title = { + Text( + text = if (status is ApkPreparationStatus.UpdateAvailable) { + stringResource(R.string.prepare_apk_update_dialog_title) + } else { + stringResource(R.string.prepare_apk_dialog_title) + }, + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Text( + text = if (status is ApkPreparationStatus.UpdateAvailable) { + stringResource(R.string.prepare_apk_update_dialog_message, status.newVersion, status.currentVersion) + } 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 + ) + }, + confirmButton = { + Button(onClick = { + apkViewModel.onEvent(ApkUiEvent.ConfirmDownload) + }) { + Text(stringResource(R.string.prepare_apk_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) }) { + Text(stringResource(R.string.cancel)) + } + }, + containerColor = colorScheme.surface + ) + } + + // Delete Dialog + if (apkUiState.showDeleteDialog) { + val sizeMB = (apkStatus as? ApkPreparationStatus.Ready)?.sizeMB ?: 0 + AlertDialog( + onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissDeleteDialog) }, + title = { + Text( + text = stringResource(R.string.prepare_apk_delete_confirm), + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Text( + text = stringResource(R.string.prepare_apk_delete_message, sizeMB), + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + Button( + onClick = { + apkViewModel.onEvent(ApkUiEvent.ConfirmDelete) + }, + colors = androidx.compose.material3.ButtonDefaults.buttonColors( + containerColor = colorScheme.error + ) + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { apkViewModel.onEvent(ApkUiEvent.DismissDeleteDialog) }) { + Text(stringResource(R.string.cancel)) + } + }, + containerColor = colorScheme.surface + ) + } + + // Show sharing rows only when APK is ready + val canShareAPK = apkStatus is ApkPreparationStatus.Ready || + apkStatus is ApkPreparationStatus.UpdateAvailable + + AnimatedVisibility( + visible = canShareAPK, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column { + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // === Share via Hotspot Row === + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + apkViewModel.onEvent(ApkUiEvent.HotspotShareClicked) + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Wifi, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.hotspot_share_via), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = stringResource(R.string.hotspot_share_via_subtitle), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.6f), + lineHeight = 16.sp + ) + } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = colorScheme.onSurface.copy(alpha = 0.4f), + modifier = Modifier.size(20.dp) + ) + } + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // === Share via Bluetooth/Email Row (Fallback) === + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { apkViewModel.onEvent(ApkUiEvent.AppShareClicked) } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Bluetooth, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.hotspot_share_other), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = stringResource(R.string.hotspot_share_other_subtitle), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.6f), + lineHeight = 16.sp + ) + } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = colorScheme.onSurface.copy(alpha = 0.4f), + modifier = Modifier.size(20.dp) + ) + } + + // APK Share Dialog + ApkShareExplanationDialog( + show = apkUiState.showShareApkDialog, + onConfirm = { + apkViewModel.onEvent(ApkUiEvent.ConfirmAppShare) + }, + onDismiss = { apkViewModel.onEvent(ApkUiEvent.DismissShareDialog) } + ) + } + } + } } @@ -741,3 +1144,91 @@ fun PasswordPromptDialog( ) } } + + +/** + * Dialog explaining APK sharing feature before sharing + */ +@Composable +private fun ApkShareExplanationDialog( + show: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + if (show) { + val colorScheme = MaterialTheme.colorScheme + + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Default.Share, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + }, + title = { + Text( + text = stringResource(R.string.share_apk_title), + style = MaterialTheme.typography.titleLarge, + color = colorScheme.onSurface + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResource(R.string.share_apk_explanation), + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurface + ) + + // Info box with receiver instructions + Surface( + color = colorScheme.primaryContainer.copy(alpha = 0.3f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(R.string.share_apk_receiver_instructions), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.8f), + lineHeight = 18.sp + ) + } + } + } + }, + confirmButton = { + Button(onClick = onConfirm) { + Text( + text = stringResource(R.string.share_apk_confirm), + style = MaterialTheme.typography.bodyMedium + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(R.string.cancel), + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurface + ) + } + }, + containerColor = colorScheme.surface, + tonalElevation = 8.dp + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt new file mode 100644 index 00000000..0b4e0005 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -0,0 +1,341 @@ +package com.bitchat.android.ui + +import android.app.Application +import android.util.Log +import androidx.core.content.FileProvider +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.bitchat.android.R +import com.bitchat.android.util.ApkDownloader +import com.bitchat.android.util.UniversalApkManager +import com.bitchat.android.util.WorkManagerApkDownloader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +// --- State --- + +sealed class ApkPreparationStatus { + object Loading : 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, + val newSizeMB: Int + ) : ApkPreparationStatus() + object Downloading : ApkPreparationStatus() + data class Resumable(val progressPercent: Int, val message: String) : ApkPreparationStatus() + data class Error(val message: String) : ApkPreparationStatus() +} + +data class ApkUiState( + val apkStatus: ApkPreparationStatus = ApkPreparationStatus.Loading, + val downloadProgress: Int = 0, + val showPrepareDialog: Boolean = false, + val showDeleteDialog: Boolean = false, + val showShareApkDialog: Boolean = false +) + +// --- Events (UI → ViewModel) --- + +sealed class ApkUiEvent { + object CheckStatus : ApkUiEvent() + object PrepareRowClicked : ApkUiEvent() + object ConfirmDownload : ApkUiEvent() + object DismissPrepareDialog : ApkUiEvent() + object DeleteClicked : ApkUiEvent() + object ConfirmDelete : ApkUiEvent() + object DismissDeleteDialog : ApkUiEvent() + object HotspotShareClicked : ApkUiEvent() + object AppShareClicked : ApkUiEvent() + object ConfirmAppShare : ApkUiEvent() + object DismissShareDialog : ApkUiEvent() + object CancelDownload : ApkUiEvent() +} + +// --- Effects (ViewModel → UI, one-shot) --- + +sealed class ApkUiEffect { + data class NavigateToHotspot(val apkPath: String) : ApkUiEffect() + data class ShareApk(val apkUri: android.net.Uri, val chooserTitle: String) : ApkUiEffect() + data class ShowToast(val message: String) : ApkUiEffect() +} + +/** + * ViewModel for APK download/status/share logic following MVI pattern. + * UI sends [ApkUiEvent], observes [ApkUiState], and collects [ApkUiEffect]. + */ +class ApkDownloadViewModel(application: Application) : AndroidViewModel(application) { + + companion object { + private const val TAG = "ApkDownloadVM" + } + + private val apkManager = UniversalApkManager(application) + private val downloader: ApkDownloader = WorkManagerApkDownloader(application) + + private val _state = MutableStateFlow(ApkUiState()) + val state: StateFlow = _state.asStateFlow() + + private val _effect = Channel(Channel.BUFFERED) + val effect = _effect.receiveAsFlow() + + init { + observeDownloader() + } + + fun onEvent(event: ApkUiEvent) { + when (event) { + is ApkUiEvent.CheckStatus -> checkStatus() + is ApkUiEvent.PrepareRowClicked -> onPrepareRowClicked() + is ApkUiEvent.ConfirmDownload -> onConfirmDownload() + is ApkUiEvent.DismissPrepareDialog -> _state.update { it.copy(showPrepareDialog = false) } + is ApkUiEvent.DeleteClicked -> _state.update { it.copy(showDeleteDialog = true) } + is ApkUiEvent.ConfirmDelete -> onConfirmDelete() + is ApkUiEvent.DismissDeleteDialog -> _state.update { it.copy(showDeleteDialog = false) } + is ApkUiEvent.HotspotShareClicked -> onHotspotShareClicked() + is ApkUiEvent.AppShareClicked -> _state.update { it.copy(showShareApkDialog = true) } + is ApkUiEvent.ConfirmAppShare -> onConfirmAppShare() + is ApkUiEvent.DismissShareDialog -> _state.update { it.copy(showShareApkDialog = false) } + is ApkUiEvent.CancelDownload -> onCancelDownload() + } + } + + private fun onPrepareRowClicked() { + when (_state.value.apkStatus) { + is ApkPreparationStatus.NotDownloaded, + is ApkPreparationStatus.UpdateAvailable, + is ApkPreparationStatus.Error -> { + _state.update { it.copy(showPrepareDialog = true) } + } + is ApkPreparationStatus.Resumable -> { + startDownload() + } + else -> {} + } + } + + private fun onConfirmDownload() { + _state.update { it.copy(showPrepareDialog = false) } + startDownload() + } + + private fun onConfirmDelete() { + _state.update { it.copy(showDeleteDialog = false) } + downloader.cancelDownload() + apkManager.deleteCachedApk() + checkStatus() + } + + private fun onHotspotShareClicked() { + val apkFile = apkManager.getCachedApk() + if (apkFile != null) { + viewModelScope.launch { + _effect.send(ApkUiEffect.NavigateToHotspot(apkFile.absolutePath)) + } + } else { + sendToast(getString(R.string.apk_not_ready_please_prepare_it_first)) + } + } + + private fun onConfirmAppShare() { + _state.update { it.copy(showShareApkDialog = false) } + viewModelScope.launch(Dispatchers.IO) { + try { + val apkFile = apkManager.getCachedApk() + if (apkFile == null || !apkFile.exists()) { + sendToast(getString(R.string.apk_not_ready_please_prepare_it_first)) + return@launch + } + + val context = getApplication() + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + apkFile + ) + _effect.send( + ApkUiEffect.ShareApk( + apkUri = uri, + chooserTitle = getString(R.string.share_apk_chooser_title) + ) + ) + } catch (e: Exception) { + Log.e(TAG, "Error preparing APK share", e) + sendToast(getString(R.string.share_apk_error)) + } + } + } + + private fun onCancelDownload() { + downloader.cancelDownload() + checkStatus() + } + + private fun startDownload() { + val partial = apkManager.getPartialDownloadProgress() + _state.update { + it.copy( + apkStatus = ApkPreparationStatus.Downloading, + downloadProgress = partial ?: 0 + ) + } + downloader.startDownload() + } + + private fun checkStatus() { + viewModelScope.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 + } + + val resolvedStatus = resolveApkStatus() + _state.update { current -> + if (current.apkStatus is ApkPreparationStatus.Downloading) { + current + } else { + current.copy(apkStatus = resolvedStatus) + } + } + } + } + + private fun observeDownloader() { + viewModelScope.launch { + downloader.downloadState.collect { downloadState -> + when (downloadState) { + is ApkDownloader.DownloadState.Idle -> { + // Don't overwrite — status set by checkStatus() + } + is ApkDownloader.DownloadState.Downloading -> { + _state.update { + it.copy( + apkStatus = ApkPreparationStatus.Downloading, + downloadProgress = downloadState.progressPercent + ) + } + } + is ApkDownloader.DownloadState.Success -> { + val info = apkManager.getCachedApkInfo() + _state.update { + it.copy( + apkStatus = ApkPreparationStatus.Ready( + version = downloadState.version, + sizeMB = downloadState.sizeMB, + source = info?.source ?: UniversalApkManager.ApkSource.GITHUB + ), + downloadProgress = 100 + ) + } + } + is ApkDownloader.DownloadState.Failed -> { + _state.update { + if (downloadState.resumablePercent != null) { + it.copy( + apkStatus = ApkPreparationStatus.Resumable( + progressPercent = downloadState.resumablePercent, + message = downloadState.message + ), + downloadProgress = downloadState.resumablePercent + ) + } else { + it.copy(apkStatus = ApkPreparationStatus.Error(downloadState.message)) + } + } + } + } + } + } + } + + private fun sendToast(message: String) { + viewModelScope.launch { + _effect.send(ApkUiEffect.ShowToast(message)) + } + } + + private fun getString(resId: Int): String { + return getApplication().getString(resId) + } + + private suspend fun resolveApkStatus(): ApkPreparationStatus = withContext(Dispatchers.IO) { + try { + val updateStatus = apkManager.checkForUpdate() + when (updateStatus) { + is UniversalApkManager.UpdateStatus.NotDownloaded -> { + val partial = apkManager.getPartialDownloadProgress() + if (partial != null) { + ApkPreparationStatus.Resumable( + progressPercent = partial, + message = getString(R.string.prepare_apk_download_interrupted) + ) + } else { + ApkPreparationStatus.NotDownloaded( + sizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt() + ) + } + } + is UniversalApkManager.UpdateStatus.UpToDate -> { + val info = apkManager.getCachedApkInfo() + if (info != null) { + ApkPreparationStatus.Ready( + version = info.version, + sizeMB = (info.size / 1024 / 1024).toInt(), + source = info.source + ) + } else { + ApkPreparationStatus.Error("Cached APK info not found") + } + } + is UniversalApkManager.UpdateStatus.UpdateAvailable -> { + ApkPreparationStatus.UpdateAvailable( + currentVersion = updateStatus.currentVersion, + newVersion = updateStatus.latestRelease.versionName, + newSizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt() + ) + } + is UniversalApkManager.UpdateStatus.Error -> { + // A cached artifact stays shareable even when the update + // check fails or the release lags the installed version. + val info = apkManager.getCachedApkInfo() + if (info != null) { + ApkPreparationStatus.Ready( + version = info.version, + sizeMB = (info.size / 1024 / 1024).toInt(), + source = info.source + ) + } else { + val partial = apkManager.getPartialDownloadProgress() + if (partial != null) { + ApkPreparationStatus.Resumable( + progressPercent = partial, + message = getString(R.string.prepare_apk_download_interrupted) + ) + } else { + ApkPreparationStatus.Error(updateStatus.message) + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error checking APK status", e) + ApkPreparationStatus.Error( + e.message ?: getString(R.string.prepare_apk_error_github) + ) + } + } +} 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 be950316..32427c44 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.* @@ -45,6 +48,9 @@ import com.bitchat.android.onboarding.PermissionManager 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( @@ -102,6 +108,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) @@ -129,6 +224,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() @@ -217,6 +315,14 @@ fun DebugSettingsSheet( } } + LaunchedEffect(isPresented) { + if (isPresented) { + distributionInfo = withContext(Dispatchers.IO) { + runCatching { DistributionInfoProvider.inspect(context) }.getOrNull() + } + } + } + val scope = rememberCoroutineScope() if (!isPresented) return @@ -246,6 +352,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 new file mode 100644 index 00000000..b8285321 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt @@ -0,0 +1,161 @@ +package com.bitchat.android.util + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.bitchat.android.R + +/** + * WorkManager worker that downloads the universal APK in the background. + * Survives app backgrounding and process death. Transient network errors are + * retried with backoff; partial downloads resume via HTTP Range requests. + * + * Runs as foreground (dataSync) work when possible so slow transfers (e.g. + * over Tor) are not killed by WorkManager's background execution window. + */ +class ApkDownloadWorker( + appContext: Context, + params: WorkerParameters +) : CoroutineWorker(appContext, params) { + + companion object { + const val TAG = "ApkDownloadWorker" + const val WORK_NAME = "apk_download" + + // Progress keys + const val KEY_PROGRESS = "progress" + const val KEY_VERSION = "version" + const val KEY_SIZE_MB = "size_mb" + const val KEY_ERROR = "error" + const val KEY_RESUMABLE_PERCENT = "resumable_percent" + + private const val MAX_RETRIES = 3 + + private const val CHANNEL_ID = "apk_download" + private const val NOTIFICATION_ID = 4201 + private const val NOTIFY_STEP_PERCENT = 5 + } + + private val apkManager = UniversalApkManager(applicationContext) + private val notificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + private var lastNotifiedProgress = -NOTIFY_STEP_PERCENT + + override suspend fun doWork(): Result { + Log.d(TAG, "Starting APK download work") + + // Promote to foreground so long transfers aren't stopped by the + // ~10-minute background execution window. Android 12+ can reject the + // promotion when the app is backgrounded — continue as regular + // background work and rely on Range-resume in that case. + try { + setForeground(createForegroundInfo(apkManager.getPartialDownloadProgress() ?: 0)) + } catch (e: Exception) { + Log.w(TAG, "Could not promote download to foreground work", e) + } + + val result = apkManager.downloadUniversalApk { progress -> + setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, progress).build()) + updateNotification(progress) + } + + return if (result.isSuccess) { + val info = apkManager.getCachedApkInfo() + val outputData = Data.Builder() + .putString(KEY_VERSION, info?.version ?: "") + .putInt(KEY_SIZE_MB, ((info?.size ?: 0L) / 1024 / 1024).toInt()) + .build() + Result.success(outputData) + } else { + val error = result.exceptionOrNull() + + // Retry transient network errors with backoff; the partial file + // is kept on disk, so the retry resumes where it left off. + 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() + } + + val partial = apkManager.getPartialDownloadProgress() + val outputData = Data.Builder() + .putString(KEY_ERROR, error?.message ?: "Download failed") + .putInt(KEY_RESUMABLE_PERCENT, partial ?: -1) + .build() + Result.failure(outputData) + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + return createForegroundInfo(apkManager.getPartialDownloadProgress() ?: 0) + } + + private fun createForegroundInfo(progress: Int): ForegroundInfo { + ensureChannel() + val notification = buildNotification(progress) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + ForegroundInfo(NOTIFICATION_ID, notification) + } + } + + private fun buildNotification(progress: Int): android.app.Notification { + val cancelIntent = WorkManager.getInstance(applicationContext) + .createCancelPendingIntent(id) + + return NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setContentTitle(applicationContext.getString(R.string.apk_download_notification_title)) + .setSmallIcon(R.drawable.ic_notification) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setProgress(100, progress, progress <= 0) + .addAction( + android.R.drawable.ic_delete, + applicationContext.getString(android.R.string.cancel), + cancelIntent + ) + .build() + } + + private fun updateNotification(progress: Int) { + if (progress - lastNotifiedProgress < NOTIFY_STEP_PERCENT) return + lastNotifiedProgress = progress + try { + notificationManager.notify(NOTIFICATION_ID, buildNotification(progress)) + } catch (e: Exception) { + // Missing POST_NOTIFICATIONS permission just drops the update; + // the download itself is unaffected. + Log.w(TAG, "Could not update download notification", e) + } + } + + private fun ensureChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + applicationContext.getString(R.string.apk_download_channel_name), + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(channel) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt new file mode 100644 index 00000000..3bf234ae --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt @@ -0,0 +1,36 @@ +package com.bitchat.android.util + +import kotlinx.coroutines.flow.Flow + +/** + * Interface for APK download operations. + * Abstracts the download mechanism so it can be swapped + * (e.g., WorkManager, ForegroundService, plain coroutine). + */ +interface ApkDownloader { + + /** + * Current download state as an observable flow. + */ + val downloadState: Flow + + /** + * Start or resume a download. If a partial download exists, it resumes automatically. + */ + fun startDownload() + + /** + * Cancel an in-progress download. The partial file is kept for future resume. + */ + fun cancelDownload() + + /** + * Download state reported by the downloader. + */ + sealed class DownloadState { + object Idle : DownloadState() + data class Downloading(val progressPercent: Int) : DownloadState() + data class Success(val version: String, val sizeMB: Int) : DownloadState() + data class Failed(val message: String, val resumablePercent: Int?) : DownloadState() + } +} \ 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 new file mode 100644 index 00000000..699637bd --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -0,0 +1,338 @@ +package com.bitchat.android.util + +import android.util.Log +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. + */ +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 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. + * 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(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+json") + .addHeader("X-GitHub-Api-Version", "2022-11-28") + .build() + + 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 + ) + ) + } + + 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) + } + } catch (e: IOException) { + Log.e(TAG, "Network error fetching release", e) + Result.failure( + ReleaseFetchException( + "Could not reach GitHub${e.message?.let { ": $it" } ?: ""}", + cause = e + ) + ) + } catch (e: Exception) { + Log.e(TAG, "Error fetching release", e) + 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. + */ + internal fun parseRelease(jsonString: String): Release? { + try { + val json = JSONObject(jsonString) + val tagName = json.optString("tag_name", "") + val versionName = tagName.removePrefix("v") // Remove "v" prefix if present + + if (versionName.isBlank()) { + Log.e(TAG, "No version tag found in release") + return null + } + + Log.d(TAG, "Found release: $versionName") + + // Parse assets array to find universal APK + val assets = json.optJSONArray("assets") + if (assets == null || assets.length() == 0) { + Log.e(TAG, "No assets found in release") + return null + } + + // Look for universal APK (usually named "app-universal-release.apk") + for (i in 0 until assets.length()) { + val asset = assets.getJSONObject(i) + val name = asset.optString("name", "") + + if (name.contains("universal", ignoreCase = true) && name.endsWith(".apk")) { + val downloadUrl = asset.optString("browser_download_url", "") + val size = asset.optLong("size", 0L) + + if (downloadUrl.isBlank()) { + Log.e(TAG, "Universal APK found but no download URL") + continue + } + + // Prefer GitHub's asset digest when available, then fall + // back to release notes used by older releases. + val body = json.optString("body", "") + 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)") + + return Release( + tagName = tagName, + versionName = versionName, + universalApkUrl = downloadUrl, + universalApkSha256 = sha256, + universalApkSize = size, + universalApkName = name + ) + } + } + + Log.e(TAG, "No universal APK found in release assets") + return null + + } catch (e: Exception) { + Log.e(TAG, "Error parsing release JSON", e) + return null + } + } + + /** + * Extract SHA256 checksum from release body/notes. + * Looks for patterns like: + * - sha256:abc123... + * - SHA256: abc123... + * - app-universal-release.apk: abc123... + */ + private fun extractSha256FromBody(body: String, apkName: String): String? { + if (body.isBlank()) return null + + try { + // Pattern 1: Look for "sha256:" followed by hash + val sha256Pattern = Regex("""sha256:\s*([a-fA-F0-9]{64})""", RegexOption.IGNORE_CASE) + sha256Pattern.find(body)?.let { match -> + return match.groupValues[1].lowercase() + } + + // Pattern 2: Look for APK name followed by hash + val apkPattern = Regex("""${Regex.escape(apkName)}.*?([a-fA-F0-9]{64})""", RegexOption.IGNORE_CASE) + apkPattern.find(body)?.let { match -> + return match.groupValues[1].lowercase() + } + + Log.w(TAG, "Could not extract SHA256 from release body") + return null + + } catch (e: Exception) { + Log.w(TAG, "Error extracting SHA256", e) + return null + } + } + + /** + * Check if a newer version is available. + * @param currentVersion Current installed/cached version + * @param latestRelease Latest release from GitHub + * @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 = candidateVersion.removePrefix("v").trim() + + if (current == latest) { + return false + } + + // Split by dots and compare each part + val currentParts = current.split(".").mapNotNull { it.toIntOrNull() } + val latestParts = latest.split(".").mapNotNull { it.toIntOrNull() } + + val maxLength = maxOf(currentParts.size, latestParts.size) + + for (i in 0 until maxLength) { + val currentPart = currentParts.getOrNull(i) ?: 0 + val latestPart = latestParts.getOrNull(i) ?: 0 + + if (latestPart > currentPart) { + return true + } else if (latestPart < currentPart) { + return false + } + } + + false + } catch (e: Exception) { + Log.e(TAG, "Error comparing versions", e) + false + } + } + + /** + * Release information from GitHub. + */ + data class Release( + val tagName: String, + val versionName: String, + val universalApkUrl: String, + val universalApkSha256: String?, + 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 new file mode 100644 index 00000000..ab1e97f1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -0,0 +1,825 @@ +package com.bitchat.android.util + +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.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Request +import okhttp3.Response +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 + +/** + * Manages downloading, caching, and verifying the universal APK for offline sharing. + */ +class UniversalApkManager(private val context: Context) { + + companion object { + private const val TAG = "UniversalApk" + private const val CACHE_DIR_NAME = "universal_apk" + private const val METADATA_FILE_NAME = "universal_apk_info.json" + private const val PROGRESS_FILE_NAME = "download_progress.json" + private const val APK_FILE_PREFIX = "bitchat-universal-" + + // Download buffer size (128KB) + private const val BUFFER_SIZE = 128 * 1024 + } + + private val cacheDir: File + get() = File(context.cacheDir, CACHE_DIR_NAME).also { it.mkdirs() } + + private val metadataFile: File get() = File(cacheDir, METADATA_FILE_NAME) + private val progressFile: File get() = File(cacheDir, PROGRESS_FILE_NAME) + + // Download client: inherits Tor proxy settings but with no call timeout + // for large file downloads that can take minutes + 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. + */ + fun getCachedApkInfo(): ApkInfo? { + return try { + if (!metadataFile.exists()) { + return null + } + + val json = JSONObject(metadataFile.readText()) + val version = json.optString("version", "") + val checksum = json.optString("checksum", "") + 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 + } + + val apkFile = File(cacheDir, fileName) + if (!apkFile.exists()) { + Log.w(TAG, "Metadata exists but APK file not found: ${apkFile.path}") + return null + } + + ApkInfo( + version = version, + checksum = checksum, + downloadDate = downloadDate, + size = size, + file = apkFile, + source = source + ) + } catch (e: Exception) { + Log.e(TAG, "Error reading cached APK info", e) + null + } + } + + /** + * Get the cached APK file, if it exists. + */ + fun getCachedApk(): File? { + return getCachedApkInfo()?.file + } + + /** + * Check if a partial (resumable) download exists. + * Returns the progress percentage (0-100) or null if no partial download. + */ + fun getPartialDownloadProgress(): Int? { + val tempFile = File(cacheDir, "download_temp.apk") + val resumeInfo = loadResumeInfo() + if (tempFile.exists() && resumeInfo != null) { + val expectedSize = resumeInfo.optLong("expectedSize", 0L) + if (expectedSize > 0) { + return ((tempFile.length() * 100) / expectedSize).toInt().coerceIn(0, 99) + } + } + return null + } + + /** + * Check for updates from GitHub. + * @return UpdateStatus indicating if update is available, current version, etc. + */ + suspend fun checkForUpdate(): UpdateStatus = withContext(Dispatchers.IO) { + try { + // A genuinely universal standalone APK is already an installable + // sharing artifact. Architecture-specific standalone APKs and split + // installs still need the universal GitHub artifact. + val installedApkInfo = cacheInstalledApkIfPreferred() + if (installedApkInfo != null) { + return@withContext UpdateStatus.UpToDate(installedApkInfo.version) + } + + val cachedInfo = getCachedApkInfo() + val latestRelease = GitHubReleaseClient.fetchLatestRelease().getOrElse { error -> + return@withContext UpdateStatus.Error( + error.message ?: "Failed to fetch latest release from GitHub" + ) + } + // The GitHub release may briefly lag behind the installed version + // (upstream bumps versionName in main before tagging the release). + // An older release is still a genuine, signed, universal artifact — + // recipients with a newer install can't be downgraded by Android + // anyway — so share it rather than disabling the feature. + if (isOlderThanInstalledVersion(latestRelease.versionName)) { + Log.i( + TAG, + "GitHub universal APK ${latestRelease.versionName} is older than installed " + + "app ${installedVersionName()}; sharing it until the matching release ships" + ) + } + + if (cachedInfo == null) { + // No cached APK + return@withContext UpdateStatus.NotDownloaded(latestRelease) + } + + // Compare versions + val isNewer = GitHubReleaseClient.isNewerVersion(cachedInfo.version, latestRelease) + + if (isNewer) { + UpdateStatus.UpdateAvailable( + currentVersion = cachedInfo.version, + latestRelease = latestRelease + ) + } else { + UpdateStatus.UpToDate(cachedInfo.version) + } + + } catch (e: Exception) { + Log.e(TAG, "Error checking for update", e) + UpdateStatus.Error(e.message ?: "Unknown error") + } + } + + /** + * Check if there's enough disk space to download the APK. + * Requires 1.5x the file size for safety margin (temp + final file). + * @throws IOException if insufficient space + */ + private fun checkDiskSpace(requiredSize: Long) { + val availableSpace = cacheDir.usableSpace + val requiredWithMargin = (requiredSize * 1.5).toLong() + + if (availableSpace < requiredWithMargin) { + val requiredMB = requiredWithMargin / 1024 / 1024 + val availableMB = availableSpace / 1024 / 1024 + val error = "Insufficient storage: need ${requiredMB}MB, have ${availableMB}MB" + Log.e(TAG, error) + throw IOException(error) + } + } + + /** + * Download the universal APK from GitHub with resume support. + * @param progressCallback Called with progress percentage (0-100) + * @return Result with File on success, or error message + */ + suspend fun downloadUniversalApk( + progressCallback: ((Int) -> Unit)? = null + ): Result = withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Starting universal APK download") + + // Fetch latest 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 (!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 + + Log.d(TAG, "Downloading from: $url") + Log.d(TAG, "Expected size: ${expectedSize / 1024 / 1024}MB") + + val tempFile = File(cacheDir, "download_temp.apk") + + // Check for resumable download + var existingBytes = 0L + if (tempFile.exists()) { + val resumeInfo = loadResumeInfo() + if (resumeInfo != null && + resumeInfo.optString("url") == url && + resumeInfo.optString("versionName") == release.versionName + ) { + existingBytes = tempFile.length() + Log.d(TAG, "Resuming download from $existingBytes bytes") + } else { + Log.d(TAG, "Stale temp file found, starting fresh") + tempFile.delete() + progressFile.delete() + } + } + + // Bytes already in the temp file have already consumed storage, so + // a resume only needs room for the remaining tail. Promotion is a + // rename and needs no extra space. + checkDiskSpace((expectedSize - existingBytes).coerceAtLeast(0)) + + // A temp file that already holds the full asset means the process + // died between download and verification. Requesting + // "Range: bytes=-" for it would get HTTP 416 forever, so skip + // the network and let checksum/signature verification decide its fate. + if (expectedSize > 0 && existingBytes >= expectedSize) { + Log.d(TAG, "Temp file already complete ($existingBytes bytes), skipping to verification") + } else { + val requestBuilder = Request.Builder() + .url(url) + .addHeader("User-Agent", "BitChat-Android") + + if (existingBytes > 0) { + requestBuilder.addHeader("Range", "bytes=$existingBytes-") + Log.d(TAG, "Added Range header: bytes=$existingBytes-") + } + + val request = requestBuilder.build() + downloadToTempFile( + call = downloadClient.newCall(request), + tempFile = tempFile, + url = url, + expectedSize = expectedSize, + versionName = release.versionName, + existingBytes = existingBytes, + progressCallback = progressCallback + ) + } + + // Verify checksum if available + if (release.universalApkSha256 != null) { + Log.d(TAG, "Verifying checksum...") + val isValid = verifyChecksum(tempFile, release.universalApkSha256) + if (!isValid) { + tempFile.delete() + progressFile.delete() + return@withContext Result.failure( + Exception("Checksum verification failed. Downloaded file may be corrupted.") + ) + } + Log.d(TAG, "Checksum verified successfully") + } else { + Log.w(TAG, "No checksum available for verification") + } + + // 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 by a trusted BitChat release key.") + ) + } + Log.d(TAG, "Signature verified successfully") + + 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) + replaceFileSafely(tempFile, finalFile) + + // Clean up resume metadata on success + progressFile.delete() + + // Save metadata + saveMetadata( + version = release.versionName, + checksum = release.universalApkSha256 ?: "", + size = finalFile.length(), + fileName = finalFileName, + source = ApkSource.GITHUB + ) + cleanupOldApks(except = finalFile) + + Log.d(TAG, "Universal APK downloaded successfully: ${finalFile.path}") + Result.success(finalFile) + + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + Log.e(TAG, "Network error downloading APK", e) + Result.failure(e) + } catch (e: Exception) { + Log.e(TAG, "Error downloading APK", e) + Result.failure(e) + } + } + + /** + * Streams an HTTP response into [tempFile] while keeping the coroutine + * suspended for the lifetime of the response body. Cancelling the worker + * therefore cancels the OkHttp call and promptly unblocks a pending read. + */ + private suspend fun downloadToTempFile( + call: Call, + tempFile: File, + url: String, + expectedSize: Long, + versionName: String, + existingBytes: Long, + progressCallback: ((Int) -> Unit)? + ) = suspendCancellableCoroutine { continuation -> + fun completeSuccessfully() { + continuation.resumeWith(Result.success(Unit)) + } + + fun completeWithError(error: Throwable) { + continuation.resumeWith(Result.failure(error)) + } + + continuation.invokeOnCancellation { + call.cancel() + } + + try { + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + completeWithError(e) + } + + override fun onResponse(call: Call, response: Response) { + try { + response.use { + if (response.code == 416) { + // Our offset is no longer valid for this asset; discard + // the partial state so the retry starts from scratch. + Log.w(TAG, "Server rejected resume range, restarting download") + tempFile.delete() + progressFile.delete() + throw IOException( + "Resume rejected by server. Download will restart." + ) + } + if (!response.isSuccessful && response.code != 206) { + throw IOException( + "Download failed: ${response.code} ${response.message}" + ) + } + + val body = response.body + ?: throw IOException("Empty response body") + + // Handle resume: 206 = partial content (append), 200 = full + // content (overwrite). + val append = response.code == 206 + val resumedBytes = if (!append && existingBytes > 0) { + Log.d( + TAG, + "Server didn't honor Range request, starting from scratch" + ) + 0L + } else { + existingBytes + } + + saveResumeInfo(url, expectedSize, versionName) + + if (resumedBytes > 0 && expectedSize > 0) { + val initialProgress = + ((resumedBytes * 100) / expectedSize).toInt() + progressCallback?.invoke(initialProgress) + } + + body.byteStream().use { input -> + FileOutputStream(tempFile, append).use { output -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + var totalBytesRead = resumedBytes + var lastProgress = if (expectedSize > 0) { + ((resumedBytes * 100) / expectedSize).toInt() + } else { + 0 + } + + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + + if (expectedSize > 0) { + val progress = + ((totalBytesRead * 100) / expectedSize).toInt() + if (progress != lastProgress) { + lastProgress = progress + progressCallback?.invoke(progress) + } + } + } + + Log.d( + TAG, + "Download complete: ${totalBytesRead / 1024 / 1024}MB" + ) + } + } + } + completeSuccessfully() + } catch (e: Exception) { + completeWithError(e) + } + } + }) + } catch (e: Exception) { + completeWithError(e) + } + } + + /** + * 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()) + } + + /** + * 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 packageInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, signingFlags()) + ?: run { + Log.e(TAG, "Could not parse APK for signature verification") + return false + } + val apkCerts = signatureDigests(packageInfo) + if (apkCerts.isEmpty()) { + Log.e(TAG, "No signatures found in downloaded APK") + return false + } + + 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, "Trusted cert(s): $trustedCerts") + Log.e(TAG, "APK cert(s): $apkCerts") + } + matches + } catch (e: Exception) { + Log.e(TAG, "Error verifying APK signature", e) + false + } + } + + private fun signingFlags(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + PackageManager.GET_SIGNING_CERTIFICATES + } else { + @Suppress("DEPRECATION") + PackageManager.GET_SIGNATURES + } + } + + private fun signatureDigests(packageInfo: android.content.pm.PackageInfo): Set { + val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val signingInfo = packageInfo.signingInfo ?: return emptySet() + if (signingInfo.hasMultipleSigners()) { + signingInfo.apkContentsSigners + } else { + signingInfo.signingCertificateHistory + } + } else { + @Suppress("DEPRECATION") + packageInfo.signatures + } + if (signatures.isNullOrEmpty()) return emptySet() + + val digest = MessageDigest.getInstance("SHA-256") + return signatures.map { sig -> + digest.digest(sig.toByteArray()).joinToString("") { "%02x".format(it) } + }.toSet() + } + + private fun normalizeCertificateDigest(value: String): String? { + return value + .replace(":", "") + .trim() + .lowercase() + .takeIf { it.matches(Regex("[a-f0-9]{64}")) } + } + + /** + * Verify the SHA256 checksum of a file. + */ + suspend fun verifyChecksum(file: File, expectedSha256: String): Boolean = withContext(Dispatchers.IO) { + try { + val checksum = calculateChecksum(file) + val matches = checksum.equals(expectedSha256, ignoreCase = true) + + if (!matches) { + Log.e(TAG, "Checksum mismatch!") + Log.e(TAG, "Expected: $expectedSha256") + Log.e(TAG, "Actual: $checksum") + } + + matches + } catch (e: Exception) { + Log.e(TAG, "Error verifying checksum", e) + false + } + } + + 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. + */ + fun deleteCachedApk(): Boolean { + return try { + val info = getCachedApkInfo() + if (info != null) { + info.file.delete() + metadataFile.delete() + progressFile.delete() + Log.d(TAG, "Deleted cached APK: ${info.version}") + true + } else { + Log.w(TAG, "No cached APK to delete") + false + } + } catch (e: Exception) { + Log.e(TAG, "Error deleting cached APK", e) + false + } + } + + /** + * Clean up old APK files (keep only the current one). + */ + private fun cleanupOldApks(except: File) { + try { + cacheDir.listFiles()?.forEach { file -> + if (file != except && + file.name.startsWith(APK_FILE_PREFIX) && + file.name.endsWith(".apk") + ) { + file.delete() + Log.d(TAG, "Cleaned up old APK: ${file.name}") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error cleaning up old APKs", e) + } + } + + /** + * Save metadata about the downloaded APK. + */ + 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) { + try { + val json = JSONObject().apply { + put("url", url) + put("expectedSize", expectedSize) + put("versionName", versionName) + } + progressFile.writeText(json.toString()) + } catch (e: Exception) { + Log.e(TAG, "Error saving resume info", e) + } + } + + private fun loadResumeInfo(): JSONObject? { + return try { + if (progressFile.exists()) { + JSONObject(progressFile.readText()) + } else null + } catch (e: Exception) { + Log.e(TAG, "Error loading resume info", e) + null + } + } + + /** + * Commit [source] to [target] without removing a valid target first. + * Both files live in the same cache directory, so this is a rename, not a + * copy — no extra disk space is needed and ATOMIC_MOVE either fully + * succeeds or leaves both files intact. + */ + private fun replaceFileSafely(source: File, target: File) { + try { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + } + + /** + * Information about a cached APK. + */ + data class ApkInfo( + val version: String, + val checksum: String, + val downloadDate: Long, + val size: Long, + val file: File, + val source: ApkSource + ) + + enum class ApkSource { + INSTALLED, + GITHUB + } + + /** + * Update check status. + */ + sealed class UpdateStatus { + data class NotDownloaded(val latestRelease: GitHubReleaseClient.Release) : UpdateStatus() + data class UpToDate(val currentVersion: String) : UpdateStatus() + data class UpdateAvailable( + val currentVersion: String, + val latestRelease: GitHubReleaseClient.Release + ) : UpdateStatus() + data class Error(val message: String) : UpdateStatus() + } +} diff --git a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt new file mode 100644 index 00000000..feb31172 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt @@ -0,0 +1,93 @@ +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 +import androidx.work.OneTimeWorkRequestBuilder +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]. + * Downloads survive app backgrounding, process death, and device reboots. + */ +class WorkManagerApkDownloader(context: Context) : ApkDownloader { + + private val appContext = context.applicationContext + private val workManager = WorkManager.getInstance(appContext) + private val apkManager = UniversalApkManager(appContext) + + override val downloadState: Flow = + workManager.getWorkInfosForUniqueWorkFlow(ApkDownloadWorker.WORK_NAME) + .map { workInfos -> mapWorkInfoToState(workInfos.firstOrNull()) } + + override fun startDownload() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 15, + TimeUnit.SECONDS + ) + .addTag(ApkDownloadWorker.TAG) + .build() + + workManager.enqueueUniqueWork( + ApkDownloadWorker.WORK_NAME, + ExistingWorkPolicy.KEEP, + request + ) + } + + override fun cancelDownload() { + workManager.cancelUniqueWork(ApkDownloadWorker.WORK_NAME) + } + + private fun mapWorkInfoToState(workInfo: WorkInfo?): ApkDownloader.DownloadState { + if (workInfo == null) return ApkDownloader.DownloadState.Idle + + return when (workInfo.state) { + WorkInfo.State.ENQUEUED, + WorkInfo.State.BLOCKED -> { + // Waiting for constraints (network). Show existing partial progress if any. + val partial = apkManager.getPartialDownloadProgress() + ApkDownloader.DownloadState.Downloading(partial ?: 0) + } + WorkInfo.State.RUNNING -> { + val progress = workInfo.progress.getInt(ApkDownloadWorker.KEY_PROGRESS, 0) + ApkDownloader.DownloadState.Downloading(progress) + } + WorkInfo.State.SUCCEEDED -> { + val version = workInfo.outputData.getString(ApkDownloadWorker.KEY_VERSION) ?: "" + val sizeMB = workInfo.outputData.getInt(ApkDownloadWorker.KEY_SIZE_MB, 0) + ApkDownloader.DownloadState.Success(version, sizeMB) + } + WorkInfo.State.FAILED -> { + val error = workInfo.outputData.getString(ApkDownloadWorker.KEY_ERROR) ?: "Download failed" + val resumable = workInfo.outputData.getInt(ApkDownloadWorker.KEY_RESUMABLE_PERCENT, -1) + ApkDownloader.DownloadState.Failed(error, if (resumable >= 0) resumable else null) + } + WorkInfo.State.CANCELLED -> { + val partial = apkManager.getPartialDownloadProgress() + if (partial != null) { + ApkDownloader.DownloadState.Failed( + appContext.getString(R.string.prepare_apk_download_cancelled), + partial + ) + } else { + ApkDownloader.DownloadState.Idle + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b8086f0a..bd316e59 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -147,6 +147,71 @@ Privacy Protected cancel + + Share BitChat + Share installation file for offline distribution + Share BitChat App + This will share the BitChat installation file(s) so others can install the app without internet access. Perfect for mesh network expansion! + The receiver will need to:\n• Enable \"Install from unknown sources\" in Android settings\n• Uninstall BitChat first if already installed (signatures differ) + Share App + Share BitChat via… + Failed to prepare app for sharing. Please try again. + + + 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 + Update + Delete + 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… + Verifying checksum… + Universal APK ready! + Network error. Check your connection. + Checksum verification failed. Please try again. + Not enough storage space. + Failed to fetch release info from GitHub. + Delete cached APK? + This will free up ~%1$d MB of storage. + Update Available + A newer version (%1$s) is available. Current: %2$s + Please prepare the app for sharing first. + Download interrupted + Download cancelled + Downloading universal APK + APK downloads + + + Share via Hotspot + Create Wi-Fi hotspot to share offline + Share via Quick Share + Use standard Android sharing + + + Install Received APK + Install BitChat from received files + Install BitChat Update + Install BitChat from the received APK file(s)? This allows offline app distribution in mesh networks. + Permission Required + BitChat needs permission to install packages for self-distribution. Please enable \"Install unknown apps\" in the next screen. + Install + Grant Permission + Select APK Files + Failed to install APK. Please try again. + No APK files selected. + Warning Location Services @@ -449,6 +514,8 @@ Join Cancel Tor not available in this build + Checking... + APK not ready. Please prepare it first. diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 725040b7..d85ee1d1 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -6,4 +6,8 @@ + + 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 c5ca74c1..e3461bd0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,7 +22,13 @@ 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 +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError + # Enabled parallel sync for Gradle 9.4+ org.gradle.tooling.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4a8ad015..95e83d07 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,6 +42,12 @@ tor-android-binary = "0.4.4.6" # Google Play Services gms-location = "21.4.0" +# WorkManager +work-runtime = "2.10.1" + +# NanoHTTPD (hotspot APK sharing) +nanohttpd = "2.3.1" + # Security security-crypto = "1.1.0" @@ -111,6 +117,12 @@ tor-android-binary = { module = "org.torproject:tor-android-binary", version.ref # Google Play Services gms-location = { module = "com.google.android.gms:play-services-location", version.ref = "gms-location" } +# WorkManager +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work-runtime" } + +# NanoHTTPD +nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanohttpd" } + # Security androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" }