diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1f9a8c49..9599ecf8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -126,6 +126,12 @@ + + + + + + diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 785dcf9b..2493e3a3 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -82,6 +82,11 @@ class BitchatApplication : Application() { ) } catch (_: Exception) { } + com.bitchat.android.services.bridge.MeshGatewayService.initialize(this) + com.bitchat.android.groups.GroupRuntime.getInstance(this) + com.bitchat.android.services.PrivateMediaOutbox.initialize(this) + com.bitchat.android.model.PeerCapabilities.setPhoneFeaturesEnabled(true) + // Proactively start the foreground service to keep mesh alive try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { } diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 6f80052a..d73d9341 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -705,6 +705,7 @@ class MainActivity : OrientationAwareActivity() { // Handle any notification intent handleNotificationIntent(intent) handleVerificationIntent(intent) + handleShareIntent(intent) // Small delay to ensure mesh service is fully initialized delay(500) @@ -734,6 +735,7 @@ class MainActivity : OrientationAwareActivity() { if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { handleNotificationIntent(intent) handleVerificationIntent(intent) + handleShareIntent(intent) } } @@ -848,6 +850,21 @@ class MainActivity : OrientationAwareActivity() { } } + private fun handleShareIntent(intent: Intent) { + if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/") == true) { + intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.takeIf { it.isNotBlank() }?.let { + chatViewModel.receiveSharedText(it) + intent.removeExtra(Intent.EXTRA_TEXT) + } + } + if (intent.action == Intent.ACTION_VIEW) { + val cell = intent.data?.toString()?.let(com.bitchat.android.services.ChannelInvitation::decode) ?: return + val channel = com.bitchat.android.ui.channelForManualGeohash(cell) ?: return + LocationChannelManager.getInstance(applicationContext).selectManual(channel) + intent.data = null + } + } + private fun handleVerificationIntent(intent: Intent) { val uri = intent.data ?: return if (uri.scheme != "bitchat" || uri.host != "verify") return diff --git a/app/src/main/java/com/bitchat/android/board/BoardStore.kt b/app/src/main/java/com/bitchat/android/board/BoardStore.kt index 97aa95d1..19bfc1a5 100644 --- a/app/src/main/java/com/bitchat/android/board/BoardStore.kt +++ b/app/src/main/java/com/bitchat/android/board/BoardStore.kt @@ -143,7 +143,7 @@ class BoardStore( fun wipe() = synchronized(lock) { posts.clear() tombstones.clear() - file?.let { runCatching { if (it.exists()) it.delete() } } + file?.let { check(!it.exists() || it.delete()) } publishSnapshotLocked() } diff --git a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt index da6898e8..2d8c04ea 100644 --- a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt +++ b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt @@ -280,7 +280,7 @@ object FileUtils { * Recursively delete all media files (incoming and outgoing) * Used for Panic Mode cleanup */ - fun clearAllMedia(context: Context) { + fun clearAllMedia(context: Context): Boolean { try { // Clear files dir subdirectories (legacy storage and outgoing) val filesDir = context.filesDir @@ -295,7 +295,7 @@ object FileUtils { dirsToClear.forEach { subDir -> val dir = File(filesDir, subDir) if (dir.exists()) { - dir.deleteRecursively() + check(dir.deleteRecursively()) Log.d(TAG, "Deleted media directory from filesDir: $subDir") } } @@ -312,17 +312,19 @@ object FileUtils { cacheDirsToClear.forEach { subDir -> val dir = File(cacheDir, subDir) if (dir.exists()) { - dir.deleteRecursively() + check(dir.deleteRecursively()) Log.d(TAG, "Deleted media directory from cacheDir: $subDir") } } // Also clear entire cache dir as a catch-all - context.cacheDir.deleteRecursively() + check(!context.cacheDir.exists() || context.cacheDir.deleteRecursively()) Log.d(TAG, "Cleared entire cache directory") + return true } catch (e: Exception) { Log.e(TAG, "Failed to clear media files", e) + return false } } diff --git a/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt b/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt index 05223b48..ce6bc273 100644 --- a/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt +++ b/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt @@ -252,14 +252,22 @@ class LiveVoiceManager private constructor(private val context: Context) { } ?: return false val finished = entry.value val replacement = message.copy( - id = finished.messageID, + id = if (message.isPrivate && com.bitchat.android.model.PrivateMediaMessageIdentity.isStableID(message.id)) message.id else finished.messageID, timestamp = finished.timestamp, sender = finished.nickname, senderPeerID = peerID, isPrivate = messageScope == LiveVoiceScope.DIRECT_MESSAGE ) if (messageScope == LiveVoiceScope.DIRECT_MESSAGE) { - AppStateStore.upsertPrivateMessage(peerID, replacement, isVisible(messageScope, peerID)) + if (replacement.id != finished.messageID) { + val saved = kotlinx.coroutines.runBlocking { + AppStateStore.addPrivateMessageDurably(peerID, replacement, isVisible(messageScope, peerID)) + } + if (!saved) return false + AppStateStore.removePrivateMessage(finished.messageID) + } else { + AppStateStore.upsertPrivateMessage(peerID, replacement, isVisible(messageScope, peerID)) + } } else { AppStateStore.upsertPublicMessage(replacement) } diff --git a/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt b/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt index 7e9fadaa..c914cf82 100644 --- a/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt @@ -112,7 +112,9 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { @Volatile private var inboundGeneration = 0L + @Synchronized fun createGroup(rawName: String): GroupCommandResult { + if (!acceptsInboundEvents) return error("private groups are paused") if (!context.groupStore.isReady) return loadingError() val name = rawName.trim() if (name.isEmpty()) return error("usage: /group create ") @@ -131,7 +133,9 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { return success("created private group #${group.name}") } + @Synchronized fun inviteMember(rawNickname: String): GroupCommandResult { + if (!acceptsInboundEvents) return error("private groups are paused") if (!context.groupStore.isReady) return loadingError() val selector = parseMemberSelector(rawNickname) ?: return error("usage: /group invite [#identity-suffix]") @@ -171,7 +175,9 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { return success("invited $nickname to #${updated.name}") } + @Synchronized fun removeMember(rawNickname: String): GroupCommandResult { + if (!acceptsInboundEvents) return error("private groups are paused") if (!context.groupStore.isReady) return loadingError() val selector = parseMemberSelector(rawNickname) ?: return error("usage: /group remove [#identity-suffix]") @@ -205,7 +211,9 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { return success("removed ${member.nickname} and rotated the group key") } + @Synchronized fun leaveGroup(): GroupCommandResult { + if (!acceptsInboundEvents) return error("private groups are paused") if (!context.groupStore.isReady) return loadingError() val group = selectedGroup() ?: return error("open a private group first") if (isCreator(group) && group.members.size > 1) { @@ -225,6 +233,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { return success("left #${group.name}") } + @Synchronized fun listGroups(): GroupCommandResult { if (!context.groupStore.isReady) return loadingError() val groups = context.groupStore.groups.value @@ -237,26 +246,28 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { return success("private groups:\n$lines") } - fun sendMessage(content: String, groupPeerID: String) { + @Synchronized + fun sendMessage(content: String, groupPeerID: String): Boolean { + if (!acceptsInboundEvents) return false if (!context.groupStore.isReady) { context.addGroupSystemMessage(groupPeerID, "private groups are still loading") - return + return false } if (content.isEmpty() || content.codePointCount(0, content.length) > MAX_MESSAGE_LENGTH ) { - return + return false } val group = context.groupStore.group(groupPeerID) val key = group?.let { context.groupStore.key(it.groupID) } if (group == null || key == null) { context.addGroupSystemMessage(groupPeerID, "this private group is unavailable") - return + return false } val signingKey = context.mySigningPublicKey() if (signingKey?.size != 32) { context.addGroupSystemMessage(groupPeerID, "your signing identity is unavailable") - return + return false } val messageID = UUID.randomUUID().toString() @@ -275,10 +286,10 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { ) } catch (_: Exception) { context.addGroupSystemMessage(groupPeerID, "could not encrypt group message") - return + return false } - context.appendGroupMessage( + val stored = context.appendGroupMessage( groupPeerID, BitchatMessage( id = messageID, @@ -291,9 +302,12 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { deliveryStatus = DeliveryStatus.Sent ) ) + if (!stored) return false context.broadcastGroupMessage(payload) + return true } + @Synchronized fun handleMessage(payload: ByteArray, receivedAtMs: Long) { val generation = inboundGeneration if (!acceptsInboundEvents) return @@ -346,6 +360,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { } } + @Synchronized fun handleInvite( peerID: String, authenticatedRemoteStaticKey: ByteArray, @@ -370,6 +385,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { } } + @Synchronized fun handleKeyUpdate( peerID: String, authenticatedRemoteStaticKey: ByteArray, @@ -399,6 +415,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { * reconnects. This uses the existing iOS GROUP_KEY_UPDATE payload and * repairs updates that could not be delivered while the member was offline. */ + @Synchronized fun handlePeerAuthenticated(peerID: String) { val generation = inboundGeneration if (!acceptsInboundEvents) return @@ -425,6 +442,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { /** * Drains packets received during asynchronous store initialization. */ + @Synchronized fun onStoreReady() { val generation = inboundGeneration if (!acceptsInboundEvents) return @@ -460,6 +478,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { } } + @Synchronized fun suspendForPanic() { synchronized(lifecycleLock) { acceptsInboundEvents = false @@ -471,6 +490,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { } } + @Synchronized fun resumeAfterPanic() { synchronized(lifecycleLock) { acceptsInboundEvents = true diff --git a/app/src/main/java/com/bitchat/android/groups/GroupRuntime.kt b/app/src/main/java/com/bitchat/android/groups/GroupRuntime.kt new file mode 100644 index 00000000..d0622531 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/groups/GroupRuntime.kt @@ -0,0 +1,96 @@ +package com.bitchat.android.groups + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import com.bitchat.android.mesh.GroupMessagePort +import com.bitchat.android.mesh.GroupMessageReceiver +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.service.MeshServiceHolder +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.ContactIdentityResolver +import com.bitchat.android.ui.DataManager +import java.lang.ref.WeakReference +import java.util.Date +import kotlinx.coroutines.* + +interface GroupUiDelegate { + val nickname: String + fun markGroupUnread(groupPeerID: String) + fun openGroupConversation(groupPeerID: String) + fun closeGroupConversation() +} + +/** Keeps group membership, decryption, and durable delivery alive when the Activity closes. */ +class GroupRuntime private constructor(private val application: Context) : GroupMessageReceiver { + val store = GroupStore(application) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Volatile private var ui = WeakReference(null) + private val mesh get() = MeshServiceHolder.unifiedMeshService + private val notifications = com.bitchat.android.ui.NotificationManager(application, NotificationManagerCompat.from(application)) + + val coordinator = GroupCoordinator(object : GroupCoordinatorContext { + override val groupStore get() = store + override val nickname get() = ui.get()?.nickname ?: AppStateStore.nickname.value + override val myPeerID get() = mesh?.myPeerID.orEmpty() + override val selectedConversationID get() = AppStateStore.selectedPrivateChatPeer.value + override fun myNoiseFingerprint() = mesh?.getIdentityFingerprint().orEmpty() + override fun mySigningPublicKey() = mesh?.getSigningPublicKey() + override fun sign(data: ByteArray) = mesh?.signData(data) + override fun peerIDsForNickname(nickname: String) = mesh?.getPeerNicknames().orEmpty() + .filterValues { it.equals(nickname, ignoreCase = true) }.keys.toList() + override fun isPeerConnected(peerID: String) = mesh?.getPeerInfo(peerID)?.isConnected == true && mesh?.hasEstablishedSession(peerID) == true + override fun peerGroupCapability(peerID: String): PeerGroupCapability { + val peer = mesh?.getPeerInfo(peerID) ?: return PeerGroupCapability.UNKNOWN + return PeerGroupCapability.fromPeerState(peer.capabilities, peer.hasVerifiedAnnouncement) + } + override fun peerNickname(peerID: String) = mesh?.getPeerNicknames()?.get(peerID) + override fun peerIdentity(peerID: String): GroupPeerIdentity? { + val info = mesh?.getPeerInfo(peerID) ?: return null + val signing = info.signingPublicKey?.takeIf { it.size == 32 } ?: return null + val key = info.noisePublicKey ?: return null + val fingerprint = ContactIdentityResolver.fingerprintHex(key) + if (!fingerprint.equals(mesh?.getPeerFingerprint(peerID), ignoreCase = true)) return null + return GroupPeerIdentity(fingerprint, signing.copyOf()) + } + override fun connectedPeerID(fingerprint: String) = mesh?.getPeerNicknames()?.keys?.firstOrNull { + isPeerConnected(it) && fingerprint.equals(mesh?.getPeerFingerprint(it), ignoreCase = true) + } + override fun isFingerprintBlocked(fingerprint: String) = DataManager.isFingerprintBlocked(application, fingerprint) + override fun sendGroupInvite(payload: ByteArray, peerID: String) { mesh?.sendGroupInvite(payload, peerID) } + override fun sendGroupKeyUpdate(payload: ByteArray, peerID: String) { mesh?.sendGroupKeyUpdate(payload, peerID) } + override fun broadcastGroupMessage(payload: ByteArray) { mesh?.broadcastGroupMessage(payload) } + override fun appendGroupMessage(groupPeerID: String, message: BitchatMessage): Boolean = runBlocking { + AppStateStore.addPrivateMessageDurably(groupPeerID, message, selectedConversationID == groupPeerID || message.senderPeerID == myPeerID) + } + override fun markGroupUnread(groupPeerID: String) { ui.get()?.markGroupUnread(groupPeerID) } + override fun removeGroupConversation(groupPeerID: String) { AppStateStore.deletePrivateConversation(groupPeerID) } + override fun openGroupConversation(groupPeerID: String) { scope.launch(Dispatchers.Main) { ui.get()?.openGroupConversation(groupPeerID) } } + override fun closeGroupConversation() { scope.launch(Dispatchers.Main) { ui.get()?.closeGroupConversation() } } + override fun addSystemMessage(message: String) { AppStateStore.addPublicMessage(BitchatMessage(sender = "system", content = message, timestamp = Date())) } + override fun addGroupSystemMessage(groupPeerID: String, message: String) { + appendGroupMessage(groupPeerID, BitchatMessage(sender = "system", content = message, timestamp = Date(), isPrivate = true)) + } + override fun notifyGroupMessage(groupPeerID: String, sender: String, message: String) { + notifications.showPrivateMessageNotification(groupPeerID, sender, message) + } + }) + + init { + GroupMessagePort.receiver = this + scope.launch { if (store.initialize()) coordinator.onStoreReady() } + } + + fun attach(context: GroupUiDelegate) { ui = WeakReference(context) } + fun detach(context: GroupUiDelegate) { if (ui.get() === context) ui.clear() } + override fun invite(peerID: String, authenticatedKey: ByteArray, payload: ByteArray) = coordinator.handleInvite(peerID, authenticatedKey, payload) + override fun keyUpdate(peerID: String, authenticatedKey: ByteArray, payload: ByteArray) = coordinator.handleKeyUpdate(peerID, authenticatedKey, payload) + override fun message(payload: ByteArray, timestampMs: Long) = coordinator.handleMessage(payload, timestampMs) + override fun peerAuthenticated(peerID: String) { scope.launch { coordinator.handlePeerAuthenticated(peerID) } } + + companion object { + @Volatile private var instance: GroupRuntime? = null + fun getInstance(context: Context): GroupRuntime = instance ?: synchronized(this) { + instance ?: GroupRuntime(context.applicationContext).also { instance = it } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt index ab9e2ece..574fea68 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -668,22 +668,18 @@ class SecureIdentityStateManager { * Clear all identity data (for panic mode) */ @SuppressLint("UseKtx") - fun clearIdentityData() { - try { - synchronized(identityPersistenceLock) { - identityPersistenceEpoch += 1 - identityPersistenceEpochAtCreation = identityPersistenceEpoch - if (!prefs.edit().clear().commit()) { - Log.e(TAG, "Identity preference wipe could not be committed") - } - identityChanges.tryEmit(Unit) - } - Log.w(TAG, "All identity data cleared") - } catch (e: Exception) { - Log.e(TAG, "Failed to clear identity data: ${e.message}") + fun clearIdentityData(): Boolean = try { + synchronized(identityPersistenceLock) { + identityPersistenceEpoch += 1 + identityPersistenceEpochAtCreation = identityPersistenceEpoch + check(prefs.edit().clear().commit()) { "Identity preference wipe could not be committed" } + identityChanges.tryEmit(Unit) } + true + } catch (_: Exception) { + false } - + /** * Check if identity data exists */ diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 6c0e7c02..6b7f1557 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -80,7 +80,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic }, applyAuthenticatedState = peerManager::applyAuthenticatedPeerState, sendState = ::sendAuthenticatedPeerState, - onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) } + onResolution = { peerID -> GroupMessagePort.receiver?.peerAuthenticated(peerID) + delegate?.didResolvePrivateMediaPolicy(peerID) } ) } private val privateMediaSecurity by lazy { PrivateMediaSecurityController( @@ -558,6 +559,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } override fun onDeliveryAckReceived(messageID: String, peerID: String) { + com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.acknowledge(messageID, peerID) try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageID, peerID) } catch (_: Exception) { } // Status events can arrive while MainActivity has detached the UI delegate. // Persist first so the next UI collector observes the advancement. @@ -571,6 +573,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } override fun onReadReceiptReceived(messageID: String, peerID: String) { + com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.acknowledge(messageID, peerID) try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageID, peerID) } catch (_: Exception) { } try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( @@ -594,7 +597,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic authenticatedRemoteStaticKey: ByteArray, payload: ByteArray ) { - delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload) + GroupMessagePort.receiver?.invite(peerID, authenticatedRemoteStaticKey, payload) } override fun onGroupKeyUpdateReceived( @@ -602,11 +605,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic authenticatedRemoteStaticKey: ByteArray, payload: ByteArray ) { - delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload) + GroupMessagePort.receiver?.keyUpdate(peerID, authenticatedRemoteStaticKey, payload) } override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) { - delegate?.didReceiveGroupMessage(payload, timestampMs) + GroupMessagePort.receiver?.message(payload, timestampMs) } override fun onVouchPayloadReceived(peerID: String, payload: ByteArray) { @@ -1137,6 +1140,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } /** Safe non-interactive entry point: encrypted sends commit; legacy sends require UI consent. */ + fun supportsPrivateMediaReceipts(peerID: String): Boolean { + val session = encryptionService.getAuthenticatedSession(peerID) ?: return false + val proof = authenticatedPeerState.status(peerID, session) as? AuthenticatedPeerStateStatus.Proven ?: return false + return proof.state.capabilities.contains(com.bitchat.android.model.PeerCapabilities.PRIVATE_MEDIA_RECEIPTS) + } + fun sendFilePrivate(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) { val payload = file.encode() ?: return when (val prepared = prepareFilePrivate( @@ -2094,11 +2103,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic bridgeCourierService::stop, gossipSyncManager::clear ) + var failure: Exception? = null operations.forEach { operation -> - try { operation() } catch (e: Exception) { - Log.e(TAG, "Error clearing mesh service internal data: ${e.message}") - } + try { operation() } catch (error: Exception) { failure = error } } + failure?.let { throw IllegalStateException("Mesh data wipe incomplete", it) } } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/GroupMessagePort.kt b/app/src/main/java/com/bitchat/android/mesh/GroupMessagePort.kt new file mode 100644 index 00000000..2ce93a20 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/GroupMessagePort.kt @@ -0,0 +1,13 @@ +package com.bitchat.android.mesh + +/** Process-level group ingress, independent of Activity and transport lifetimes. */ +interface GroupMessageReceiver { + fun invite(peerID: String, authenticatedKey: ByteArray, payload: ByteArray) + fun keyUpdate(peerID: String, authenticatedKey: ByteArray, payload: ByteArray) + fun message(payload: ByteArray, timestampMs: Long) + fun peerAuthenticated(peerID: String) +} + +object GroupMessagePort { + @Volatile var receiver: GroupMessageReceiver? = null +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index b0165814..d4a934ff 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -50,6 +50,7 @@ class MeshCore( * Return false to suppress all downstream effects for a rejected message. */ val onMessageReceived: ((BitchatMessage) -> Boolean)? = null, + val onDeliveryReceipt: ((String, String) -> Unit)? = null, val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null, val readReceiptInterceptor: ((String, String) -> Boolean)? = null, val onReadReceiptSent: ((String) -> Unit)? = null, @@ -76,7 +77,8 @@ class MeshCore( }, applyAuthenticatedState = peerManager::applyAuthenticatedPeerState, sendState = ::sendAuthenticatedPeerState, - onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) } + onResolution = { peerID -> GroupMessagePort.receiver?.peerAuthenticated(peerID) + delegate?.didResolvePrivateMediaPolicy(peerID) } ) } private val privateMediaSecurity by lazy { PrivateMediaSecurityController( @@ -437,6 +439,7 @@ class MeshCore( } override fun onDeliveryAckReceived(messageID: String, peerID: String) { + hooks.onDeliveryReceipt?.invoke(messageID, peerID) try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( messageID, @@ -447,6 +450,7 @@ class MeshCore( } override fun onReadReceiptReceived(messageID: String, peerID: String) { + hooks.onDeliveryReceipt?.invoke(messageID, peerID) try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( messageID, @@ -469,7 +473,7 @@ class MeshCore( authenticatedRemoteStaticKey: ByteArray, payload: ByteArray ) { - delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload) + GroupMessagePort.receiver?.invite(peerID, authenticatedRemoteStaticKey, payload) } override fun onGroupKeyUpdateReceived( @@ -477,11 +481,11 @@ class MeshCore( authenticatedRemoteStaticKey: ByteArray, payload: ByteArray ) { - delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload) + GroupMessagePort.receiver?.keyUpdate(peerID, authenticatedRemoteStaticKey, payload) } override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) { - delegate?.didReceiveGroupMessage(payload, timestampMs) + GroupMessagePort.receiver?.message(payload, timestampMs) } override fun onVouchPayloadReceived(peerID: String, payload: ByteArray) { @@ -851,6 +855,12 @@ class MeshCore( } } + fun supportsPrivateMediaReceipts(peerID: String): Boolean { + val session = encryptionService.getAuthenticatedSession(peerID) ?: return false + val proof = authenticatedPeerState.status(peerID, session) as? AuthenticatedPeerStateStatus.Proven ?: return false + return proof.state.capabilities.contains(com.bitchat.android.model.PeerCapabilities.PRIVATE_MEDIA_RECEIPTS) + } + fun prepareFilePrivate( recipientPeerID: String, file: BitchatFilePacket, diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt b/app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt index 2773cb15..e9b216c7 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt @@ -38,6 +38,7 @@ internal class MeshPingManager( private val inboundByLink = ConcurrentHashMap>() fun ping(peerID: String, callback: (MeshPingResult?) -> Unit) { + if (pending.size >= 64) { callback(null); return } val payload = MeshPingPayload.create(MeshDiagnosticsConstants.TTL) val key = pendingKey(payload) val timeout = scope.launch { @@ -73,7 +74,16 @@ internal class MeshPingManager( private fun consumeInboundBudget(link: String): Boolean { val now = System.currentTimeMillis() - val timestamps = inboundByLink.computeIfAbsent(link) { ArrayDeque() } + synchronized(inboundByLink) { + inboundByLink.entries.removeAll { (_, times) -> + synchronized(times) { + times.lastOrNull()?.let { now - it >= MeshDiagnosticsConstants.INBOUND_RATE_WINDOW_MILLIS } != false + } + } + if (inboundByLink.size >= 256 && link !in inboundByLink) return false + inboundByLink.putIfAbsent(link, ArrayDeque()) + } + val timestamps = inboundByLink[link] ?: return false synchronized(timestamps) { while (timestamps.firstOrNull()?.let { now - it >= MeshDiagnosticsConstants.INBOUND_RATE_WINDOW_MILLIS diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt index c2593a00..f7c1f32d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -17,6 +17,8 @@ interface MeshService { fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) fun sendPrekeyBundle(payload: ByteArray) fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) + fun supportsPrivateMediaReceipts(peerID: String): Boolean = false + fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) fun sendDeliveryAck(messageID: String, recipientPeerID: String) {} fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {} diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 4c73e1ec..ba106282 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -119,7 +119,8 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro // Notify delegate delegate?.onMessageReceived(message) - // Send delivery ACK exactly like iOS + // An ACK means durable admission, including a previously deleted duplicate. + if (!com.bitchat.android.services.AppStateStore.hasPrivateTextReceipt(message)) return false sendDeliveryAck(privateMessage.messageID, peerID) } } @@ -129,7 +130,19 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro val file = com.bitchat.android.model.BitchatFilePacket.decode(noisePayload.data) if (file != null) { Log.d(TAG, "Encrypted file from $peerID: ${file.fileSize} bytes") - val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase() + val stableID = com.bitchat.android.model.PrivateMediaMessageIdentity.stableID(peerID, myPeerID, file.fileName) + if (stableID != null) { + when (com.bitchat.android.services.AppStateStore.privateMediaReceiptState(stableID)) { + com.bitchat.android.services.PrivateMediaReceiptState.ACCEPTED, + com.bitchat.android.services.PrivateMediaReceiptState.TOMBSTONED -> { + sendDeliveryAck(stableID, peerID) + return true + } + com.bitchat.android.services.PrivateMediaReceiptState.UNAVAILABLE -> return false + com.bitchat.android.services.PrivateMediaReceiptState.ABSENT -> Unit + } + } + val uniqueMsgId = stableID ?: java.util.UUID.randomUUID().toString().uppercase() val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file) val message = BitchatMessage( id = uniqueMsgId, @@ -147,8 +160,18 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro delegate?.onMessageReceived(message) } - // Send delivery ACK with generated message ID - sendDeliveryAck(uniqueMsgId, peerID) + if (stableID == null) { + sendDeliveryAck(uniqueMsgId, peerID) + } else { + val receipt = com.bitchat.android.services.AppStateStore.privateMediaReceiptState(stableID) + if (receipt == com.bitchat.android.services.PrivateMediaReceiptState.ACCEPTED || + receipt == com.bitchat.android.services.PrivateMediaReceiptState.TOMBSTONED) { + sendDeliveryAck(stableID, peerID) + } else { + com.bitchat.android.features.file.FileUtils.deleteStoredMediaPaths(appContext, listOf(savedPath)) + return false + } + } } else { Log.w(TAG, "Failed to decode encrypted file transfer from $peerID") } @@ -248,7 +271,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro senderPeerID = peerID ) delegate?.onMessageReceived(message) - return true + return com.bitchat.android.services.AppStateStore.hasPrivateTextReceipt(message) } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index 7d8b6fd3..eb7232c1 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -161,6 +161,9 @@ class UnifiedMeshService( onAccepted: () -> Unit ): Boolean = bluetooth.sendBridgeCourierMessage(content, messageID, recipientNoiseKey, onAccepted) + override fun supportsPrivateMediaReceipts(peerID: String): Boolean = + bluetooth.supportsPrivateMediaReceipts(peerID) || wifiService()?.supportsPrivateMediaReceipts(peerID) == true + override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { when { isBleReady(recipientPeerID) -> bluetooth.sendReadReceipt(messageID, recipientPeerID, readerNickname) @@ -471,8 +474,10 @@ class UnifiedMeshService( } override fun clearAllInternalData() { - try { bluetooth.clearAllInternalData() } catch (_: Exception) { } - try { wifiService()?.clearAllInternalData() } catch (_: Exception) { } + val bluetoothResult = runCatching { bluetooth.clearAllInternalData() } + val wifiResult = runCatching { wifiService()?.clearAllInternalData() } + bluetoothResult.getOrThrow() + wifiResult.getOrThrow() } override fun clearAllEncryptionData() { diff --git a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt index a14444bd..bf5fb4fe 100644 --- a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt +++ b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt @@ -58,10 +58,16 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable { /** Capabilities implemented by this Android build. */ @Deprecated("Use localSupported() so runtime bridge state is included") - val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or PREKEYS.rawValue or GROUPS.rawValue or BOARD.rawValue or VOUCH.rawValue or MESH_DIAGNOSTICS.rawValue) + val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or BOARD.rawValue or VOUCH.rawValue or MESH_DIAGNOSTICS.rawValue) @Volatile private var bridgeEnabled: Boolean = false + @Volatile private var gatewayEnabled: Boolean = false + fun setGatewayEnabled(enabled: Boolean) { gatewayEnabled = enabled } + + @Volatile private var phoneFeaturesEnabled = false + + fun setPhoneFeaturesEnabled(enabled: Boolean) { phoneFeaturesEnabled = enabled } fun setBridgeEnabled(enabled: Boolean) { bridgeEnabled = enabled @@ -69,8 +75,9 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable { fun localSupported(): PeerCapabilities = PeerCapabilities( LOCAL_SUPPORTED.rawValue or - PREKEYS.rawValue or - if (bridgeEnabled) BRIDGE.rawValue else 0L + (if (phoneFeaturesEnabled) PREKEYS.rawValue or GROUPS.rawValue or PRIVATE_MEDIA_RECEIPTS.rawValue else 0L) or + (if (bridgeEnabled) BRIDGE.rawValue else 0L) or + (if (gatewayEnabled) GATEWAY.rawValue else 0L) ) /** diff --git a/app/src/main/java/com/bitchat/android/model/PrivateMediaMessageIdentity.kt b/app/src/main/java/com/bitchat/android/model/PrivateMediaMessageIdentity.kt new file mode 100644 index 00000000..5b2fdc6d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/PrivateMediaMessageIdentity.kt @@ -0,0 +1,38 @@ +package com.bitchat.android.model + +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.security.MessageDigest + +/** Stable, direction-bound IDs matching the iOS private-media receipt contract. */ +object PrivateMediaMessageIdentity { + private val stable = Regex("media-[0-9a-f]{32}") + private val uuid = Regex("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + private val peer = Regex("[0-9a-f]{16}") + + fun isStableID(value: String): Boolean = stable.matches(value) + + fun stableID(senderPeerID: String, recipientPeerID: String, fileName: String?): String? { + if (fileName.isNullOrEmpty() || '/' in fileName || '\\' in fileName) return null + val sender = senderPeerID.lowercase() + val recipient = recipientPeerID.lowercase() + if (!peer.matches(sender) || !peer.matches(recipient)) return null + val stem = fileName.substringBeforeLast('.', fileName) + val extension = fileName.substringAfterLast('.', "").lowercase() + val isVoice = stem.startsWith("voice_") && extension == "m4a" + val isImage = stem.startsWith("img_") && extension in setOf("jpg", "jpeg") + if (!isVoice && !isImage) return null + val burst = stem.removePrefix("voice_") + if (!uuid.matches(stem.substringAfterLast('_')) && + !(isVoice && Regex("[0-9a-fA-F]{16}").matches(burst))) return null + val input = ByteArrayOutputStream() + input.write("bitchat-private-media-message-v1".toByteArray(Charsets.UTF_8)) + listOf(sender, recipient, fileName).forEach { field -> + val bytes = field.toByteArray(Charsets.UTF_8) + input.write(ByteBuffer.allocate(4).putInt(bytes.size).array()) + input.write(bytes) + } + return "media-" + MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) + .take(16).joinToString("") { "%02x".format(it) } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt b/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt index c7120f5b..160ac30c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt @@ -76,7 +76,8 @@ private class NostrBridgeCourierRelay( event: NostrEvent, relayUrls: List, onAccepted: () -> Unit - ): Boolean = relayManager.sendEvent(event, relayUrls, onAccepted = onAccepted) + ): Boolean = relayManager.sendEvent(event, relayUrls, onAccepted = onAccepted, + publicationAllowed = com.bitchat.android.services.bridge.MeshBridgeService.publicationPermit()) } private class EncryptionBridgeCourierCipher( diff --git a/app/src/main/java/com/bitchat/android/nostr/CustomRelayUrl.kt b/app/src/main/java/com/bitchat/android/nostr/CustomRelayUrl.kt new file mode 100644 index 00000000..a9c9b3d9 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/CustomRelayUrl.kt @@ -0,0 +1,14 @@ +package com.bitchat.android.nostr + +import java.net.URI + +object CustomRelayUrl { + fun normalize(input: String): String? = runCatching { + val uri = URI(input.trim()) + require(uri.scheme.equals("wss", ignoreCase = true)) + require(!uri.host.isNullOrBlank() && uri.userInfo == null && uri.fragment == null && uri.query == null) + require(uri.port == -1 || uri.port in 1..65535) + URI("wss", null, uri.host.lowercase(), uri.port, + uri.path?.takeUnless { it == "/" }, null, null).toASCIIString() + }.getOrNull() +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt index a895f612..758a0652 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt @@ -92,6 +92,13 @@ object NostrBackgroundRuntime { ) } + fun receiveGatewayEvent(event: NostrEvent, geohash: String) { + if (!initialized || activeGeohash != geohash) return + val selected = (locationChannels.selectedChannel.value as? ChannelID.Location)?.channel ?: return + if (selected.geohash != geohash || !locationChannels.canUseSelectedLocationChannel(selected)) return + eventProcessor.onGeohashMessage(event, geohash) + } + private fun subscribeAccountDm() { val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) ?: return subscriptions.subscribeGiftWraps( @@ -147,7 +154,10 @@ object NostrBackgroundRuntime { sinceMs = System.currentTimeMillis() - 3_600_000L, limit = 200, id = "geohash-$geohash", - handler = { event -> eventProcessor.onGeohashMessage(event, geohash) }, + handler = { event -> + eventProcessor.onGeohashMessage(event, geohash) + com.bitchat.android.services.bridge.MeshGatewayService.rebroadcastRelayEvent(event, geohash, liveLocationToken) + }, liveLocationToken = liveLocationToken ) subscribeGeohashDm(geohash, "geo-dm-$geohash", liveLocationToken) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt index dd9e16d1..fb40475a 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt @@ -16,14 +16,16 @@ internal class NostrPendingEventQueue( data class Delivery( val queueId: Long, val event: NostrEvent, - val liveLocationToken: Long? + val liveLocationToken: Long?, + val publicationAllowed: () -> Boolean ) private data class Entry( val queueId: Long, val event: NostrEvent, val pendingRelayUrls: MutableSet, - val liveLocationToken: Long? + val liveLocationToken: Long?, + val publicationAllowed: () -> Boolean ) private val lock = Any() @@ -33,7 +35,8 @@ internal class NostrPendingEventQueue( fun enqueue( event: NostrEvent, relayUrls: Collection, - liveLocationToken: Long? + liveLocationToken: Long?, + publicationAllowed: () -> Boolean = { true } ): Long? { val pendingRelays = relayUrls.filterTo(linkedSetOf()) { it.isNotBlank() } if (pendingRelays.isEmpty()) return null @@ -46,7 +49,8 @@ internal class NostrPendingEventQueue( queueId = queueId, event = event, pendingRelayUrls = pendingRelays, - liveLocationToken = liveLocationToken + liveLocationToken = liveLocationToken, + publicationAllowed = publicationAllowed ) ) queueId @@ -54,10 +58,11 @@ internal class NostrPendingEventQueue( } fun pendingForRelay(relayUrl: String): List = synchronized(lock) { + entries.removeAll { !it.publicationAllowed() } entries .asSequence() .filter { relayUrl in it.pendingRelayUrls } - .map { Delivery(it.queueId, it.event, it.liveLocationToken) } + .map { Delivery(it.queueId, it.event, it.liveLocationToken, it.publicationAllowed) } .toList() } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index d8e431a8..ca619345 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -37,6 +37,7 @@ class NostrRelayManager private constructor() { */ fun getInstance(context: android.content.Context): NostrRelayManager { shared.appContext = context.applicationContext + shared.loadCustomRelays(context.applicationContext) return shared } @@ -61,7 +62,7 @@ class NostrRelayManager private constructor() { pendingGiftWrapIDs.add(id) } - fun defaultRelays(): List = DEFAULT_RELAYS + fun defaultRelays(): List = (DEFAULT_RELAYS + shared.customRelays.value).distinct() } /** @@ -79,6 +80,67 @@ class NostrRelayManager private constructor() { var nextReconnectTime: Long? = null ) + private val _customRelays = MutableStateFlow>(emptyList()) + val customRelays: StateFlow> = _customRelays.asStateFlow() + private var customRelaysLoaded = false + + @Synchronized + private fun loadCustomRelays(context: android.content.Context) { + if (customRelaysLoaded) return + customRelaysLoaded = true + val urls = context.getSharedPreferences("nostr_custom_relays", android.content.Context.MODE_PRIVATE) + .getStringSet("urls", emptySet()).orEmpty().mapNotNull(CustomRelayUrl::normalize).distinct().take(20) + _customRelays.value = urls + nonLiveRelayUrls.addAll(urls) + ensureConnectionsFor(urls.toSet()) + } + + @Synchronized + fun addCustomRelay(input: String): Boolean { + val url = CustomRelayUrl.normalize(input) ?: return false + if (url in defaultRelays()) return true + if (_customRelays.value.size >= 20) return false + val previousDefaults = defaultRelays().toSet() + _customRelays.value = _customRelays.value + url + persistCustomRelays() + nonLiveRelayUrls.add(url) + activeSubscriptions.replaceAll { _, subscription -> + if (subscription.targetRelayUrls == previousDefaults) { + subscription.copy(targetRelayUrls = previousDefaults + url) + } else subscription + } + ensureConnectionsFor(setOf(url)) + return true + } + + @Synchronized + fun removeCustomRelay(url: String) { + if (url !in _customRelays.value) return + _customRelays.value = _customRelays.value - url + persistCustomRelays() + activeSubscriptions.replaceAll { _, subscription -> + subscription.copy(targetRelayUrls = subscription.targetRelayUrls?.minus(url)) + } + reconnectJobs.remove(url)?.cancel() + connections.remove(url)?.close(1000, "Relay removed") + subscriptions.remove(url) + nonLiveRelayUrls.remove(url) + synchronized(relaysList) { relaysList.removeAll { it.url == url } } + updateRelaysList() + updateConnectionStatus() + } + + fun clearCustomRelays() { + _customRelays.value.toList().forEach(::removeCustomRelay) + check(appContext?.getSharedPreferences("nostr_custom_relays", android.content.Context.MODE_PRIVATE) + ?.edit()?.clear()?.commit() != false) + } + + private fun persistCustomRelays() { + appContext?.getSharedPreferences("nostr_custom_relays", android.content.Context.MODE_PRIVATE) + ?.edit()?.putStringSet("urls", _customRelays.value.toSet())?.apply() + } + // Published state private val _relays = MutableStateFlow>(emptyList()) val relays: StateFlow> = _relays.asStateFlow() @@ -237,23 +299,24 @@ class NostrRelayManager private constructor() { geohash: String, includeDefaults: Boolean = false, nRelays: Int = 5, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + publicationAllowed: () -> Boolean = { true } ) { - if (!isNetworkActionAllowed(liveLocationToken)) return + if (!publicationAllowed() || !isNetworkActionAllowed(liveLocationToken)) return ensureGeohashRelaysConnected( geohash, nRelays, includeDefaults, liveLocationToken ) - if (!isNetworkActionAllowed(liveLocationToken)) return + if (!publicationAllowed() || !isNetworkActionAllowed(liveLocationToken)) return val relayUrls = getRelaysForGeohash(geohash) if (relayUrls.isEmpty()) { Log.w(TAG, "No target relays for geohash event; falling back to defaults") - sendEvent(event, Companion.defaultRelays(), liveLocationToken) + sendEvent(event, Companion.defaultRelays(), liveLocationToken, publicationAllowed = publicationAllowed) return } - sendEvent(event, relayUrls, liveLocationToken) + sendEvent(event, relayUrls, liveLocationToken, publicationAllowed = publicationAllowed) } // --- Internal helpers --- @@ -458,8 +521,10 @@ class NostrRelayManager private constructor() { event: NostrEvent, relayUrls: List? = null, liveLocationToken: Long? = null, - onAccepted: (() -> Unit)? = null + onAccepted: (() -> Unit)? = null, + publicationAllowed: () -> Boolean = { true } ): Boolean { + if (!publicationAllowed()) return false val targetRelays = (relayUrls ?: relaysList.map { it.url }) .filter { it.isNotBlank() } .distinct() @@ -470,7 +535,8 @@ class NostrRelayManager private constructor() { val queueId = messageQueue.enqueue( event = event, relayUrls = targetRelays, - liveLocationToken = liveLocationToken + liveLocationToken = liveLocationToken, + publicationAllowed = publicationAllowed ) ?: return@runNetworkAction enqueued = true if (onAccepted != null) { @@ -484,7 +550,7 @@ class NostrRelayManager private constructor() { targetRelays.forEach { relayUrl -> val webSocket = connections[relayUrl] if (webSocket != null) { - if (sendToRelay(event, webSocket, relayUrl, liveLocationToken)) { + if (sendToRelay(event, webSocket, relayUrl, liveLocationToken, publicationAllowed)) { messageQueue.markDelivered(queueId, relayUrl) } } @@ -904,16 +970,17 @@ class NostrRelayManager private constructor() { event: NostrEvent, webSocket: WebSocket, relayUrl: String, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + publicationAllowed: () -> Boolean = { true } ): Boolean { - if (!isNetworkActionAllowed(liveLocationToken)) return false + if (!publicationAllowed() || !isNetworkActionAllowed(liveLocationToken)) return false return try { val request = NostrRequest.Event(event) val message = gson.toJson(request, NostrRequest::class.java) var success = false runNetworkAction(liveLocationToken) { - success = webSocket.send(message) + if (publicationAllowed()) success = webSocket.send(message) } if (success) { // Update relay stats @@ -1193,7 +1260,8 @@ class NostrRelayManager private constructor() { delivery.event, webSocket, relayUrl, - delivery.liveLocationToken + delivery.liveLocationToken, + delivery.publicationAllowed ) ) { messageQueue.markDelivered(delivery.queueId, relayUrl) diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index ebcaac69..86aa2f20 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -272,6 +272,22 @@ object AppStateStore { * Persists an incoming private message before it is admitted to UI, unread, haptic, or * notification state. Transport callbacks invoke this from their background worker. */ + suspend fun hasPrivateTextReceipt(message: BitchatMessage): Boolean { + val repository = synchronized(this) { + if (privateConversationWritesSuspended) return false + conversationRepository + } ?: return false + return repository.hasPrivateTextReceipt(message) + } + + suspend fun privateMediaReceiptState(messageID: String): PrivateMediaReceiptState { + val repository = synchronized(this) { + if (privateConversationWritesSuspended) return PrivateMediaReceiptState.UNAVAILABLE + conversationRepository + } ?: return PrivateMediaReceiptState.UNAVAILABLE + return repository.privateMediaReceiptState(messageID) + } + suspend fun addPrivateMessageDurably( peerID: String, msg: BitchatMessage, diff --git a/app/src/main/java/com/bitchat/android/services/ChannelInvitation.kt b/app/src/main/java/com/bitchat/android/services/ChannelInvitation.kt new file mode 100644 index 00000000..f8c4e5bf --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/ChannelInvitation.kt @@ -0,0 +1,15 @@ +package com.bitchat.android.services + +import java.net.URI + +/** Parses explicit channel invitations; never requests or infers device location. */ +object ChannelInvitation { + private val geohash = Regex("[0123456789bcdefghjkmnpqrstuvwxyz]{1,12}") + fun decode(link: String): String? = runCatching { + val uri = URI(link) + require(uri.scheme == "bitchat" && uri.host == "geohash" && uri.query == null && uri.fragment == null && uri.userInfo == null && uri.port == -1) + val cell = uri.path.removePrefix("/").lowercase() + cell.takeIf(geohash::matches) + }.getOrNull() + fun link(cell: String): String? = cell.lowercase().takeIf(geohash::matches)?.let { "bitchat://geohash/$it" } +} diff --git a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt index a6b849d7..0b5ab27a 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt @@ -27,6 +27,8 @@ import java.util.Date import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean +enum class PrivateMediaReceiptState { ABSENT, ACCEPTED, TOMBSTONED, UNAVAILABLE } + /** * Process-wide, serialized persistence for private conversations. * @@ -141,6 +143,16 @@ class ConversationRepository internal constructor( } } + suspend fun hasPrivateTextReceipt(message: BitchatMessage): Boolean = withContext(dispatcher) { + try { database.hasPrivateTextReceipt(message) } catch (_: Exception) { false } + } + + suspend fun privateMediaReceiptState(messageID: String): PrivateMediaReceiptState = withContext(dispatcher) { + try { database.privateMediaReceiptState(messageID) } catch (_: Exception) { + PrivateMediaReceiptState.UNAVAILABLE + } + } + suspend fun upsertMessageAndWait( conversationID: String, aliases: Set, @@ -743,6 +755,33 @@ internal class ConversationDatabase( } } + fun hasPrivateTextReceipt(incoming: BitchatMessage): Boolean { + if (!incoming.isPrivate || incoming.type != BitchatMessageType.Message) return false + if (isDeletedMessageLocked(readableDatabase, incoming.id)) return true + readableDatabase.query("private_messages", MESSAGE_COLUMNS, "message_id = ?", + arrayOf(incoming.id), null, null, null).use { cursor -> + if (!cursor.moveToFirst()) return false + val stored = cursor.toMessage() + return stored.type == BitchatMessageType.Message && stored.content == incoming.content && + stored.senderPeerID == incoming.senderPeerID + } + } + + fun privateMediaReceiptState(messageID: String): PrivateMediaReceiptState { + if (!com.bitchat.android.model.PrivateMediaMessageIdentity.isStableID(messageID)) { + return PrivateMediaReceiptState.UNAVAILABLE + } + if (isDeletedMessageLocked(readableDatabase, messageID)) return PrivateMediaReceiptState.TOMBSTONED + readableDatabase.query("private_messages", MESSAGE_COLUMNS, "message_id = ?", + arrayOf(messageID), null, null, null).use { cursor -> + if (!cursor.moveToFirst()) return PrivateMediaReceiptState.ABSENT + val message = cursor.toMessage() + return if (message.type != BitchatMessageType.Message && File(message.content).isFile) { + PrivateMediaReceiptState.ACCEPTED + } else PrivateMediaReceiptState.UNAVAILABLE + } + } + private fun loadDeletedMessageIDs(): Set { readableDatabase.query( "deleted_private_messages", diff --git a/app/src/main/java/com/bitchat/android/services/PrivateMediaOutbox.kt b/app/src/main/java/com/bitchat/android/services/PrivateMediaOutbox.kt new file mode 100644 index 00000000..788a3a3c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/PrivateMediaOutbox.kt @@ -0,0 +1,190 @@ +package com.bitchat.android.services + +import android.content.Context +import android.util.AtomicFile +import android.util.Base64 +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.PrivateMediaPreparation +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.model.PrivateMediaMessageIdentity +import com.google.gson.Gson +import java.io.File +import java.security.MessageDigest +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** One encrypted atomic record per pending media message; no plaintext retransmission spool. */ +internal class PrivateMediaOutboxStore( + context: Context, + private val cipher: ConversationStorageCipher = AndroidConversationStorageCipher("bitchat_private_media_outbox_v1") +) { + data class Entry( + val id: String, + val conversationID: String, + val recipientPeerID: String, + val encodedPacket: String, + val createdAt: Long, + val attempts: Int = 1, + val lastAttemptAt: Long = createdAt + ) + + private val directory = File(context.filesDir, "private-media-outbox") + private val gson = Gson() + + fun load(): List { + if (!directory.exists()) return emptyList() + return checkNotNull(directory.listFiles()).filter { PrivateMediaMessageIdentity.isStableID(it.name) } + .take(MAX_ENTRIES).mapNotNull { file -> + // A corrupt record stays quarantined in place; it is never retransmitted. + runCatching { + require(file.length() <= MAX_RECORD_BYTES) + val plaintext = cipher.decrypt(AtomicFile(file).readFully(), file.name.toByteArray()) + gson.fromJson(plaintext.toString(Charsets.UTF_8), Entry::class.java) + .also { require(it.id == file.name && it.attempts in 1..MAX_ATTEMPTS) } + }.getOrNull() + } + } + + fun save(entry: Entry) { + require(PrivateMediaMessageIdentity.isStableID(entry.id)) + check(directory.isDirectory || directory.mkdirs()) + val files = checkNotNull(directory.listFiles()) + val file = File(directory, entry.id) + check(file.exists() || files.size < MAX_ENTRIES) + val encrypted = cipher.encrypt(gson.toJson(entry).toByteArray(), entry.id.toByteArray()) + require(encrypted.size <= MAX_RECORD_BYTES) + check(files.sumOf { it.length() } - file.length() + encrypted.size <= MAX_TOTAL_BYTES) + val atomic = AtomicFile(file) + val stream = atomic.startWrite() + try { + stream.write(encrypted) + atomic.finishWrite(stream) + } catch (error: Exception) { + atomic.failWrite(stream) + throw error + } + } + + fun remove(id: String) { + require(PrivateMediaMessageIdentity.isStableID(id)) + AtomicFile(File(directory, id)).delete() + check(!File(directory, id).exists()) + } + + fun wipe() { + cipher.destroyKey() + check(!directory.exists() || directory.deleteRecursively()) + } + + companion object { + const val MAX_ATTEMPTS = 8 + private const val MAX_ENTRIES = 100 + private const val MAX_RECORD_BYTES = 8 * 1024 * 1024L + private const val MAX_TOTAL_BYTES = 64 * 1024 * 1024L + } +} + +/** Retries the original file and message identity until a matching recipient acknowledges it. */ +class PrivateMediaOutbox private constructor(context: Context) { + private val store = PrivateMediaOutboxStore(context) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val mutex = Mutex() + private var entries: MutableMap? = null + @Volatile private var paused = false + + init { + scope.launch { + while (isActive) { + delay(15_000) + runCatching { retryPending() } + } + } + } + + private fun loaded(): MutableMap = + entries ?: store.load().associateByTo(linkedMapOf()) { it.id }.also { entries = it } + + suspend fun enqueue(id: String, conversationID: String, recipientPeerID: String, packet: BitchatFilePacket): Boolean = + withContext(Dispatchers.IO) { + mutex.withLock { + if (paused) return@withLock false + runCatching { + val encoded = checkNotNull(packet.encode()) + val entry = PrivateMediaOutboxStore.Entry(id, conversationID, recipientPeerID, + Base64.encodeToString(encoded, Base64.NO_WRAP), System.currentTimeMillis()) + store.save(entry) + loaded()[id] = entry + true + }.getOrDefault(false) + } + } + + fun acknowledge(id: String, peerID: String) { + if (!PrivateMediaMessageIdentity.isStableID(id)) return + scope.launch { + mutex.withLock { + val entry = loaded()[id] ?: return@withLock + if (entry.recipientPeerID != peerID) return@withLock + runCatching { store.remove(id) }.onSuccess { loaded().remove(id) } + } + } + } + + suspend fun wipe() { + paused = true + withContext(Dispatchers.IO) { + mutex.withLock { + store.wipe() + entries = linkedMapOf() + } + } + } + + fun resume() { paused = false } + + private suspend fun retryPending() = mutex.withLock { + if (paused) return@withLock + val mesh = com.bitchat.android.service.MeshServiceHolder.unifiedMeshService ?: return@withLock + val now = System.currentTimeMillis() + loaded().values.toList().forEach { entry -> + if (AppStateStore.privateMediaReceiptState(entry.id) == PrivateMediaReceiptState.TOMBSTONED) { + store.remove(entry.id) + loaded().remove(entry.id) + return@forEach + } + if (now - entry.createdAt > 24 * 60 * 60 * 1000L || + (entry.attempts >= PrivateMediaOutboxStore.MAX_ATTEMPTS && now - entry.lastAttemptAt >= 300_000)) { + AppStateStore.updatePrivateMessageStatus(entry.id, DeliveryStatus.Failed("No delivery receipt received")) + store.remove(entry.id) + loaded().remove(entry.id) + return@forEach + } + if (entry.attempts >= PrivateMediaOutboxStore.MAX_ATTEMPTS) return@forEach + val interval = (30_000L shl (entry.attempts - 1).coerceAtMost(4)).coerceAtMost(300_000L) + if (now - entry.lastAttemptAt < interval) return@forEach + val recipient = ContactDirectory.resolve(entry.conversationID).meshPeerID ?: return@forEach + if (recipient != entry.recipientPeerID || !mesh.supportsPrivateMediaReceipts(recipient)) return@forEach + val encoded = Base64.decode(entry.encodedPacket, Base64.NO_WRAP) + val packet = BitchatFilePacket.decode(encoded) ?: return@forEach + if (PrivateMediaMessageIdentity.stableID(mesh.myPeerID, recipient, packet.fileName) != entry.id) return@forEach + val transferID = MessageDigest.getInstance("SHA-256").digest(encoded).joinToString("") { "%02x".format(it) } + val prepared = mesh.prepareFilePrivate(recipient, packet, transferID, allowLegacyFallback = false) + if (prepared is PrivateMediaPreparation.Ready) { + val attempted = entry.copy(attempts = entry.attempts + 1, lastAttemptAt = now) + store.save(attempted) + loaded()[entry.id] = attempted + if (prepared.transfer.commit()) AppStateStore.updatePrivateMessageStatus(entry.id, DeliveryStatus.Sent) + } + } + } + + companion object { + @Volatile private var instance: PrivateMediaOutbox? = null + fun initialize(context: Context): PrivateMediaOutbox = instance ?: synchronized(this) { + instance ?: PrivateMediaOutbox(context.applicationContext).also { instance = it } + } + fun tryGetInstance(): PrivateMediaOutbox? = instance + } +} diff --git a/app/src/main/java/com/bitchat/android/services/PublicMessageReconciler.kt b/app/src/main/java/com/bitchat/android/services/PublicMessageReconciler.kt deleted file mode 100644 index 12365c50..00000000 --- a/app/src/main/java/com/bitchat/android/services/PublicMessageReconciler.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.bitchat.android.services - -import com.bitchat.android.model.BitchatMessage - -/** - * Owns public-timeline replay deduplication and bridge/radio reconciliation. - * - * The store remains responsible for synchronization and cross-timeline IDs; - * this class keeps bridge-specific alias policy independently testable. - */ -internal class PublicMessageReconciler { - data class Result( - val messages: List, - val accepted: Boolean - ) - - private val seenKeys = mutableSetOf() - - fun reconcile( - existing: List, - incoming: BitchatMessage, - messageIdAlreadySeen: Boolean - ): Result { - val withoutBridgeAliases = if (incoming.isBridged) { - existing - } else { - existing.filterNot { - it.isBridged && it.bridgeRadioMessageIdHint == incoming.id - } - } - val key = publicMessageKey(incoming) - if (messageIdAlreadySeen || key in seenKeys) { - return Result(withoutBridgeAliases, accepted = false) - } - seenKeys += key - return Result(withoutBridgeAliases + incoming, accepted = true) - } - - fun clear() { - seenKeys.clear() - } - - private fun publicMessageKey(message: BitchatMessage): String { - val sender = message.senderPeerID ?: message.sender - return listOf( - sender, - message.timestamp.time.toString(), - message.type.name, - message.channel ?: "", - message.content - ).joinToString("\u001F") - } -} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt b/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt index c0a0610f..2ae7da18 100644 --- a/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt +++ b/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt @@ -1,9 +1,5 @@ package com.bitchat.android.services.bridge -import android.content.SharedPreferences -import androidx.core.content.edit -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken internal class BoundedIdSet(private val capacity: Int) { private val values = LinkedHashSet() @@ -49,52 +45,3 @@ internal class RelaySubscriptionSlot(private val idPrefix: String) { close(id) } } - -/** - * A small insertion-ordered expiring set. Callers own synchronization; bridge - * coordinators keep each instance confined to their serial dispatcher. - */ -internal class PersistentExpiringIdSet( - private val preferences: SharedPreferences, - private val key: String, - private val capacity: Int -) { - private val gson = Gson() - private val values: LinkedHashMap = load() - - fun contains(id: String, nowMs: Long = System.currentTimeMillis()): Boolean { - prune(nowMs) - return (values[id] ?: return false) > nowMs - } - - fun add(id: String, lifetimeMs: Long, nowMs: Long = System.currentTimeMillis()) { - prune(nowMs) - values.remove(id) - values[id] = nowMs + lifetimeMs - while (values.size > capacity) values.remove(values.keys.first()) - persist() - } - - fun clear() { - values.clear() - preferences.edit { remove(key) } - } - - private fun prune(nowMs: Long) { - val changed = values.entries.removeAll { it.value <= nowMs } - if (changed) persist() - } - - private fun load(): LinkedHashMap { - val type = object : TypeToken>() {}.type - val decoded: Map = runCatching { - preferences.getString(key, null) - ?.let { json -> gson.fromJson>(json, type) } - }.getOrNull() ?: emptyMap() - return LinkedHashMap(decoded) - } - - private fun persist() { - preferences.edit { putString(key, gson.toJson(values)) } - } -} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt b/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt index 60323cc5..8683241e 100644 --- a/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt +++ b/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt @@ -17,21 +17,6 @@ data class BridgeUiState( val participants: List = emptyList() ) -sealed interface CourierDepositResult { - data object Published : CourierDepositResult - data object ForwardedToGateway : CourierDepositResult - data object QueuedLocally : CourierDepositResult - data object AlreadyPublished : CourierDepositResult - data class Rejected(val reason: Reason) : CourierDepositResult - - enum class Reason { - BRIDGE_DISABLED, - CONTENT_TOO_LARGE, - INVALID_MESSAGE, - ENCRYPTION_FAILED - } -} - internal data class VerifiedBridgePeer( val peerId: String, val nickname: String, diff --git a/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt b/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt index 75027555..3c40e6b7 100644 --- a/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt +++ b/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt @@ -189,19 +189,6 @@ object MeshBridgeService : BridgeMeshDelegate { } } } - scope.launch { - val defaultRelays = NostrRelayManager.defaultRelays().toSet() - relayManager?.relays - ?.map { relays -> - relays.asSequence() - .filter { it.isConnected && it.url in defaultRelays } - .map { it.url } - .toSet() - } - ?.distinctUntilChanged() - ?.collect { connectedDefaults -> - } - } scope.launch { if (_isEnabled.value) { relayManager?.connect() @@ -217,12 +204,20 @@ object MeshBridgeService : BridgeMeshDelegate { } } + private val publicationGeneration = java.util.concurrent.atomic.AtomicLong() + fun publicationPermit(): () -> Boolean { + val generation = publicationGeneration.get() + val enabledAtCapture = _isEnabled.value + return { enabledAtCapture && _isEnabled.value && generation == publicationGeneration.get() } + } + fun setEnabled(enabled: Boolean) { if (outboundPolicy.get().enabled == enabled) return if (enabled) { panicQuiesced = false suppressPrekeyBroadcasts = false } + publicationGeneration.incrementAndGet() outboundPolicy.set(BridgeOutboundPolicy.capture(enabled, nearbyOnly = false)) _isEnabled.value = enabled prefs?.edit { putBoolean(KEY_ENABLED, enabled) } @@ -289,7 +284,7 @@ object MeshBridgeService : BridgeMeshDelegate { publishedEventIds.add(event.id) injectedEventIds.add(event.id) if (relayManager?.isConnected?.value == true) { - relayManager?.sendEventToGeohash(event, cell) + relayManager?.sendEventToGeohash(event, cell, publicationAllowed = publicationPermit()) } else { val peer = availableBridgePeer() ?: return@launch NostrCarrierPacket.fromEvent( @@ -354,7 +349,7 @@ object MeshBridgeService : BridgeMeshDelegate { if (!directedToUs) handleDownlink(carrier) } NostrCarrierPacket.Direction.TO_GATEWAY, - NostrCarrierPacket.Direction.FROM_GATEWAY -> Unit + NostrCarrierPacket.Direction.FROM_GATEWAY -> MeshGatewayService.handleCarrier(carrier, fromPeerId, directedToUs) } } } @@ -363,6 +358,7 @@ object MeshBridgeService : BridgeMeshDelegate { suspend fun wipe() { // Revoke policy synchronously so no already-queued bridge work can be // authorized by the pre-panic setting. + publicationGeneration.incrementAndGet() outboundPolicy.set(BridgeOutboundPolicy.Denied) panicQuiesced = true suppressPrekeyBroadcasts = true @@ -626,7 +622,7 @@ object MeshBridgeService : BridgeMeshDelegate { private fun publishCarriedEvent(event: NostrEvent, cell: String) { publishedEventIds.add(event.id) - relayManager?.sendEventToGeohash(event, cell) + relayManager?.sendEventToGeohash(event, cell, publicationAllowed = publicationPermit()) } private fun publishPresence() { @@ -635,7 +631,7 @@ object MeshBridgeService : BridgeMeshDelegate { val identity = NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext()) val event = NostrProtocol.createBridgePresenceEvent(cell, identity) publishedEventIds.add(event.id) - relayManager?.sendEventToGeohash(event, cell) + relayManager?.sendEventToGeohash(event, cell, publicationAllowed = publicationPermit()) } private fun startPresenceLoop() { @@ -663,6 +659,12 @@ object MeshBridgeService : BridgeMeshDelegate { presenceJob = null } + fun resumeAfterPanic() { + panicQuiesced = false + suppressPrekeyBroadcasts = false + refreshPrekeys() + } + fun refreshPrekeys() { scope.launch { broadcastPrekeys(force = true) } } diff --git a/app/src/main/java/com/bitchat/android/services/bridge/MeshGatewayService.kt b/app/src/main/java/com/bitchat/android/services/bridge/MeshGatewayService.kt new file mode 100644 index 00000000..073b42b4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/MeshGatewayService.kt @@ -0,0 +1,167 @@ +package com.bitchat.android.services.bridge + +import android.content.Context +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.LiveLocationPrivacyGate +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.model.NostrCarrierPacket +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.nostr.* +import com.bitchat.android.service.MeshServiceHolder +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +internal object GatewayEventPolicy { + fun inspect(carrier: NostrCarrierPacket, nowSeconds: Long): NostrEvent? { + if (!Regex("[0123456789bcdefghjkmnpqrstuvwxyz]{1,12}").matches(carrier.geohash)) return null + val event = carrier.event() ?: return null + if (event.kind != NostrKind.EPHEMERAL_EVENT || + event.tags.none { it.size >= 2 && it[0] == "g" && it[1] == carrier.geohash } || + nowSeconds - event.createdAt.toLong() !in -900L..900L) return null + return event + } +} + +/** Opt-in internet sharing for signed public geohash events (carrier directions 1 and 2). */ +object MeshGatewayService { + private val dispatcher = Dispatchers.IO.limitedParallelism(1) + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + private val generation = AtomicLong() + private val _enabled = MutableStateFlow(false) + val enabled: StateFlow = _enabled.asStateFlow() + private var context: Context? = null + private val radioEvents = BoundedIdSet(512) + private val published = BoundedIdSet(512) + private val delivered = BoundedIdSet(512) + private val inboundTimes = ArrayDeque() + private val perPeer = linkedMapOf>() + private val downlinkTimes = ArrayDeque() + private val pending = linkedMapOf>() + private var drain: Job? = null + private val mesh get() = MeshServiceHolder.unifiedMeshService + private val relays get() = context?.let(NostrRelayManager::getInstance) + + @Synchronized + fun initialize(application: Context) { + if (context != null) return + context = application.applicationContext + _enabled.value = application.getSharedPreferences("mesh_gateway", Context.MODE_PRIVATE).getBoolean("enabled", false) + PeerCapabilities.setGatewayEnabled(_enabled.value) + } + + fun setEnabled(enabled: Boolean) { + generation.incrementAndGet() + _enabled.value = enabled + context?.getSharedPreferences("mesh_gateway", Context.MODE_PRIVATE)?.edit()?.putBoolean("enabled", enabled)?.apply() + PeerCapabilities.setGatewayEnabled(enabled) + if (!enabled) scope.launch { pending.clear(); drain?.cancel(); drain = null } + mesh?.sendBroadcastAnnounce() + } + + suspend fun wipe() { + setEnabled(false) + withContext(dispatcher) { + pending.clear() + drain?.cancel() + drain = null + radioEvents.clear() + published.clear() + delivered.clear() + inboundTimes.clear() + perPeer.clear() + downlinkTimes.clear() + check(context?.getSharedPreferences("mesh_gateway", Context.MODE_PRIVATE)?.edit()?.clear()?.commit() != false) + } + } + + private fun permit(): () -> Boolean { + val captured = generation.get() + val enabledAtSend = _enabled.value + return { enabledAtSend && _enabled.value && generation.get() == captured } + } + + fun uplinkIfOffline(event: NostrEvent, cell: String, liveToken: Long?) { + val current = mesh ?: return + if (liveToken != null && !LiveLocationPrivacyGate.accepts(liveToken)) return + if (relays?.hasConnectedRelay(relays?.getRelaysForGeohash(cell).orEmpty()) == true) return + val gateway = current.getPeerNicknames().keys.firstOrNull { peer -> + current.getPeerInfo(peer)?.let { it.isConnected && it.hasVerifiedAnnouncement && + it.capabilities?.contains(PeerCapabilities.GATEWAY) == true } == true + } ?: return + val carrier = NostrCarrierPacket.fromEvent(NostrCarrierPacket.Direction.TO_GATEWAY, cell, event) ?: return + scope.launch { + if (liveToken != null && !LiveLocationPrivacyGate.accepts(liveToken)) return@launch + radioEvents.add(event.id) + current.sendNostrCarrier(carrier.encode(), gateway) + } + } + + fun handleCarrier(carrier: NostrCarrierPacket, peerID: String, directedToUs: Boolean) { + val application = context ?: return + val allowed = permit() + scope.launch { + val event = GatewayEventPolicy.inspect(carrier, System.currentTimeMillis() / 1000) ?: return@launch + if (carrier.direction == NostrCarrierPacket.Direction.TO_GATEWAY) { + if (!directedToUs || !allowed() || published.contains(event.id)) return@launch + if (mesh?.getPeerInfo(peerID)?.hasVerifiedAnnouncement != true || !allowInbound(peerID)) return@launch + if (!event.isValidSignature() || !allowed()) return@launch + radioEvents.add(event.id) + published.add(event.id) + relays?.sendEventToGeohash(event, carrier.geohash, publicationAllowed = allowed) + NostrBackgroundRuntime.receiveGatewayEvent(event, carrier.geohash) + } else if (carrier.direction == NostrCarrierPacket.Direction.FROM_GATEWAY) { + if (directedToUs || delivered.contains(event.id) || !allowInbound(peerID)) return@launch + val channelManager = LocationChannelManager.getInstance(application) + val selected = (channelManager.selectedChannel.value as? ChannelID.Location)?.channel ?: return@launch + if (selected.geohash != carrier.geohash || !channelManager.canUseSelectedLocationChannel(selected)) return@launch + if (!event.isValidSignature()) return@launch + radioEvents.add(event.id) + delivered.add(event.id) + NostrBackgroundRuntime.receiveGatewayEvent(event, carrier.geohash) + } + } + } + + fun rebroadcastRelayEvent(event: NostrEvent, cell: String, liveToken: Long?) { + val allowed = permit() + if (!allowed()) return + val carrier = NostrCarrierPacket.fromEvent(NostrCarrierPacket.Direction.FROM_GATEWAY, cell, event) ?: return + scope.launch { + if (!allowed() || (liveToken != null && !LiveLocationPrivacyGate.accepts(liveToken)) || + GatewayEventPolicy.inspect(carrier, System.currentTimeMillis() / 1000) == null || + radioEvents.contains(event.id) || delivered.contains(event.id) || pending.containsKey(event.id)) return@launch + if (!event.isValidSignature() || pending.size >= 100) return@launch + pending[event.id] = carrier to liveToken + if (drain?.isActive != true) drain = scope.launch { + while (pending.isNotEmpty() && allowed()) { + delay(kotlin.random.Random.nextLong(200, 1501)) + val now = System.currentTimeMillis() + while (downlinkTimes.firstOrNull()?.let { now - it >= 60_000 } == true) downlinkTimes.removeFirst() + if (downlinkTimes.size >= 30) { delay(1000); continue } + val next = pending.entries.first() + pending.remove(next.key) + val (outgoing, token) = next.value + if (!allowed() || (token != null && !LiveLocationPrivacyGate.accepts(token)) || radioEvents.contains(next.key) || + GatewayEventPolicy.inspect(outgoing, now / 1000) == null) continue + mesh?.sendNostrCarrier(outgoing.encode()) + delivered.add(next.key) + downlinkTimes.addLast(now) + } + } + } + } + + private fun allowInbound(peerID: String): Boolean { + val now = System.currentTimeMillis() + while (inboundTimes.firstOrNull()?.let { now - it >= 60_000 } == true) inboundTimes.removeFirst() + perPeer.entries.removeAll { it.value.lastOrNull()?.let { now - it >= 60_000 } != false } + if (perPeer.size >= 200 && peerID !in perPeer) return false + val times = perPeer.getOrPut(peerID) { ArrayDeque() } + while (times.firstOrNull()?.let { now - it >= 60_000 } == true) times.removeFirst() + if (inboundTimes.size >= 20 || times.size >= 5) return false + inboundTimes.addLast(now) + times.addLast(now) + return true + } +} 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 c3a2fdff..573831fb 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -448,6 +448,8 @@ fun AboutSheet( } } + item(key = "client_privacy") { ClientSettingsSection() } + item(key = "language") { val selectedLanguageName = supportedLanguages .firstOrNull { it.languageTag == selectedLanguageTag } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index a57b7e3b..a1e322ce 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -355,9 +355,9 @@ fun NoiseSessionIcon( ) } - // Closed once the handshake resolves (success or failure); open while idle or in flight. + // A closed lock only represents an established encrypted session. val lockIconRes = when { - sessionState == "established" || sessionState?.startsWith("failed") == true -> + sessionState == "established" -> R.drawable.ic_spec_lock else -> R.drawable.ic_spec_lock_open } 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 4ab7aeb8..40660cb3 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -85,6 +85,9 @@ fun ChatScreen(viewModel: ChatViewModel) { val legacyPrivateMediaConsent by viewModel.legacyPrivateMediaConsent.collectAsStateWithLifecycle() val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle() + val panicWipeState by viewModel.panicWipeState.collectAsStateWithLifecycle() + PanicWipeDialog(panicWipeState, viewModel::panicClearAllData, viewModel::dismissPanicClear) + var messageText by remember { mutableStateOf(TextFieldValue("")) } var showPasswordPrompt by remember { mutableStateOf(false) } var showPasswordDialog by remember { mutableStateOf(false) } @@ -108,6 +111,15 @@ fun ChatScreen(viewModel: ChatViewModel) { ) } + val sharedDraft by viewModel.sharedDraft.collectAsStateWithLifecycle() + LaunchedEffect(sharedDraft) { + sharedDraft?.let { shared -> + val combined = listOf(messageText.text, shared).filter { it.isNotBlank() }.joinToString("\n") + messageText = TextFieldValue(combined, androidx.compose.ui.text.TextRange(combined.length)) + viewModel.consumeSharedText() + } + } + // Show password dialog when needed LaunchedEffect(showPasswordPrompt) { showPasswordDialog = showPasswordPrompt @@ -308,7 +320,7 @@ fun ChatScreen(viewModel: ChatViewModel) { conversationKey = conversationKey, contentPadding = PaddingValues( top = statusBarHeight + headerHeight + - (if (showNotesStrip) notesStripHeight else 0.dp), + notesStripHeight, bottom = composerHeight ), forceScrollToBottom = forceScrollToBottom, @@ -362,16 +374,13 @@ fun ChatScreen(viewModel: ChatViewModel) { } ) - if (showNotesStrip) { - NearbyNotesStrip( + Column(modifier = Modifier.align(Alignment.TopCenter) + .padding(top = statusBarHeight + headerHeight) + .onSizeChanged { notesStripHeight = with(density) { it.height.toDp() } }) { + ConnectivityBanner() + if (showNotesStrip) NearbyNotesStrip( noteCount = nearbyNotes.size, - onClick = { showLocationNotesSheet = true }, - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = statusBarHeight + headerHeight) - .onSizeChanged { size -> - notesStripHeight = with(density) { size.height.toDp() } - }, + onClick = { showLocationNotesSheet = true } ) } @@ -463,7 +472,7 @@ fun ChatScreen(viewModel: ChatViewModel) { colorScheme = colorScheme, onSidebarToggle = { viewModel.showMeshPeerList() }, onShowAppInfo = { viewModel.showAppInfo() }, - onPanicClear = { viewModel.panicClearAllData() }, + onPanicClear = { viewModel.requestPanicClear() }, onLocationChannelsClick = { showLocationChannelsSheet = true }, onLocationNotesClick = { nearbyNotesController.reveal() diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 16f5e409..c6aed279 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -170,7 +170,9 @@ class ChatViewModel( .select(com.bitchat.android.geohash.ChannelID.Mesh) } ) - private val groupStore = GroupStore(application.applicationContext) + private val groupRuntime = com.bitchat.android.groups.GroupRuntime.getInstance(application) + private val groupStore = groupRuntime.store + private val groupCoordinator = groupRuntime.coordinator val groups: StateFlow> = groupStore.groups // Create Noise session delegate for clean dependency injection @@ -247,136 +249,26 @@ class ChatViewModel( NotificationManagerCompat.from(application.applicationContext) ) - private val groupCoordinator = GroupCoordinator(object : GroupCoordinatorContext { - override val groupStore: GroupStore - get() = this@ChatViewModel.groupStore - override val nickname: String - get() = state.getNicknameValue() - override val myPeerID: String - get() = mesh.myPeerID - override val selectedConversationID: String? - get() = state.getSelectedPrivateChatPeerValue() - - override fun myNoiseFingerprint(): String = mesh.getIdentityFingerprint() - override fun mySigningPublicKey(): ByteArray? = mesh.getSigningPublicKey() - override fun sign(data: ByteArray): ByteArray? = mesh.signData(data) - - override fun peerIDsForNickname(nickname: String): List = - mesh.getPeerNicknames().entries.filter { - it.value.equals(nickname, ignoreCase = true) - }.map { it.key } - - override fun isPeerConnected(peerID: String): Boolean = - mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID) - - override fun peerGroupCapability(peerID: String): PeerGroupCapability { - val peerInfo = mesh.getPeerInfo(peerID) ?: return PeerGroupCapability.UNKNOWN - return PeerGroupCapability.fromPeerState( - peerInfo.capabilities, - peerInfo.hasVerifiedAnnouncement - ) - } - - override fun peerNickname(peerID: String): String? = - mesh.getPeerNicknames()[peerID] - - override fun peerIdentity(peerID: String): GroupPeerIdentity? { - val info = mesh.getPeerInfo(peerID) ?: return null - if (info.signingPublicKey?.size != 32) return null - val liveFingerprint = mesh.getPeerFingerprint(peerID) ?: return null - val announcedNoiseKey = info.noisePublicKey ?: return null - val announcedFingerprint = ContactIdentityResolver.fingerprintHex(announcedNoiseKey) - if (!liveFingerprint.equals(announcedFingerprint, ignoreCase = true)) return null - return GroupPeerIdentity( - liveFingerprint.lowercase(), - info.signingPublicKey!!.copyOf() - ) - } - - override fun connectedPeerID(fingerprint: String): String? = - mesh.getPeerNicknames().keys.firstOrNull { peerID -> - mesh.getPeerInfo(peerID)?.isConnected == true && - mesh.hasEstablishedSession(peerID) && - mesh.getPeerFingerprint(peerID).equals(fingerprint, ignoreCase = true) - } - - override fun isFingerprintBlocked(fingerprint: String): Boolean = - dataManager.isUserBlocked(fingerprint) - - override fun sendGroupInvite(payload: ByteArray, peerID: String) { - mesh.sendGroupInvite(payload, peerID) - } - - override fun sendGroupKeyUpdate(payload: ByteArray, peerID: String) { - mesh.sendGroupKeyUpdate(payload, peerID) - } - - override fun broadcastGroupMessage(payload: ByteArray) { - mesh.broadcastGroupMessage(payload) - } - - override fun appendGroupMessage( - groupPeerID: String, - message: BitchatMessage - ): Boolean { - val existing = state.getPrivateChatsValue()[groupPeerID].orEmpty() - if (existing.any { it.id == message.id }) return false - messageManager.addPrivateMessage(groupPeerID, message) - if (state.getSelectedPrivateChatPeerValue() == groupPeerID) { - try { - com.bitchat.android.services.AppStateStore.markPrivateMessageRead(message.id) - } catch (_: Exception) { - } - } - return true - } - + private val groupContext = object : com.bitchat.android.groups.GroupUiDelegate { + override val nickname get() = state.getNicknameValue() override fun markGroupUnread(groupPeerID: String) { - state.setUnreadPrivateMessages( - state.getUnreadPrivateMessagesValue() + groupPeerID - ) + state.setUnreadPrivateMessages(state.getUnreadPrivateMessagesValue() + groupPeerID) } - - override fun removeGroupConversation(groupPeerID: String) { - messageManager.removePrivateChat(groupPeerID) - } - override fun openGroupConversation(groupPeerID: String) { privateChatManager.startPrivateChat(groupPeerID, mesh) showPrivateChatSheet(groupPeerID) } + override fun closeGroupConversation() { endPrivateChat() } + } - override fun closeGroupConversation() { - endPrivateChat() + fun executeGroupCommand(arguments: List, onResult: (GroupCommandResult) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val result = handleGroupCommand(arguments) + kotlinx.coroutines.withContext(Dispatchers.Main) { onResult(result) } } + } - override fun addSystemMessage(message: String) { - messageManager.addSystemMessage(message) - } - - override fun addGroupSystemMessage(groupPeerID: String, message: String) { - messageManager.addPrivateMessage( - groupPeerID, - BitchatMessage( - sender = "system", - content = message, - timestamp = Date(), - isPrivate = true, - recipientNickname = groupStore.group(groupPeerID)?.name - ) - ) - } - - override fun notifyGroupMessage( - groupPeerID: String, - sender: String, - message: String - ) { - notificationManager.showPrivateMessageNotification(groupPeerID, sender, message) - } - }) - - fun handleGroupCommand(arguments: List): GroupCommandResult { + private fun handleGroupCommand(arguments: List): GroupCommandResult { val subcommand = arguments.firstOrNull()?.lowercase() ?: return GroupCommandResult(false, GROUP_COMMAND_USAGE) val value = arguments.drop(1).joinToString(" ") @@ -648,11 +540,7 @@ class ChatViewModel( init { observeConversationPresenceWithDisconnectGrace() - viewModelScope.launch(Dispatchers.IO) { - if (groupStore.initialize()) { - groupCoordinator.onStoreReady() - } - } + groupRuntime.attach(groupContext) // Note: Mesh service delegate is now set by MainActivity loadAndInitialize() ContactDirectory.initialize(getApplication()) { mesh } @@ -874,6 +762,7 @@ class ChatViewModel( } override fun onCleared() { + groupRuntime.detach(groupContext) if (favoriteRelationshipListenerRegistered) { runCatching { FavoritesPersistenceService.shared.removeListener( @@ -1205,7 +1094,11 @@ class ChatViewModel( if (selectedPeer != null) { if (GroupIds.isGroup(selectedPeer)) { - groupCoordinator.sendMessage(content, selectedPeer) + val groupPeer = selectedPeer + viewModelScope.launch(Dispatchers.IO) { + val accepted = groupCoordinator.sendMessage(content, groupPeer) + kotlinx.coroutines.withContext(Dispatchers.Main) { onAccepted(accepted) } + } return } // If the selected peer is a temporary Nostr alias or a noise-hex identity, resolve to a canonical target @@ -1715,107 +1608,78 @@ class ChatViewModel( return meshDelegateHandler.isFavorite(peerID) } + private val _sharedDraft = MutableStateFlow(null) + val sharedDraft: StateFlow = _sharedDraft.asStateFlow() + fun receiveSharedText(text: String) { _sharedDraft.value = text.take(16_000) } + fun consumeSharedText() { _sharedDraft.value = null } + // MARK: - Emergency Clear - private var panicClearInProgress = false + private val _panicWipeState = MutableStateFlow(PanicWipeState.IDLE) + val panicWipeState: StateFlow = _panicWipeState.asStateFlow() + fun requestPanicClear() { + if (_panicWipeState.value != PanicWipeState.ERASING) _panicWipeState.value = PanicWipeState.CONFIRM + } + fun dismissPanicClear() { + if (_panicWipeState.value != PanicWipeState.ERASING) _panicWipeState.value = PanicWipeState.IDLE + } fun panicClearAllData() { - if (panicClearInProgress) return - panicClearInProgress = true + if (_panicWipeState.value == PanicWipeState.ERASING) return + _panicWipeState.value = PanicWipeState.ERASING viewModelScope.launch { - try { - performPanicClearAllData() - } finally { - panicClearInProgress = false - } + val completed = try { performPanicClearAllData() } catch (_: Exception) { false } + _panicWipeState.value = if (completed) PanicWipeState.COMPLETE else PanicWipeState.FAILED } } - private suspend fun performPanicClearAllData() { - groupCoordinator.suspendForPanic() - Log.w(TAG, "Panic wipe started") - try { - com.bitchat.android.geohash.LocationChannelManager - .getInstance(getApplication()) - .disableLocationServices() - } catch (_: Exception) { } - - // A pending one-shot downgrade confirmation must not survive panic or - // become actionable against the fresh post-wipe identity. - mediaSendingManager.clearPendingPrivateMediaConsent() - MeshBridgeService.wipe() - - // Stop all message admission before wiping storage. The AppStateStore gate also rejects - // any transport callback already in flight until the fresh identity is ready. - clearAllMeshServiceData() - val conversationsCleared = - com.bitchat.android.services.AppStateStore - .panicClearPrivateConversations() - - // Clear all UI managers - com.bitchat.android.services.AppStateStore.clear() - messageManager.clearAllMessages() - channelManager.clearAllChannels() - privateChatManager.clearAllPrivateChats() - val groupsCleared = groupStore.wipe() - dataManager.clearAllData() - conversationListPreferences.clearAll() - boardManager.clearTransientState() - - // Clear seen message store and MessageRouter outbox - try { - com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() - } catch (_: Exception) { } - try { - com.bitchat.android.services.MessageRouter.panicClear(getApplication()) - } catch (_: Exception) { } - - // Clear all cryptographic data - clearAllCryptographicData() - - // Clear all notifications - notificationManager.clearAllNotifications(removeConversationShortcuts = true) - - // Clear all media files - com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication()) - - // Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch - try { - // Clear geohash bookmarks too (panic should remove everything) - try { - val store = com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication()) - store.clearAll() - } catch (_: Exception) { } - - try { - val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) - locationManager.panicReset() - } catch (_: Exception) { } - - geohashViewModel.panicReset() - } catch (e: Exception) { - Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") + private suspend fun performPanicClearAllData(): Boolean { + var successful = true + suspend fun step(action: suspend () -> Unit) { + try { action() } catch (_: Exception) { successful = false } } + groupCoordinator.suspendForPanic() + step { com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()).disableLocationServices() } + step { com.bitchat.android.services.bridge.MeshGatewayService.wipe() } + step { MeshBridgeService.wipe() } + step { mesh.stopServices(); mesh.clearAllInternalData() } + step { check(com.bitchat.android.services.AppStateStore.panicClearPrivateConversations()) } + step { com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.wipe() } + mediaSendingManager.clearPendingPrivateMediaConsent() + step { + com.bitchat.android.services.AppStateStore.clear() + messageManager.clearAllMessages() + channelManager.clearAllChannels() + privateChatManager.clearAllPrivateChats() + check(groupStore.wipe()) + dataManager.clearAllData() + conversationListPreferences.clearAll() + boardManager.clearTransientState() + } + step { com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() } + step { com.bitchat.android.services.MessageRouter.panicClear(getApplication()) } + step { + mesh.clearAllEncryptionData() + check(SecureIdentityStateManager(getApplication()).clearIdentityData()) + FavoritesPersistenceService.shared.clearAllFavorites() + } + step { notificationManager.clearAllNotifications(removeConversationShortcuts = true) } + step { check(com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication())) } + step { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication()).clearAll() } + step { com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()).panicReset() } + step { com.bitchat.android.nostr.NostrRelayManager.getInstance(getApplication()).clearCustomRelays() } + step { geohashViewModel.panicReset() } + if (!successful) return false - // Reset nickname val newNickname = "anon${Random.nextInt(1000, 9999)}" state.setNickname(newNickname) dataManager.saveNickname(newNickname) - - if (!conversationsCleared || !groupsCleared) { - // Privacy wins over availability: keep private-message admission and transports - // stopped if SQLite could not prove that the conversation history was erased. - Log.e(TAG, "🚨 PANIC MODE INCOMPLETE - conversation database wipe failed") - return - } - - // Recreate mesh service with fresh identity - com.bitchat.android.services.AppStateStore - .resumePrivateConversationsAfterPanic() recreateMeshServiceAfterPanic() + com.bitchat.android.services.AppStateStore.resumePrivateConversationsAfterPanic() groupCoordinator.resumeAfterPanic() - - Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}") + MeshBridgeService.resumeAfterPanic() + com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.resume() + return true } /** @@ -1847,53 +1711,6 @@ class ChatViewModel( ) } - /** - * Clear all mesh service related data - */ - private fun clearAllMeshServiceData() { - try { - // Request mesh service to clear all its internal data - mesh.clearAllInternalData() - - Log.d(TAG, "βœ… Cleared all mesh service data") - } catch (e: Exception) { - Log.e(TAG, "❌ Error clearing mesh service data: ${e.message}") - } - } - - /** - * Clear all cryptographic data including persistent identity - */ - private fun clearAllCryptographicData() { - try { - // Clear encryption service persistent identity (Ed25519 signing keys) - mesh.clearAllEncryptionData() - - // Clear secure identity state (if used) - try { - val identityManager = SecureIdentityStateManager(getApplication()) - identityManager.clearIdentityData() - // Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) - try { - identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index") - } catch (_: Exception) { } - Log.d(TAG, "βœ… Cleared secure identity state and secure favorites store") - } catch (e: Exception) { - Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}") - } - - // Clear FavoritesPersistenceService persistent relationships - try { - FavoritesPersistenceService.shared.clearAllFavorites() - Log.d(TAG, "βœ… Cleared FavoritesPersistenceService relationships") - } catch (_: Exception) { } - - Log.d(TAG, "βœ… Cleared all cryptographic data") - } catch (e: Exception) { - Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}") - } - } - /** * Get participant count for a specific geohash (5-minute activity window) */ diff --git a/app/src/main/java/com/bitchat/android/ui/ClientPrivacyPreferences.kt b/app/src/main/java/com/bitchat/android/ui/ClientPrivacyPreferences.kt new file mode 100644 index 00000000..762680fd --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ClientPrivacyPreferences.kt @@ -0,0 +1,14 @@ +package com.bitchat.android.ui + +import android.content.Context + +object ClientPrivacyPreferences { + fun showNotificationPreviews(context: Context): Boolean = + context.getSharedPreferences("client_privacy", Context.MODE_PRIVATE).getBoolean("notification_previews", false) + + fun setNotificationPreviews(context: Context, enabled: Boolean) { + context.getSharedPreferences("client_privacy", Context.MODE_PRIVATE).edit() + .putBoolean("notification_previews", enabled).apply() + NotificationManager.clearAllForPrivacyChange(context) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ClientSettingsSection.kt b/app/src/main/java/com/bitchat/android/ui/ClientSettingsSection.kt new file mode 100644 index 00000000..9207e9c8 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ClientSettingsSection.kt @@ -0,0 +1,65 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R +import com.bitchat.android.nostr.NostrRelayManager + +@Composable +fun ClientSettingsSection() { + val context = LocalContext.current + val relayManager = remember { NostrRelayManager.getInstance(context) } + val relays by relayManager.customRelays.collectAsStateWithLifecycle() + val gateway by com.bitchat.android.services.bridge.MeshGatewayService.enabled.collectAsStateWithLifecycle() + var previews by remember { mutableStateOf(ClientPrivacyPreferences.showNotificationPreviews(context)) } + var relayInput by remember { mutableStateOf("") } + var relayError by remember { mutableStateOf(false) } + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.notification_previews), style = MaterialTheme.typography.titleSmall) + Text(stringResource(R.string.notification_previews_description), style = MaterialTheme.typography.bodySmall) + } + Switch(checked = previews, onCheckedChange = { + previews = it + ClientPrivacyPreferences.setNotificationPreviews(context, it) + }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.gateway_title), style = MaterialTheme.typography.titleSmall) + Text(stringResource(R.string.gateway_description), style = MaterialTheme.typography.bodySmall) + } + Switch(gateway, onCheckedChange = com.bitchat.android.services.bridge.MeshGatewayService::setEnabled) + } + Text(stringResource(R.string.custom_relays), style = MaterialTheme.typography.titleSmall) + Text(stringResource(R.string.custom_relays_description), style = MaterialTheme.typography.bodySmall) + relays.forEach { url -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text(url, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + TextButton(onClick = { relayManager.removeCustomRelay(url) }) { Text(stringResource(R.string.relay_remove)) } + } + } + OutlinedTextField( + value = relayInput, + onValueChange = { relayInput = it; relayError = false }, + label = { Text(stringResource(R.string.relay_url)) }, + placeholder = { Text("wss://relay.example") }, + singleLine = true, + isError = relayError, + modifier = Modifier.fillMaxWidth() + ) + if (relayError) Text(stringResource(R.string.relay_invalid), color = MaterialTheme.colorScheme.error) + TextButton(onClick = { + relayError = !relayManager.addCustomRelay(relayInput) + if (!relayError) relayInput = "" + }, enabled = relayInput.isNotBlank()) { Text(stringResource(R.string.relay_add)) } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index da35cef3..463ae97e 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -79,10 +79,9 @@ class CommandProcessor( return } - val result = viewModel.handleGroupCommand( - parts.drop(1).filter(String::isNotBlank) - ) - addCommandOutput(result.message) + viewModel.executeGroupCommand(parts.drop(1).filter(String::isNotBlank)) { result -> + addCommandOutput(result.message) + } } private fun addCommandOutput(message: String) { diff --git a/app/src/main/java/com/bitchat/android/ui/ConnectivityBanner.kt b/app/src/main/java/com/bitchat/android/ui/ConnectivityBanner.kt new file mode 100644 index 00000000..eac63629 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ConnectivityBanner.kt @@ -0,0 +1,62 @@ +package com.bitchat.android.ui + +import android.Manifest +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.os.Build +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.TorMode + +@Composable +fun ConnectivityBanner() { + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current.lifecycle + fun bluetoothReady(): Boolean = runCatching { + if (Build.VERSION.SDK_INT >= 31 && ContextCompat.checkSelfPermission(context, + Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) false + else context.getSystemService(BluetoothManager::class.java)?.adapter?.isEnabled == true + }.getOrDefault(false) + var bluetoothEnabled by remember { mutableStateOf(bluetoothReady()) } + val tor by remember { ArtiTorManager.getInstance().statusFlow }.collectAsStateWithLifecycle() + DisposableEffect(context, lifecycle) { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { bluetoothEnabled = bluetoothReady() } + } + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) bluetoothEnabled = bluetoothReady() + } + ContextCompat.registerReceiver(context, receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED), ContextCompat.RECEIVER_EXPORTED) + lifecycle.addObserver(observer) + onDispose { context.unregisterReceiver(receiver); lifecycle.removeObserver(observer) } + } + val warning = when { + !bluetoothEnabled -> stringResource(R.string.bluetooth_unavailable_banner) + tor.mode == TorMode.ON && tor.state == ArtiTorManager.TorState.ERROR -> stringResource(R.string.tor_failed_banner) + tor.mode == TorMode.ON && tor.state != ArtiTorManager.TorState.RUNNING -> stringResource(R.string.tor_connecting_banner, tor.bootstrapPercent) + else -> null + } + warning?.let { + Surface(color = MaterialTheme.colorScheme.errorContainer, modifier = Modifier.fillMaxWidth()) { + Text(it, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 63a5c0a7..b64a0e69 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -164,6 +164,7 @@ class GeohashViewModel( teleported ) val relayManager = NostrRelayManager.getInstance(getApplication()) + com.bitchat.android.services.bridge.MeshGatewayService.uplinkIfOffline(event, channel.geohash, liveLocationToken) relayManager.sendEventToGeohash( event, channel.geohash, 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 2b4d6514..c4f53606 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -231,6 +231,18 @@ fun LocationChannelsSheet( verticalArrangement = Arrangement.spacedBy(0.dp) ) { // Mesh section: icon + title header, offline subtitle, then selection card + (selectedChannel as? ChannelID.Location)?.channel?.geohash?.let { cell -> + item(key = "share_channel") { + androidx.compose.material3.TextButton(onClick = { + val link = com.bitchat.android.services.ChannelInvitation.link(cell) + if (link != null) context.startActivity(android.content.Intent.createChooser( + android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(android.content.Intent.EXTRA_TEXT, context.getString(R.string.channel_invitation, cell, link)) + }, context.getString(R.string.share_channel))) + }, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.share_channel)) } + } + } item(key = "mesh_card") { Column { SheetIconSectionHeader( diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index d5db98bb..fd41d17e 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -50,6 +50,7 @@ class MediaSendingManager( // Track in-flight transfer progress: transferId -> messageId and reverse private val transferMessageMap = mutableMapOf() private val messageTransferMap = mutableMapOf() + private val privateTransferMessageIDs = mutableSetOf() private val pendingConsentLock = Any() private val _legacyPrivateMediaConsent = MutableStateFlow(null) val legacyPrivateMediaConsent: StateFlow = @@ -288,10 +289,17 @@ class MediaSendingManager( */ private suspend fun sendPrivateFile( toPeerID: String, - filePacket: BitchatFilePacket, + originalPacket: BitchatFilePacket, filePath: String, messageType: BitchatMessageType ) { + val filePacket = if (com.bitchat.android.model.PrivateMediaMessageIdentity.stableID( + meshService.myPeerID, meshService.myPeerID, originalPacket.fileName) != null) originalPacket + else when (originalPacket.mimeType) { + "image/jpeg" -> originalPacket.copy(fileName = "img_${UUID.randomUUID()}.jpg") + "audio/mp4", "audio/m4a" -> originalPacket.copy(fileName = "voice_${UUID.randomUUID()}.m4a") + else -> originalPacket + } val payload = withContext(mediaWorkDispatcher) { filePacket.encode() } ?: run { Log.e(TAG, "Failed to encode file packet for private send") @@ -454,7 +462,8 @@ class MediaSendingManager( pending.recipientMeshPeerID, pending.filePath, pending.messageType, - pending.transferId + pending.transferId, + pending.filePacket ) } @@ -605,15 +614,20 @@ class MediaSendingManager( recipientMeshPeerID: String, filePath: String, messageType: BitchatMessageType, - transferId: String + transferId: String, + filePacket: BitchatFilePacket ) { if (preparation.transfer.transferId != transferId) { Log.e(TAG, "Prepared private-media transfer ID changed; send aborted") return } + val stableID = if (preparation.transfer.wireMode == com.bitchat.android.mesh.PrivateMediaWireMode.ENCRYPTED_NOISE_0X20) { + com.bitchat.android.model.PrivateMediaMessageIdentity.stableID(meshService.myPeerID, recipientMeshPeerID, filePacket.fileName) + } else null + val expectsReceipt = stableID != null && meshService.supportsPrivateMediaReceipts(recipientMeshPeerID) val msg = BitchatMessage( - id = UUID.randomUUID().toString().uppercase(), + id = stableID ?: UUID.randomUUID().toString().uppercase(), sender = state.getNicknameValue() ?: "me", content = filePath, type = messageType, @@ -638,7 +652,14 @@ class MediaSendingManager( ) return } + if (expectsReceipt && com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance() + ?.enqueue(msg.id, conversationID, recipientMeshPeerID, filePacket) != true) { + messageManager.updateMessageDeliveryStatus(msg.id, + com.bitchat.android.model.DeliveryStatus.Failed("Unable to save media for reliable delivery")) + return + } synchronized(transferMessageMap) { + privateTransferMessageIDs += msg.id transferMessageMap[transferId] = msg.id messageTransferMap[msg.id] = transferId } @@ -777,16 +798,24 @@ class MediaSendingManager( ) synchronized(transferMessageMap) { val msgIdRemoved = transferMessageMap.remove(evt.transferId) - if (msgIdRemoved != null) messageTransferMap.remove(msgIdRemoved) + if (msgIdRemoved != null) { + messageTransferMap.remove(msgIdRemoved) + privateTransferMessageIDs.remove(msgIdRemoved) + } } } else if (evt.completed) { messageManager.updateMessageDeliveryStatus( msgId, - com.bitchat.android.model.DeliveryStatus.Delivered(to = "mesh", at = java.util.Date()) + if (synchronized(transferMessageMap) { msgId in privateTransferMessageIDs }) { + com.bitchat.android.model.DeliveryStatus.Sent + } else com.bitchat.android.model.DeliveryStatus.Delivered(to = "mesh", at = java.util.Date()) ) synchronized(transferMessageMap) { val msgIdRemoved = transferMessageMap.remove(evt.transferId) - if (msgIdRemoved != null) messageTransferMap.remove(msgIdRemoved) + if (msgIdRemoved != null) { + messageTransferMap.remove(msgIdRemoved) + privateTransferMessageIDs.remove(msgIdRemoved) + } } } else { messageManager.updateMessageDeliveryStatus( diff --git a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt index 2b77075d..2ad9d29c 100644 --- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -68,6 +68,12 @@ class NotificationManager( private val liveManagers: MutableSet = Collections.newSetFromMap(WeakHashMap()) + fun clearAllForPrivacyChange(context: Context) { + synchronized(liveManagers) { liveManagers.toList() } + .forEach { it.clearAllNotifications(removeConversationShortcuts = true) } + NotificationManagerCompat.from(context).cancelAll() + } + /** * Synchronizes notification action receivers with every manager instance in this process. * Without this, an old in-memory MessagingStyle history could reappear on the next DM. @@ -198,8 +204,8 @@ class NotificationManager( val notification = PendingNotification( senderPeerID = conversationID, - senderNickname = senderNickname, - messageContent = messageContent, + senderNickname = if (ClientPrivacyPreferences.showNotificationPreviews(context)) senderNickname else context.getString(R.string.app_name), + messageContent = if (ClientPrivacyPreferences.showNotificationPreviews(context)) messageContent else context.getString(R.string.notification_hidden_message), timestamp = System.currentTimeMillis() ) @@ -243,11 +249,9 @@ class NotificationManager( .setKey(senderPeerID) .build() val shortcutID = conversationShortcutID(senderPeerID) - publishConversationShortcut( - shortcutID = shortcutID, - person = person, - contentIntent = intent - ) + if (ClientPrivacyPreferences.showNotificationPreviews(context)) { + publishConversationShortcut(shortcutID = shortcutID, person = person, contentIntent = intent) + } // Build notification content val contentText = if (messageCount == 1) { @@ -523,8 +527,8 @@ class NotificationManager( val notification = GeohashNotification( geohash = geohash, - senderNickname = senderNickname, - messageContent = messageContent, + senderNickname = if (ClientPrivacyPreferences.showNotificationPreviews(context)) senderNickname else context.getString(R.string.app_name), + messageContent = if (ClientPrivacyPreferences.showNotificationPreviews(context)) messageContent else context.getString(R.string.notification_hidden_message), timestamp = System.currentTimeMillis(), isMention = isMention, isFirstMessage = isFirstMessage, @@ -748,8 +752,8 @@ class NotificationManager( val meshMentionKey = "mesh_mentions" val notification = PendingNotification( senderPeerID = senderPeerID ?: meshMentionKey, - senderNickname = senderNickname, - messageContent = messageContent, + senderNickname = if (ClientPrivacyPreferences.showNotificationPreviews(context)) senderNickname else context.getString(R.string.app_name), + messageContent = if (ClientPrivacyPreferences.showNotificationPreviews(context)) messageContent else context.getString(R.string.notification_hidden_message), timestamp = System.currentTimeMillis() ) diff --git a/app/src/main/java/com/bitchat/android/ui/PanicWipeDialog.kt b/app/src/main/java/com/bitchat/android/ui/PanicWipeDialog.kt new file mode 100644 index 00000000..0a08d531 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PanicWipeDialog.kt @@ -0,0 +1,42 @@ +package com.bitchat.android.ui + +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.bitchat.android.R + +enum class PanicWipeState { IDLE, CONFIRM, ERASING, COMPLETE, FAILED } + +@Composable +fun PanicWipeDialog(state: PanicWipeState, onConfirm: () -> Unit, onDismiss: () -> Unit) { + if (state == PanicWipeState.IDLE) return + AlertDialog( + onDismissRequest = { if (state != PanicWipeState.ERASING) onDismiss() }, + title = { Text(stringResource(when (state) { + PanicWipeState.CONFIRM -> R.string.panic_confirm_title + PanicWipeState.ERASING -> R.string.panic_erasing_title + PanicWipeState.COMPLETE -> R.string.panic_complete_title + else -> R.string.panic_failed_title + })) }, + text = { + if (state == PanicWipeState.ERASING) CircularProgressIndicator() + else Text(stringResource(when (state) { + PanicWipeState.CONFIRM -> R.string.panic_confirm_body + PanicWipeState.COMPLETE -> R.string.panic_complete_body + else -> R.string.panic_failed_body + })) + }, + confirmButton = { + if (state != PanicWipeState.ERASING) TextButton(onClick = { + if (state == PanicWipeState.CONFIRM || state == PanicWipeState.FAILED) onConfirm() else onDismiss() + }) { Text(stringResource(when (state) { + PanicWipeState.CONFIRM -> R.string.panic_erase + PanicWipeState.FAILED -> R.string.panic_retry + else -> android.R.string.ok + })) } + }, + dismissButton = { + if (state == PanicWipeState.CONFIRM) TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + } + ) +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index 2a8da6a3..3dfde238 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -167,6 +167,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor }, hooks = MeshCore.Hooks( onMessageReceived = { message -> handleMessageReceived(message) }, + onDeliveryReceipt = { id, peer -> + com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.acknowledge(id, peer) + com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(id, peer) + }, onAnnounceProcessed = { routed, _ -> publishControllerDebugSnapshot() routed.peerID?.let { pid -> @@ -1460,6 +1464,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor meshCore.sendVoiceFrame(recipientPeerID, payload) } + override fun supportsPrivateMediaReceipts(peerID: String): Boolean = meshCore.supportsPrivateMediaReceipts(peerID) + override fun prepareFilePrivate( recipientPeerID: String, file: BitchatFilePacket, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82376531..1993f21b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -694,4 +694,29 @@ App language System default Select language + New message + Notification previews + Show sender names and message text in notifications. Hidden by default. + Custom relays + Add relays for your direct messages. Built-in relays stay available. + Relay URL + Add relay + Remove + Enter a valid wss URL without credentials or query parameters. Up to 20 custom relays are supported. + Share channel invitation + Join #%1$s on bitchat: %2$s β€” get the app at https://bitchat.free + Bluetooth is unavailable. Nearby Bluetooth messaging is paused. + Tor could not connect. Internet messages are paused. + Connecting to Tor (%1$d%%). Internet messages are waiting. + Erase local bitchat data? + This removes conversations, media, keys, favorites, groups, and custom relays from this app. Copies held by other people or relays remain. This cannot be undone. + Erasing local data… + Local data erased + A new identity is ready. Copies on other devices and relays were not erased. + Erase incomplete + Some local data could not be erased. Messaging remains paused. Retry to finish. + Erase data + Retry erase + Share internet with the mesh + Carry signed public channel messages between nearby peers and relays. Off by default. diff --git a/app/src/test/kotlin/com/bitchat/android/identity/VouchPersistenceTest.kt b/app/src/test/kotlin/com/bitchat/android/identity/VouchPersistenceTest.kt index 3bbd27cd..a389a491 100644 --- a/app/src/test/kotlin/com/bitchat/android/identity/VouchPersistenceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/identity/VouchPersistenceTest.kt @@ -41,7 +41,7 @@ class VouchPersistenceTest { } @After - fun tearDown() = manager.clearIdentityData() + fun tearDown() { manager.clearIdentityData() } @Test fun `vouch persists and derives trust only while voucher remains verified`() { diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt index 68e21a72..29e1b825 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt @@ -439,7 +439,7 @@ class MessageHandlerTest { } @Test - fun `opened courier private message is admitted as its Noise sender`() = runBlocking { + fun `opened courier message retains its authenticated sender but cannot acknowledge without persistence`() = runBlocking { whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname) whenever(delegate.getMyNickname()).thenReturn("me") val payload = NoisePayload( @@ -447,7 +447,7 @@ class MessageHandlerTest { requireNotNull(PrivateMessagePacket("courier-message", "opaque courier content").encode()) ).encode() - assertTrue( + assertFalse( handler.handleOpenedCourierPayload( RoutedPacket(encryptedPacket().copy(payload = payload), peerID, "courier-ingress") ) diff --git a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt index 85492d36..d56366cb 100644 --- a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt @@ -101,10 +101,11 @@ class IdentityAnnouncementTest { @Test fun `local announcement send advertises private media and groups`() { + PeerCapabilities.setPhoneFeaturesEnabled(true) val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!! assertArrayEquals( - byteArrayOf(0x05, 0x02, 0x79, 0x01), + byteArrayOf(0x05, 0x02, 0x79, 0x03), encoded.takeLast(4).toByteArray() ) val capabilities = IdentityAnnouncement.decode(encoded)!!.capabilities!! diff --git a/app/src/test/kotlin/com/bitchat/android/model/PrivateMediaMessageIdentityTest.kt b/app/src/test/kotlin/com/bitchat/android/model/PrivateMediaMessageIdentityTest.kt new file mode 100644 index 00000000..846d3da1 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/PrivateMediaMessageIdentityTest.kt @@ -0,0 +1,34 @@ +package com.bitchat.android.model + +import org.junit.Assert.* +import org.junit.Test + +class PrivateMediaMessageIdentityTest { + private val sender = "0011223344556677" + private val recipient = "8899aabbccddeeff" + + @Test + fun `matches the iOS version one golden vector`() { + assertEquals("media-910bd42c65060ab76bb6406f220c4516", + PrivateMediaMessageIdentity.stableID(sender, recipient, + "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg")) + } + + @Test + fun `retries keep identity while direction and name changes do not collide`() { + val name = "voice_0011223344556677.m4a" + val id = PrivateMediaMessageIdentity.stableID(sender, recipient, name) + assertNotNull(id) + assertTrue(PrivateMediaMessageIdentity.isStableID(id!!)) + assertNotEquals(id, PrivateMediaMessageIdentity.stableID(recipient, sender, name)) + assertNotEquals(id, PrivateMediaMessageIdentity.stableID(sender, recipient, "voice_0011223344556678.m4a")) + } + + @Test + fun `ordinary names and paths do not acquire receipt identities`() { + listOf("voice_20260908.m4a", "img_20260908.jpg", "../voice_0011223344556677.m4a", + "voice_0011223344556677.mp3", "photo.png").forEach { + assertNull(PrivateMediaMessageIdentity.stableID(sender, recipient, it)) + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/CustomRelayUrlTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/CustomRelayUrlTest.kt new file mode 100644 index 00000000..e1a17987 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/CustomRelayUrlTest.kt @@ -0,0 +1,18 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.* +import org.junit.Test + +class CustomRelayUrlTest { + @Test fun `normalizes secure relay without dropping path or port`() { + assertEquals("wss://relay.example", CustomRelayUrl.normalize(" WSS://RELAY.EXAMPLE/ ")) + assertEquals("wss://relay.example:8443/nostr", CustomRelayUrl.normalize("wss://relay.example:8443/nostr")) + } + @Test fun `rejects insecure credentials queries and invalid endpoints`() { + listOf("ws://relay.example", "https://relay.example", "wss://user:secret@relay.example", + "wss://relay.example?secret=x", "wss://relay.example#fragment", "wss://relay.example:0", + "wss://relay.example:65536", "wss:///nostr", "not a URL").forEach { + assertNull(it, CustomRelayUrl.normalize(it)) + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt index 8954cbe3..b8e5fa9b 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt @@ -68,6 +68,20 @@ class NostrPendingEventQueueTest { ) } + @Test + fun `revocation invalidates queued and already selected deliveries even after reenable`() { + val queue = NostrPendingEventQueue(4) + var generation = 1 + val captured = generation + queue.enqueue(event("bridge"), listOf("relay"), null) { generation == captured } + val selected = queue.pendingForRelay("relay").single() + generation++ // Disable. + generation++ // Re-enable; old permission must remain invalid. + org.junit.Assert.assertFalse(selected.publicationAllowed()) + assertEquals(emptyList(), queue.pendingForRelay("relay")) + assertEquals(0, queue.size()) + } + private fun event(content: String): NostrEvent { val privateKey = "0".repeat(63) + "1" return NostrEvent( diff --git a/app/src/test/kotlin/com/bitchat/android/services/ChannelInvitationTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ChannelInvitationTest.kt new file mode 100644 index 00000000..28822e63 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ChannelInvitationTest.kt @@ -0,0 +1,16 @@ +package com.bitchat.android.services + +import org.junit.Assert.* +import org.junit.Test + +class ChannelInvitationTest { + @Test fun `explicit synthetic cell round trips without location access`() { + assertEquals("s000", ChannelInvitation.decode(ChannelInvitation.link("s000")!!)) + } + @Test fun `rejects ambiguous or malformed invitations`() { + listOf("https://geohash/s000", "bitchat://geohash/s000?live=1", + "bitchat://geohash/s000#extra", "bitchat://user@geohash/s000", + "bitchat://geohash:42/s000", "bitchat://geohash/", "bitchat://geohash/invalid", + "bitchat://geohash/s000/extra").forEach { assertNull(it, ChannelInvitation.decode(it)) } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt index 0d5947c9..1c29c86e 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt @@ -38,6 +38,42 @@ class ConversationRepositoryTest { context.deleteDatabase(databaseName) } + @Test + fun `text receipts require matching durable content and survive deletion`() = runBlocking { + repository = ConversationRepository(context, dispatcher, databaseName, InMemoryConversationStorageCipher()) + val message = BitchatMessage(id = "synthetic-receipt", sender = "alice", content = "durable text", + timestamp = Date(100), isPrivate = true, senderPeerID = "peer-alice") + org.junit.Assert.assertFalse(repository.hasPrivateTextReceipt(message)) + assertTrue(repository.upsertMessageAndWait("peer-alice", setOf("peer-alice"), "alice", message, false)) + assertTrue(repository.hasPrivateTextReceipt(message)) + org.junit.Assert.assertFalse(repository.hasPrivateTextReceipt(message.copy(senderPeerID = "peer-other"))) + org.junit.Assert.assertFalse(repository.hasPrivateTextReceipt(message.copy(content = "replacement"))) + repository.deleteMessage(message.id) + repository.awaitPendingWrites() + assertTrue(repository.hasPrivateTextReceipt(message)) + } + + @Test + fun `stable media is acknowledged only while durable or explicitly deleted`() = runBlocking { + val cipher = InMemoryConversationStorageCipher() + repository = ConversationRepository(context, dispatcher, databaseName, cipher) + val id = "media-00112233445566778899aabbccddeeff" + val payload = java.io.File(context.filesDir, "synthetic-media.m4a").apply { writeBytes(byteArrayOf(1, 2)) } + val message = BitchatMessage(id = id, sender = "alice", content = payload.absolutePath, + type = com.bitchat.android.model.BitchatMessageType.Audio, timestamp = Date(100), isPrivate = true) + assertEquals(PrivateMediaReceiptState.ABSENT, repository.privateMediaReceiptState(id)) + assertTrue(repository.upsertMessageAndWait("peer-alice", setOf("peer-alice"), "alice", message, false)) + assertEquals(PrivateMediaReceiptState.ACCEPTED, repository.privateMediaReceiptState(id)) + payload.delete() + assertEquals(PrivateMediaReceiptState.UNAVAILABLE, repository.privateMediaReceiptState(id)) + repository.deleteMessage(id) + repository.awaitPendingWrites() + assertEquals(PrivateMediaReceiptState.TOMBSTONED, repository.privateMediaReceiptState(id)) + repository.closeForTest() + repository = ConversationRepository(context, dispatcher, databaseName, cipher) + assertEquals(PrivateMediaReceiptState.TOMBSTONED, repository.privateMediaReceiptState(id)) + } + @Test fun `reload restores persisted history after initial process restore`() { repository = ConversationRepository( diff --git a/app/src/test/kotlin/com/bitchat/android/services/PrivateMediaOutboxStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/PrivateMediaOutboxStoreTest.kt new file mode 100644 index 00000000..c91ce0e7 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/PrivateMediaOutboxStoreTest.kt @@ -0,0 +1,40 @@ +package com.bitchat.android.services + +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class PrivateMediaOutboxStoreTest { + private val id = "media-00112233445566778899aabbccddeeff" + + @Test + fun `restart retains original payload and attempt count and wipe removes it`() { + val context = RuntimeEnvironment.getApplication() + File(context.filesDir, "private-media-outbox").deleteRecursively() + val cipher = InMemoryConversationStorageCipher() + val store = PrivateMediaOutboxStore(context, cipher) + val entry = PrivateMediaOutboxStore.Entry(id, "conversation", "0011223344556677", + "synthetic-payload", 100, attempts = 4, lastAttemptAt = 500) + store.save(entry) + assertEquals(listOf(entry), PrivateMediaOutboxStore(context, cipher).load()) + assertFalse(File(context.filesDir, "private-media-outbox/$id").readText().contains("synthetic-payload")) + store.wipe() + assertTrue(PrivateMediaOutboxStore(context, cipher).load().isEmpty()) + } + + @Test + fun `corrupt record is never retried or silently overwritten on load`() { + val context = RuntimeEnvironment.getApplication() + val directory = File(context.filesDir, "private-media-outbox").apply { mkdirs() } + val file = File(directory, id).apply { writeText("corrupt") } + assertTrue(PrivateMediaOutboxStore(context, InMemoryConversationStorageCipher()).load().isEmpty()) + assertTrue(file.exists()) + file.delete() + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/bridge/GatewayEventPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/services/bridge/GatewayEventPolicyTest.kt new file mode 100644 index 00000000..c1d903bd --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/bridge/GatewayEventPolicyTest.kt @@ -0,0 +1,25 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.model.NostrCarrierPacket +import com.bitchat.android.nostr.NostrEvent +import com.bitchat.android.nostr.NostrKind +import org.junit.Assert.* +import org.junit.Test + +class GatewayEventPolicyTest { + private val now = 1_800_000_000L + private fun carrier(age: Long = 0, kind: Int = NostrKind.EPHEMERAL_EVENT, cell: String = "s000") = + NostrCarrierPacket.fromEvent(NostrCarrierPacket.Direction.TO_GATEWAY, "s000", + NostrEvent(pubkey = "00".repeat(32), createdAt = (now - age).toInt(), kind = kind, + tags = listOf(listOf("g", cell)), content = "synthetic gateway event"))!! + + @Test fun `accepts exact timestamp boundaries for signature verification`() { + listOf(-900L, 0L, 900L).forEach { assertNotNull(GatewayEventPolicy.inspect(carrier(it), now)) } + } + @Test fun `rejects stale future wrong kind and mismatched channel before signature work`() { + assertNull(GatewayEventPolicy.inspect(carrier(901), now)) + assertNull(GatewayEventPolicy.inspect(carrier(-901), now)) + assertNull(GatewayEventPolicy.inspect(carrier(kind = NostrKind.TEXT_NOTE), now)) + assertNull(GatewayEventPolicy.inspect(carrier(cell = "s001"), now)) + } +} diff --git a/docs/ios-feature-parity-review.md b/docs/ios-feature-parity-review.md new file mode 100644 index 00000000..002c76f7 --- /dev/null +++ b/docs/ios-feature-parity-review.md @@ -0,0 +1,123 @@ +# iOS feature parity review + +Reviewed against iOS commit `9b84b361225facd8e623f25d76f889d3dc54a879` and Android main +`936a4cdf6d91a944698f7b46528962aef09c1f9c`. The merged-PR window is August 9 through +September 8, 2026 (UTC). This is a source and automated-test assessment; it is not +an assertion of physical Android/iOS interoperability. + +## Recent iOS changes that matter + +| Merged PR | Android finding and action | +| --- | --- | +| [#1647 β€” timestamp sanity](https://github.com/permissionlesstech/bitchat/pull/1647) | Bound verification QR timestamps in both directions without integer overflow; reject implausible Nostr DM rumor timestamps separately from randomized outer envelopes. Validate kind, signature, recipient and rumor/seal consistency on the common decrypt path. | +| [#1598 β€” reachable recent chats](https://github.com/permissionlesstech/bitchat/pull/1598) | Android main already persists and exposes recent private conversations. Preserve that implementation; integrate new outboxes and groups with it. | +| [#1597 β€” connectivity truthfulness](https://github.com/permissionlesstech/bitchat/pull/1597) | Add a persistent Bluetooth/Tor availability banner. Failed handshakes display an open lock; a closed lock requires an established session. | +| [#1595 β€” honest privacy claims](https://github.com/permissionlesstech/bitchat/pull/1595) | Default notification text previews off, make enabling them explicit, and describe panic wipe as local deletion with visible outcomes. | +| [#1588 β€” confirmable panic wipe](https://github.com/permissionlesstech/bitchat/pull/1588) | Replace immediate triple-tap erasure with confirmation, progress, success, and retryable failure. Suspend private writes and background forwarding during erasure; surface failures from persistent stores. | +| [#1596 β€” remove screenshot broadcast](https://github.com/permissionlesstech/bitchat/pull/1596) | No new screenshot reporting is added. This iOS removal is a privacy correction, not a feature to port. | +| [#1657](https://github.com/permissionlesstech/bitchat/pull/1657), [#1656](https://github.com/permissionlesstech/bitchat/pull/1656), [#1654](https://github.com/permissionlesstech/bitchat/pull/1654) β€” localization | Android has its own resource/localization system. New controls use resources; full translation coverage for the newly integrated group/board/settings surface remains follow-up work. | +| [#1653](https://github.com/permissionlesstech/bitchat/pull/1653), [#1651](https://github.com/permissionlesstech/bitchat/pull/1651) β€” deterministic tests | Keep coroutine/queue tests deterministic and exercise observable admission and revocation behavior. Swift-specific timing changes do not require an Android port. | +| [#1648 β€” blocking dead-code scan](https://github.com/permissionlesstech/bitchat/pull/1648) | Preserve Android lint and client rewrite contract gates; Periphery itself is Swift-specific. | + +The immediately preceding [#1645](https://github.com/permissionlesstech/bitchat/pull/1645) +(fail-closed handshake identity and secure randomness) and +[#1646](https://github.com/permissionlesstech/bitchat/pull/1646) (blocking SwiftLint) +were also inspected as context, but merged August 8 and are outside this window. +Android already has authenticated Noise peer binding and secure-random primitives; +new courier/group paths must preserve those properties. + +## Feature matrix and implementation + +Paths in the iOS column are relative to the iOS repository. Android paths are +relative to this repository. Features below also include older iOS functionality +still absent from Android main; they are not all attributed to the recent PRs. + +| Capability | iOS evidence | Android main | This implementation | +| --- | --- | --- | --- | +| Durable private text retries, offline courier | `bitchat/Services/Courier/MessageOutboxStore.swift`, `bitchat/Services/Gateway/BridgeCourierService.swift` | No equivalent durable outbound courier pipeline | Keystore-encrypted text outbox, retry/expiry and authenticated acknowledgements; direct mesh deposits and opt-in relay courier. | +| Durable private media receipts | `bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift`, `BLEPrivateMediaSessionStore.swift` | Encrypted private media and PTT already exist; no stable receipt/retry lifecycle | iOS-compatible stable IDs, encrypted bounded media outbox, durable receiver admission, duplicate/tombstone ACKs, matching-recipient retries, and single-row finalized PTT notes. | +| One-time prekeys | `bitchat/Services/Prekeys/LocalPrekeyStore.swift`, `PrekeyBundleStore.swift` | Not available | Signed bundle validation, persist-before-use assignment/consumption, refresh and typed synchronization. | +| Private groups | `bitchat/Services/Groups/GroupProtocol.swift`, `GroupStore.swift`, `bitchat/ViewModels/ChatGroupCoordinator.swift` | Not available | Creator-signed membership/key epochs, encrypted messages, 16-member bound, replay checks, group commands/list, durable history and process-lifetime handling. | +| Signed notices and mesh board synchronization | `bitchat/Protocols/BoardPackets.swift`, `bitchat/Services/Board/BoardManager.swift` | Location notes already exist, signed mesh board absent | Signed creation/deletion, TTL, scoped synchronization, storage and unified notices UI. Preserve existing location privacy gates. | +| Vouch attestations | `bitchat/Protocols/VouchAttestation.swift`, `bitchat/ViewModels/ChatVouchCoordinator.swift` | Direct verification only | Signed bounded-age attestations and persisted derived trust; a vouch remains distinct from direct verification. | +| Mesh-to-mesh relay bridge | `bitchat/Services/Gateway/BridgeService.swift` | Not available | Explicit opt-in forwarding with event validation, bounded dedup/rate limits, and revocable queued publication permissions. | +| Nearby internet gateway | `bitchat/Services/Gateway/GatewayService.swift` | Not available | Separate default-off gateway toggle; signed geohash events use carrier directions 1/2, authenticated capability discovery, timestamp checks, bounded forwarding and relay-echo suppression. | +| Custom relays | `bitchat/Nostr/NostrRelaySettings.swift`, `NostrRelayURL.swift` | Built-in relay selection | Persistent custom secure WebSocket relays, URL validation, subscription updates, removal and panic cleanup. | +| Incoming text/URL sharing | `bitchat/Services/SharedContentHandoff.swift`, `bitchat/App/SharedContentImportModel.swift` | No incoming text share target | Android text share target stages a bounded draft for user review; never auto-sends. | +| Channel invitations | `bitchat/Services/ChannelShare.swift` | No invitation import/export | Explicit `bitchat://geohash/…` links and share action; import selects a manual channel without requesting device location. | +| Notification privacy | `bitchat/Services/NotificationPrivacySettings.swift` | Sender/text previews exposed by default | Generic notification content by default and explicit preview control; turning previews off clears existing notification/shortcut surfaces. | +| Ping/route diagnostics | iOS mesh diagnostic packet/command paths | No matching commands | Versioned ping/pong payloads, bounded TTL/rate handling, `/ping`, `/trace` and mesh topology display. | + +Android main already supports Noise encryption, Nostr/Tor location chat, QR +verification, private image/audio transfer, live PTT, Cashu handling, location +notes and retained private conversations. These are integration constraints, +not missing features. Peer-ID rotation is not included: an iOS primitive alone +does not establish an end-to-end feature to advertise. + +## Implementation strategy + +1. Reuse and integrate the existing Android work for courier delivery, groups, + signed boards, vouching, bridging and diagnostics instead of introducing a + competing router or conversation database. The integration builds on PRs + [#877](https://github.com/permissionlesstech/bitchat-android/pull/877), + [#769](https://github.com/permissionlesstech/bitchat-android/pull/769), + [#768](https://github.com/permissionlesstech/bitchat-android/pull/768), + [#778](https://github.com/permissionlesstech/bitchat-android/pull/778) and + [#770](https://github.com/permissionlesstech/bitchat-android/pull/770), with + verification hardening informed by #910 and #927. +2. Treat durable receipt semantics, authenticated capabilities, timestamp checks, + panic admission gates and publication revocation as prerequisites for safe + feature exposure. A relay accepting an event is not a recipient delivery ACK. +3. Own group and retry work at application/process lifetime. Activity attachment + supplies navigation only; it must not decide whether an encrypted message is + persisted or acknowledged. +4. Keep phone-only capability bits disabled on Wear until their runtimes are + present. Shared packet parsing/sync comes from `app/` through the Wear source + include list. Private-media encryption remains independent of receipt support. +5. Validate wire vectors and storage/race behavior locally, then run physical + Mesh Lab and iOS interop before treating parity as release-ready. + +## Wire and persistence contract + +- Existing wire values are retained. Group invite/update use Noise types 6/7; + group messages use packet `0x25`; diagnostics use `0x26`/`0x27`. +- Capability flags are advertised only when the owning runtime is present: + prekeys bit 0, gateway bit 2, groups bit 3, boards bit 4, vouch bit 5, + diagnostics bit 6, bridge bit 7, private media bit 8 and media receipts bit 9. + Gateway and bridge bits follow their independent opt-in settings. +- Typed gossip includes signed boards, prekeys and groups, retaining compatibility + with legacy sync masks. See [sync.md](sync.md). +- Stable media message IDs hash the iOS domain and length-prefixed sender, + recipient and supported filename. `PrivateMediaMessageIdentityTest` contains + an exact iOS-compatible vector. Original encoded payloads survive retries. +- Private-media outbox limits: 100 records, 8 MiB per record, 64 MiB total, + eight attempts and 24-hour lifetime. Only authenticated live receipt support + enables retry. Missing/corrupt storage does not produce a delivery ACK. +- A deleted received message remains acknowledged through its durable tombstone; + replay must not recreate it. Encrypted media also requires a readable saved file. +- Bridge/gateway publication permissions carry a generation. Turning a feature + off invalidates both disconnected-queue entries and already-selected writes; + re-enabling cannot revive old permission. +- Channel invitation tests use synthetic cells only. No implementation/test + workflow requires reading real device location. + +## Release-readiness and remaining work + +Local validation results and visual evidence are reported in the pull request. +Physical Mesh Lab and Android-to-iOS interoperability are required separately: + +- Offline text/media delivery, process death and restart, duplicate receipt after + deletion, blocked/expired/corrupt payloads and panic during retry. +- BLE and Wi-Fi Aware delivery with the Activity closed; multi-hop courier with + the original sender unavailable; phone-to-watch capability fallback. +- Group invite/update/removal, stale epochs, restart and background notification. +- Board signature/delete/sync and bridge/gateway disable/re-enable while relays + disconnect or delayed writes are queued. +- One-time-prekey races and consumption persistence, plus iOS vectors on both + real clients. Static screenshots cannot establish any of these properties. + +Full locale coverage, accessibility review and the physical matrix remain +release work. Manual-channel Nostr board publication still obeys the existing +location-note privacy gate; this integration does not weaken it to force a +successful publish. No claim is made that this large integration is ready to +merge without the hardware and cross-client review above.