Complete durable delivery and client privacy parity

This commit is contained in:
callebtc 2026-09-08 00:31:48 +03:00
parent 71092a5e17
commit 43d9d85797
58 changed files with 1575 additions and 508 deletions

View File

@ -126,6 +126,12 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="bitchat" android:host="verify" />
<data android:scheme="bitchat" android:host="geohash" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>

View File

@ -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) { }

View File

@ -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

View File

@ -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()
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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 <name>")
@ -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 <nickname>[#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 <nickname>[#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

View File

@ -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<GroupUiDelegate>(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 }
}
}
}

View File

@ -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
*/

View File

@ -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) }
}
/**

View File

@ -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
}

View File

@ -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,

View File

@ -38,6 +38,7 @@ internal class MeshPingManager(
private val inboundByLink = ConcurrentHashMap<String, ArrayDeque<Long>>()
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

View File

@ -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) {}

View File

@ -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)
}
/**

View File

@ -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() {

View File

@ -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)
)
/**

View File

@ -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) }
}
}

View File

@ -76,7 +76,8 @@ private class NostrBridgeCourierRelay(
event: NostrEvent,
relayUrls: List<String>,
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(

View File

@ -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()
}

View File

@ -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)

View File

@ -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<String>,
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<String>,
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<Delivery> = 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()
}

View File

@ -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<String> = DEFAULT_RELAYS
fun defaultRelays(): List<String> = (DEFAULT_RELAYS + shared.customRelays.value).distinct()
}
/**
@ -79,6 +80,67 @@ class NostrRelayManager private constructor() {
var nextReconnectTime: Long? = null
)
private val _customRelays = MutableStateFlow<List<String>>(emptyList())
val customRelays: StateFlow<List<String>> = _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<List<Relay>>(emptyList())
val relays: StateFlow<List<Relay>> = _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<String>? = 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)

View File

@ -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,

View File

@ -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" }
}

View File

@ -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<String>,
@ -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<String> {
readableDatabase.query(
"deleted_private_messages",

View File

@ -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<Entry> {
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<String, PrivateMediaOutboxStore.Entry>? = null
@Volatile private var paused = false
init {
scope.launch {
while (isActive) {
delay(15_000)
runCatching { retryPending() }
}
}
}
private fun loaded(): MutableMap<String, PrivateMediaOutboxStore.Entry> =
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
}
}

View File

@ -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<BitchatMessage>,
val accepted: Boolean
)
private val seenKeys = mutableSetOf<String>()
fun reconcile(
existing: List<BitchatMessage>,
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")
}
}

View File

@ -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<String>()
@ -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<String, Long> = 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<String, Long> {
val type = object : TypeToken<Map<String, Long>>() {}.type
val decoded: Map<String, Long> = runCatching {
preferences.getString(key, null)
?.let { json -> gson.fromJson<Map<String, Long>>(json, type) }
}.getOrNull() ?: emptyMap()
return LinkedHashMap(decoded)
}
private fun persist() {
preferences.edit { putString(key, gson.toJson(values)) }
}
}

View File

@ -17,21 +17,6 @@ data class BridgeUiState(
val participants: List<BridgedParticipant> = 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,

View File

@ -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) }
}

View File

@ -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<Boolean> = _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<Long>()
private val perPeer = linkedMapOf<String, ArrayDeque<Long>>()
private val downlinkTimes = ArrayDeque<Long>()
private val pending = linkedMapOf<String, Pair<NostrCarrierPacket, Long?>>()
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
}
}

View File

@ -448,6 +448,8 @@ fun AboutSheet(
}
}
item(key = "client_privacy") { ClientSettingsSection() }
item(key = "language") {
val selectedLanguageName = supportedLanguages
.firstOrNull { it.languageTag == selectedLanguageTag }

View File

@ -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
}

View File

@ -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()

View File

@ -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<List<BitchatGroup>> = 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<String> =
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<String>, 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<String>): GroupCommandResult {
private fun handleGroupCommand(arguments: List<String>): 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<String?>(null)
val sharedDraft: StateFlow<String?> = _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> = _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)
*/

View File

@ -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)
}
}

View File

@ -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)) }
}
}

View File

@ -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) {

View File

@ -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)
}
}
}

View File

@ -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,

View File

@ -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(

View File

@ -50,6 +50,7 @@ class MediaSendingManager(
// Track in-flight transfer progress: transferId -> messageId and reverse
private val transferMessageMap = mutableMapOf<String, String>()
private val messageTransferMap = mutableMapOf<String, String>()
private val privateTransferMessageIDs = mutableSetOf<String>()
private val pendingConsentLock = Any()
private val _legacyPrivateMediaConsent = MutableStateFlow<LegacyPrivateMediaConsentRequest?>(null)
val legacyPrivateMediaConsent: StateFlow<LegacyPrivateMediaConsentRequest?> =
@ -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(

View File

@ -68,6 +68,12 @@ class NotificationManager(
private val liveManagers: MutableSet<NotificationManager> =
Collections.newSetFromMap(WeakHashMap<NotificationManager, Boolean>())
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()
)

View File

@ -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)) }
}
)
}

View File

@ -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,

View File

@ -694,4 +694,29 @@
<string name="about_app_language">App language</string>
<string name="about_system_default">System default</string>
<string name="about_select_language">Select language</string>
<string name="notification_hidden_message" tools:ignore="MissingTranslation">New message</string>
<string name="notification_previews" tools:ignore="MissingTranslation">Notification previews</string>
<string name="notification_previews_description" tools:ignore="MissingTranslation">Show sender names and message text in notifications. Hidden by default.</string>
<string name="custom_relays" tools:ignore="MissingTranslation">Custom relays</string>
<string name="custom_relays_description" tools:ignore="MissingTranslation">Add relays for your direct messages. Built-in relays stay available.</string>
<string name="relay_url" tools:ignore="MissingTranslation">Relay URL</string>
<string name="relay_add" tools:ignore="MissingTranslation">Add relay</string>
<string name="relay_remove" tools:ignore="MissingTranslation">Remove</string>
<string name="relay_invalid" tools:ignore="MissingTranslation">Enter a valid wss URL without credentials or query parameters. Up to 20 custom relays are supported.</string>
<string name="share_channel" tools:ignore="MissingTranslation">Share channel invitation</string>
<string name="channel_invitation" tools:ignore="MissingTranslation">Join #%1$s on bitchat: %2$s — get the app at https://bitchat.free</string>
<string name="bluetooth_unavailable_banner" tools:ignore="MissingTranslation">Bluetooth is unavailable. Nearby Bluetooth messaging is paused.</string>
<string name="tor_failed_banner" tools:ignore="MissingTranslation">Tor could not connect. Internet messages are paused.</string>
<string name="tor_connecting_banner" tools:ignore="MissingTranslation">Connecting to Tor (%1$d%%). Internet messages are waiting.</string>
<string name="panic_confirm_title" tools:ignore="MissingTranslation">Erase local bitchat data?</string>
<string name="panic_confirm_body" tools:ignore="MissingTranslation">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.</string>
<string name="panic_erasing_title" tools:ignore="MissingTranslation">Erasing local data…</string>
<string name="panic_complete_title" tools:ignore="MissingTranslation">Local data erased</string>
<string name="panic_complete_body" tools:ignore="MissingTranslation">A new identity is ready. Copies on other devices and relays were not erased.</string>
<string name="panic_failed_title" tools:ignore="MissingTranslation">Erase incomplete</string>
<string name="panic_failed_body" tools:ignore="MissingTranslation">Some local data could not be erased. Messaging remains paused. Retry to finish.</string>
<string name="panic_erase" tools:ignore="MissingTranslation">Erase data</string>
<string name="panic_retry" tools:ignore="MissingTranslation">Retry erase</string>
<string name="gateway_title" tools:ignore="MissingTranslation">Share internet with the mesh</string>
<string name="gateway_description" tools:ignore="MissingTranslation">Carry signed public channel messages between nearby peers and relays. Off by default.</string>
</resources>

View File

@ -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`() {

View File

@ -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")
)

View File

@ -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!!

View File

@ -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))
}
}
}

View File

@ -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))
}
}
}

View File

@ -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<NostrPendingEventQueue.Delivery>(), queue.pendingForRelay("relay"))
assertEquals(0, queue.size())
}
private fun event(content: String): NostrEvent {
val privateKey = "0".repeat(63) + "1"
return NostrEvent(

View File

@ -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)) }
}
}

View File

@ -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(

View File

@ -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()
}
}

View File

@ -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))
}
}

View File

@ -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.