diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1f9a8c49..9599ecf8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -126,6 +126,12 @@
+
+
+
+
+
+
diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt
index 785dcf9b..2493e3a3 100644
--- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt
+++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt
@@ -82,6 +82,11 @@ class BitchatApplication : Application() {
)
} catch (_: Exception) { }
+ com.bitchat.android.services.bridge.MeshGatewayService.initialize(this)
+ com.bitchat.android.groups.GroupRuntime.getInstance(this)
+ com.bitchat.android.services.PrivateMediaOutbox.initialize(this)
+ com.bitchat.android.model.PeerCapabilities.setPhoneFeaturesEnabled(true)
+
// Proactively start the foreground service to keep mesh alive
try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { }
diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt
index 6f80052a..d73d9341 100644
--- a/app/src/main/java/com/bitchat/android/MainActivity.kt
+++ b/app/src/main/java/com/bitchat/android/MainActivity.kt
@@ -705,6 +705,7 @@ class MainActivity : OrientationAwareActivity() {
// Handle any notification intent
handleNotificationIntent(intent)
handleVerificationIntent(intent)
+ handleShareIntent(intent)
// Small delay to ensure mesh service is fully initialized
delay(500)
@@ -734,6 +735,7 @@ class MainActivity : OrientationAwareActivity() {
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
handleNotificationIntent(intent)
handleVerificationIntent(intent)
+ handleShareIntent(intent)
}
}
@@ -848,6 +850,21 @@ class MainActivity : OrientationAwareActivity() {
}
}
+ private fun handleShareIntent(intent: Intent) {
+ if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/") == true) {
+ intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.takeIf { it.isNotBlank() }?.let {
+ chatViewModel.receiveSharedText(it)
+ intent.removeExtra(Intent.EXTRA_TEXT)
+ }
+ }
+ if (intent.action == Intent.ACTION_VIEW) {
+ val cell = intent.data?.toString()?.let(com.bitchat.android.services.ChannelInvitation::decode) ?: return
+ val channel = com.bitchat.android.ui.channelForManualGeohash(cell) ?: return
+ LocationChannelManager.getInstance(applicationContext).selectManual(channel)
+ intent.data = null
+ }
+ }
+
private fun handleVerificationIntent(intent: Intent) {
val uri = intent.data ?: return
if (uri.scheme != "bitchat" || uri.host != "verify") return
diff --git a/app/src/main/java/com/bitchat/android/board/BoardStore.kt b/app/src/main/java/com/bitchat/android/board/BoardStore.kt
index 97aa95d1..19bfc1a5 100644
--- a/app/src/main/java/com/bitchat/android/board/BoardStore.kt
+++ b/app/src/main/java/com/bitchat/android/board/BoardStore.kt
@@ -143,7 +143,7 @@ class BoardStore(
fun wipe() = synchronized(lock) {
posts.clear()
tombstones.clear()
- file?.let { runCatching { if (it.exists()) it.delete() } }
+ file?.let { check(!it.exists() || it.delete()) }
publishSnapshotLocked()
}
diff --git a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt
index da6898e8..2d8c04ea 100644
--- a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt
+++ b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt
@@ -280,7 +280,7 @@ object FileUtils {
* Recursively delete all media files (incoming and outgoing)
* Used for Panic Mode cleanup
*/
- fun clearAllMedia(context: Context) {
+ fun clearAllMedia(context: Context): Boolean {
try {
// Clear files dir subdirectories (legacy storage and outgoing)
val filesDir = context.filesDir
@@ -295,7 +295,7 @@ object FileUtils {
dirsToClear.forEach { subDir ->
val dir = File(filesDir, subDir)
if (dir.exists()) {
- dir.deleteRecursively()
+ check(dir.deleteRecursively())
Log.d(TAG, "Deleted media directory from filesDir: $subDir")
}
}
@@ -312,17 +312,19 @@ object FileUtils {
cacheDirsToClear.forEach { subDir ->
val dir = File(cacheDir, subDir)
if (dir.exists()) {
- dir.deleteRecursively()
+ check(dir.deleteRecursively())
Log.d(TAG, "Deleted media directory from cacheDir: $subDir")
}
}
// Also clear entire cache dir as a catch-all
- context.cacheDir.deleteRecursively()
+ check(!context.cacheDir.exists() || context.cacheDir.deleteRecursively())
Log.d(TAG, "Cleared entire cache directory")
+ return true
} catch (e: Exception) {
Log.e(TAG, "Failed to clear media files", e)
+ return false
}
}
diff --git a/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt b/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt
index 05223b48..ce6bc273 100644
--- a/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt
+++ b/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt
@@ -252,14 +252,22 @@ class LiveVoiceManager private constructor(private val context: Context) {
} ?: return false
val finished = entry.value
val replacement = message.copy(
- id = finished.messageID,
+ id = if (message.isPrivate && com.bitchat.android.model.PrivateMediaMessageIdentity.isStableID(message.id)) message.id else finished.messageID,
timestamp = finished.timestamp,
sender = finished.nickname,
senderPeerID = peerID,
isPrivate = messageScope == LiveVoiceScope.DIRECT_MESSAGE
)
if (messageScope == LiveVoiceScope.DIRECT_MESSAGE) {
- AppStateStore.upsertPrivateMessage(peerID, replacement, isVisible(messageScope, peerID))
+ if (replacement.id != finished.messageID) {
+ val saved = kotlinx.coroutines.runBlocking {
+ AppStateStore.addPrivateMessageDurably(peerID, replacement, isVisible(messageScope, peerID))
+ }
+ if (!saved) return false
+ AppStateStore.removePrivateMessage(finished.messageID)
+ } else {
+ AppStateStore.upsertPrivateMessage(peerID, replacement, isVisible(messageScope, peerID))
+ }
} else {
AppStateStore.upsertPublicMessage(replacement)
}
diff --git a/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt b/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt
index 7e9fadaa..c914cf82 100644
--- a/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt
+++ b/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt
@@ -112,7 +112,9 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
@Volatile
private var inboundGeneration = 0L
+ @Synchronized
fun createGroup(rawName: String): GroupCommandResult {
+ if (!acceptsInboundEvents) return error("private groups are paused")
if (!context.groupStore.isReady) return loadingError()
val name = rawName.trim()
if (name.isEmpty()) return error("usage: /group create ")
@@ -131,7 +133,9 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
return success("created private group #${group.name}")
}
+ @Synchronized
fun inviteMember(rawNickname: String): GroupCommandResult {
+ if (!acceptsInboundEvents) return error("private groups are paused")
if (!context.groupStore.isReady) return loadingError()
val selector = parseMemberSelector(rawNickname)
?: return error("usage: /group invite [#identity-suffix]")
@@ -171,7 +175,9 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
return success("invited $nickname to #${updated.name}")
}
+ @Synchronized
fun removeMember(rawNickname: String): GroupCommandResult {
+ if (!acceptsInboundEvents) return error("private groups are paused")
if (!context.groupStore.isReady) return loadingError()
val selector = parseMemberSelector(rawNickname)
?: return error("usage: /group remove [#identity-suffix]")
@@ -205,7 +211,9 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
return success("removed ${member.nickname} and rotated the group key")
}
+ @Synchronized
fun leaveGroup(): GroupCommandResult {
+ if (!acceptsInboundEvents) return error("private groups are paused")
if (!context.groupStore.isReady) return loadingError()
val group = selectedGroup() ?: return error("open a private group first")
if (isCreator(group) && group.members.size > 1) {
@@ -225,6 +233,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
return success("left #${group.name}")
}
+ @Synchronized
fun listGroups(): GroupCommandResult {
if (!context.groupStore.isReady) return loadingError()
val groups = context.groupStore.groups.value
@@ -237,26 +246,28 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
return success("private groups:\n$lines")
}
- fun sendMessage(content: String, groupPeerID: String) {
+ @Synchronized
+ fun sendMessage(content: String, groupPeerID: String): Boolean {
+ if (!acceptsInboundEvents) return false
if (!context.groupStore.isReady) {
context.addGroupSystemMessage(groupPeerID, "private groups are still loading")
- return
+ return false
}
if (content.isEmpty() ||
content.codePointCount(0, content.length) > MAX_MESSAGE_LENGTH
) {
- return
+ return false
}
val group = context.groupStore.group(groupPeerID)
val key = group?.let { context.groupStore.key(it.groupID) }
if (group == null || key == null) {
context.addGroupSystemMessage(groupPeerID, "this private group is unavailable")
- return
+ return false
}
val signingKey = context.mySigningPublicKey()
if (signingKey?.size != 32) {
context.addGroupSystemMessage(groupPeerID, "your signing identity is unavailable")
- return
+ return false
}
val messageID = UUID.randomUUID().toString()
@@ -275,10 +286,10 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
)
} catch (_: Exception) {
context.addGroupSystemMessage(groupPeerID, "could not encrypt group message")
- return
+ return false
}
- context.appendGroupMessage(
+ val stored = context.appendGroupMessage(
groupPeerID,
BitchatMessage(
id = messageID,
@@ -291,9 +302,12 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
deliveryStatus = DeliveryStatus.Sent
)
)
+ if (!stored) return false
context.broadcastGroupMessage(payload)
+ return true
}
+ @Synchronized
fun handleMessage(payload: ByteArray, receivedAtMs: Long) {
val generation = inboundGeneration
if (!acceptsInboundEvents) return
@@ -346,6 +360,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
}
}
+ @Synchronized
fun handleInvite(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
@@ -370,6 +385,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
}
}
+ @Synchronized
fun handleKeyUpdate(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
@@ -399,6 +415,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
* reconnects. This uses the existing iOS GROUP_KEY_UPDATE payload and
* repairs updates that could not be delivered while the member was offline.
*/
+ @Synchronized
fun handlePeerAuthenticated(peerID: String) {
val generation = inboundGeneration
if (!acceptsInboundEvents) return
@@ -425,6 +442,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
/**
* Drains packets received during asynchronous store initialization.
*/
+ @Synchronized
fun onStoreReady() {
val generation = inboundGeneration
if (!acceptsInboundEvents) return
@@ -460,6 +478,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
}
}
+ @Synchronized
fun suspendForPanic() {
synchronized(lifecycleLock) {
acceptsInboundEvents = false
@@ -471,6 +490,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
}
}
+ @Synchronized
fun resumeAfterPanic() {
synchronized(lifecycleLock) {
acceptsInboundEvents = true
diff --git a/app/src/main/java/com/bitchat/android/groups/GroupRuntime.kt b/app/src/main/java/com/bitchat/android/groups/GroupRuntime.kt
new file mode 100644
index 00000000..d0622531
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/groups/GroupRuntime.kt
@@ -0,0 +1,96 @@
+package com.bitchat.android.groups
+
+import android.content.Context
+import androidx.core.app.NotificationManagerCompat
+import com.bitchat.android.mesh.GroupMessagePort
+import com.bitchat.android.mesh.GroupMessageReceiver
+import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.service.MeshServiceHolder
+import com.bitchat.android.services.AppStateStore
+import com.bitchat.android.services.ContactIdentityResolver
+import com.bitchat.android.ui.DataManager
+import java.lang.ref.WeakReference
+import java.util.Date
+import kotlinx.coroutines.*
+
+interface GroupUiDelegate {
+ val nickname: String
+ fun markGroupUnread(groupPeerID: String)
+ fun openGroupConversation(groupPeerID: String)
+ fun closeGroupConversation()
+}
+
+/** Keeps group membership, decryption, and durable delivery alive when the Activity closes. */
+class GroupRuntime private constructor(private val application: Context) : GroupMessageReceiver {
+ val store = GroupStore(application)
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ @Volatile private var ui = WeakReference(null)
+ private val mesh get() = MeshServiceHolder.unifiedMeshService
+ private val notifications = com.bitchat.android.ui.NotificationManager(application, NotificationManagerCompat.from(application))
+
+ val coordinator = GroupCoordinator(object : GroupCoordinatorContext {
+ override val groupStore get() = store
+ override val nickname get() = ui.get()?.nickname ?: AppStateStore.nickname.value
+ override val myPeerID get() = mesh?.myPeerID.orEmpty()
+ override val selectedConversationID get() = AppStateStore.selectedPrivateChatPeer.value
+ override fun myNoiseFingerprint() = mesh?.getIdentityFingerprint().orEmpty()
+ override fun mySigningPublicKey() = mesh?.getSigningPublicKey()
+ override fun sign(data: ByteArray) = mesh?.signData(data)
+ override fun peerIDsForNickname(nickname: String) = mesh?.getPeerNicknames().orEmpty()
+ .filterValues { it.equals(nickname, ignoreCase = true) }.keys.toList()
+ override fun isPeerConnected(peerID: String) = mesh?.getPeerInfo(peerID)?.isConnected == true && mesh?.hasEstablishedSession(peerID) == true
+ override fun peerGroupCapability(peerID: String): PeerGroupCapability {
+ val peer = mesh?.getPeerInfo(peerID) ?: return PeerGroupCapability.UNKNOWN
+ return PeerGroupCapability.fromPeerState(peer.capabilities, peer.hasVerifiedAnnouncement)
+ }
+ override fun peerNickname(peerID: String) = mesh?.getPeerNicknames()?.get(peerID)
+ override fun peerIdentity(peerID: String): GroupPeerIdentity? {
+ val info = mesh?.getPeerInfo(peerID) ?: return null
+ val signing = info.signingPublicKey?.takeIf { it.size == 32 } ?: return null
+ val key = info.noisePublicKey ?: return null
+ val fingerprint = ContactIdentityResolver.fingerprintHex(key)
+ if (!fingerprint.equals(mesh?.getPeerFingerprint(peerID), ignoreCase = true)) return null
+ return GroupPeerIdentity(fingerprint, signing.copyOf())
+ }
+ override fun connectedPeerID(fingerprint: String) = mesh?.getPeerNicknames()?.keys?.firstOrNull {
+ isPeerConnected(it) && fingerprint.equals(mesh?.getPeerFingerprint(it), ignoreCase = true)
+ }
+ override fun isFingerprintBlocked(fingerprint: String) = DataManager.isFingerprintBlocked(application, fingerprint)
+ override fun sendGroupInvite(payload: ByteArray, peerID: String) { mesh?.sendGroupInvite(payload, peerID) }
+ override fun sendGroupKeyUpdate(payload: ByteArray, peerID: String) { mesh?.sendGroupKeyUpdate(payload, peerID) }
+ override fun broadcastGroupMessage(payload: ByteArray) { mesh?.broadcastGroupMessage(payload) }
+ override fun appendGroupMessage(groupPeerID: String, message: BitchatMessage): Boolean = runBlocking {
+ AppStateStore.addPrivateMessageDurably(groupPeerID, message, selectedConversationID == groupPeerID || message.senderPeerID == myPeerID)
+ }
+ override fun markGroupUnread(groupPeerID: String) { ui.get()?.markGroupUnread(groupPeerID) }
+ override fun removeGroupConversation(groupPeerID: String) { AppStateStore.deletePrivateConversation(groupPeerID) }
+ override fun openGroupConversation(groupPeerID: String) { scope.launch(Dispatchers.Main) { ui.get()?.openGroupConversation(groupPeerID) } }
+ override fun closeGroupConversation() { scope.launch(Dispatchers.Main) { ui.get()?.closeGroupConversation() } }
+ override fun addSystemMessage(message: String) { AppStateStore.addPublicMessage(BitchatMessage(sender = "system", content = message, timestamp = Date())) }
+ override fun addGroupSystemMessage(groupPeerID: String, message: String) {
+ appendGroupMessage(groupPeerID, BitchatMessage(sender = "system", content = message, timestamp = Date(), isPrivate = true))
+ }
+ override fun notifyGroupMessage(groupPeerID: String, sender: String, message: String) {
+ notifications.showPrivateMessageNotification(groupPeerID, sender, message)
+ }
+ })
+
+ init {
+ GroupMessagePort.receiver = this
+ scope.launch { if (store.initialize()) coordinator.onStoreReady() }
+ }
+
+ fun attach(context: GroupUiDelegate) { ui = WeakReference(context) }
+ fun detach(context: GroupUiDelegate) { if (ui.get() === context) ui.clear() }
+ override fun invite(peerID: String, authenticatedKey: ByteArray, payload: ByteArray) = coordinator.handleInvite(peerID, authenticatedKey, payload)
+ override fun keyUpdate(peerID: String, authenticatedKey: ByteArray, payload: ByteArray) = coordinator.handleKeyUpdate(peerID, authenticatedKey, payload)
+ override fun message(payload: ByteArray, timestampMs: Long) = coordinator.handleMessage(payload, timestampMs)
+ override fun peerAuthenticated(peerID: String) { scope.launch { coordinator.handlePeerAuthenticated(peerID) } }
+
+ companion object {
+ @Volatile private var instance: GroupRuntime? = null
+ fun getInstance(context: Context): GroupRuntime = instance ?: synchronized(this) {
+ instance ?: GroupRuntime(context.applicationContext).also { instance = it }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt
index ab9e2ece..574fea68 100644
--- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt
+++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt
@@ -668,22 +668,18 @@ class SecureIdentityStateManager {
* Clear all identity data (for panic mode)
*/
@SuppressLint("UseKtx")
- fun clearIdentityData() {
- try {
- synchronized(identityPersistenceLock) {
- identityPersistenceEpoch += 1
- identityPersistenceEpochAtCreation = identityPersistenceEpoch
- if (!prefs.edit().clear().commit()) {
- Log.e(TAG, "Identity preference wipe could not be committed")
- }
- identityChanges.tryEmit(Unit)
- }
- Log.w(TAG, "All identity data cleared")
- } catch (e: Exception) {
- Log.e(TAG, "Failed to clear identity data: ${e.message}")
+ fun clearIdentityData(): Boolean = try {
+ synchronized(identityPersistenceLock) {
+ identityPersistenceEpoch += 1
+ identityPersistenceEpochAtCreation = identityPersistenceEpoch
+ check(prefs.edit().clear().commit()) { "Identity preference wipe could not be committed" }
+ identityChanges.tryEmit(Unit)
}
+ true
+ } catch (_: Exception) {
+ false
}
-
+
/**
* Check if identity data exists
*/
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
index 6c0e7c02..6b7f1557 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
@@ -80,7 +80,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
},
applyAuthenticatedState = peerManager::applyAuthenticatedPeerState,
sendState = ::sendAuthenticatedPeerState,
- onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) }
+ onResolution = { peerID -> GroupMessagePort.receiver?.peerAuthenticated(peerID)
+ delegate?.didResolvePrivateMediaPolicy(peerID) }
)
}
private val privateMediaSecurity by lazy { PrivateMediaSecurityController(
@@ -558,6 +559,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
override fun onDeliveryAckReceived(messageID: String, peerID: String) {
+ com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.acknowledge(messageID, peerID)
try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageID, peerID) } catch (_: Exception) { }
// Status events can arrive while MainActivity has detached the UI delegate.
// Persist first so the next UI collector observes the advancement.
@@ -571,6 +573,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
override fun onReadReceiptReceived(messageID: String, peerID: String) {
+ com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.acknowledge(messageID, peerID)
try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageID, peerID) } catch (_: Exception) { }
try {
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
@@ -594,7 +597,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
- delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload)
+ GroupMessagePort.receiver?.invite(peerID, authenticatedRemoteStaticKey, payload)
}
override fun onGroupKeyUpdateReceived(
@@ -602,11 +605,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
- delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload)
+ GroupMessagePort.receiver?.keyUpdate(peerID, authenticatedRemoteStaticKey, payload)
}
override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {
- delegate?.didReceiveGroupMessage(payload, timestampMs)
+ GroupMessagePort.receiver?.message(payload, timestampMs)
}
override fun onVouchPayloadReceived(peerID: String, payload: ByteArray) {
@@ -1137,6 +1140,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
/** Safe non-interactive entry point: encrypted sends commit; legacy sends require UI consent. */
+ fun supportsPrivateMediaReceipts(peerID: String): Boolean {
+ val session = encryptionService.getAuthenticatedSession(peerID) ?: return false
+ val proof = authenticatedPeerState.status(peerID, session) as? AuthenticatedPeerStateStatus.Proven ?: return false
+ return proof.state.capabilities.contains(com.bitchat.android.model.PeerCapabilities.PRIVATE_MEDIA_RECEIPTS)
+ }
+
fun sendFilePrivate(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) {
val payload = file.encode() ?: return
when (val prepared = prepareFilePrivate(
@@ -2094,11 +2103,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
bridgeCourierService::stop,
gossipSyncManager::clear
)
+ var failure: Exception? = null
operations.forEach { operation ->
- try { operation() } catch (e: Exception) {
- Log.e(TAG, "Error clearing mesh service internal data: ${e.message}")
- }
+ try { operation() } catch (error: Exception) { failure = error }
}
+ failure?.let { throw IllegalStateException("Mesh data wipe incomplete", it) }
}
/**
diff --git a/app/src/main/java/com/bitchat/android/mesh/GroupMessagePort.kt b/app/src/main/java/com/bitchat/android/mesh/GroupMessagePort.kt
new file mode 100644
index 00000000..2ce93a20
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/GroupMessagePort.kt
@@ -0,0 +1,13 @@
+package com.bitchat.android.mesh
+
+/** Process-level group ingress, independent of Activity and transport lifetimes. */
+interface GroupMessageReceiver {
+ fun invite(peerID: String, authenticatedKey: ByteArray, payload: ByteArray)
+ fun keyUpdate(peerID: String, authenticatedKey: ByteArray, payload: ByteArray)
+ fun message(payload: ByteArray, timestampMs: Long)
+ fun peerAuthenticated(peerID: String)
+}
+
+object GroupMessagePort {
+ @Volatile var receiver: GroupMessageReceiver? = null
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
index b0165814..d4a934ff 100644
--- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
@@ -50,6 +50,7 @@ class MeshCore(
* Return false to suppress all downstream effects for a rejected message.
*/
val onMessageReceived: ((BitchatMessage) -> Boolean)? = null,
+ val onDeliveryReceipt: ((String, String) -> Unit)? = null,
val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
val onReadReceiptSent: ((String) -> Unit)? = null,
@@ -76,7 +77,8 @@ class MeshCore(
},
applyAuthenticatedState = peerManager::applyAuthenticatedPeerState,
sendState = ::sendAuthenticatedPeerState,
- onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) }
+ onResolution = { peerID -> GroupMessagePort.receiver?.peerAuthenticated(peerID)
+ delegate?.didResolvePrivateMediaPolicy(peerID) }
)
}
private val privateMediaSecurity by lazy { PrivateMediaSecurityController(
@@ -437,6 +439,7 @@ class MeshCore(
}
override fun onDeliveryAckReceived(messageID: String, peerID: String) {
+ hooks.onDeliveryReceipt?.invoke(messageID, peerID)
try {
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
messageID,
@@ -447,6 +450,7 @@ class MeshCore(
}
override fun onReadReceiptReceived(messageID: String, peerID: String) {
+ hooks.onDeliveryReceipt?.invoke(messageID, peerID)
try {
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
messageID,
@@ -469,7 +473,7 @@ class MeshCore(
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
- delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload)
+ GroupMessagePort.receiver?.invite(peerID, authenticatedRemoteStaticKey, payload)
}
override fun onGroupKeyUpdateReceived(
@@ -477,11 +481,11 @@ class MeshCore(
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
- delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload)
+ GroupMessagePort.receiver?.keyUpdate(peerID, authenticatedRemoteStaticKey, payload)
}
override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {
- delegate?.didReceiveGroupMessage(payload, timestampMs)
+ GroupMessagePort.receiver?.message(payload, timestampMs)
}
override fun onVouchPayloadReceived(peerID: String, payload: ByteArray) {
@@ -851,6 +855,12 @@ class MeshCore(
}
}
+ fun supportsPrivateMediaReceipts(peerID: String): Boolean {
+ val session = encryptionService.getAuthenticatedSession(peerID) ?: return false
+ val proof = authenticatedPeerState.status(peerID, session) as? AuthenticatedPeerStateStatus.Proven ?: return false
+ return proof.state.capabilities.contains(com.bitchat.android.model.PeerCapabilities.PRIVATE_MEDIA_RECEIPTS)
+ }
+
fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt b/app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt
index 2773cb15..e9b216c7 100644
--- a/app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt
@@ -38,6 +38,7 @@ internal class MeshPingManager(
private val inboundByLink = ConcurrentHashMap>()
fun ping(peerID: String, callback: (MeshPingResult?) -> Unit) {
+ if (pending.size >= 64) { callback(null); return }
val payload = MeshPingPayload.create(MeshDiagnosticsConstants.TTL)
val key = pendingKey(payload)
val timeout = scope.launch {
@@ -73,7 +74,16 @@ internal class MeshPingManager(
private fun consumeInboundBudget(link: String): Boolean {
val now = System.currentTimeMillis()
- val timestamps = inboundByLink.computeIfAbsent(link) { ArrayDeque() }
+ synchronized(inboundByLink) {
+ inboundByLink.entries.removeAll { (_, times) ->
+ synchronized(times) {
+ times.lastOrNull()?.let { now - it >= MeshDiagnosticsConstants.INBOUND_RATE_WINDOW_MILLIS } != false
+ }
+ }
+ if (inboundByLink.size >= 256 && link !in inboundByLink) return false
+ inboundByLink.putIfAbsent(link, ArrayDeque())
+ }
+ val timestamps = inboundByLink[link] ?: return false
synchronized(timestamps) {
while (timestamps.firstOrNull()?.let {
now - it >= MeshDiagnosticsConstants.INBOUND_RATE_WINDOW_MILLIS
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt
index c2593a00..f7c1f32d 100644
--- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt
@@ -17,6 +17,8 @@ interface MeshService {
fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String)
fun sendPrekeyBundle(payload: ByteArray)
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null)
+ fun supportsPrivateMediaReceipts(peerID: String): Boolean = false
+
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String)
fun sendDeliveryAck(messageID: String, recipientPeerID: String) {}
fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
index 4c73e1ec..ba106282 100644
--- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
@@ -119,7 +119,8 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Notify delegate
delegate?.onMessageReceived(message)
- // Send delivery ACK exactly like iOS
+ // An ACK means durable admission, including a previously deleted duplicate.
+ if (!com.bitchat.android.services.AppStateStore.hasPrivateTextReceipt(message)) return false
sendDeliveryAck(privateMessage.messageID, peerID)
}
}
@@ -129,7 +130,19 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
val file = com.bitchat.android.model.BitchatFilePacket.decode(noisePayload.data)
if (file != null) {
Log.d(TAG, "Encrypted file from $peerID: ${file.fileSize} bytes")
- val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
+ val stableID = com.bitchat.android.model.PrivateMediaMessageIdentity.stableID(peerID, myPeerID, file.fileName)
+ if (stableID != null) {
+ when (com.bitchat.android.services.AppStateStore.privateMediaReceiptState(stableID)) {
+ com.bitchat.android.services.PrivateMediaReceiptState.ACCEPTED,
+ com.bitchat.android.services.PrivateMediaReceiptState.TOMBSTONED -> {
+ sendDeliveryAck(stableID, peerID)
+ return true
+ }
+ com.bitchat.android.services.PrivateMediaReceiptState.UNAVAILABLE -> return false
+ com.bitchat.android.services.PrivateMediaReceiptState.ABSENT -> Unit
+ }
+ }
+ val uniqueMsgId = stableID ?: java.util.UUID.randomUUID().toString().uppercase()
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
val message = BitchatMessage(
id = uniqueMsgId,
@@ -147,8 +160,18 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
delegate?.onMessageReceived(message)
}
- // Send delivery ACK with generated message ID
- sendDeliveryAck(uniqueMsgId, peerID)
+ if (stableID == null) {
+ sendDeliveryAck(uniqueMsgId, peerID)
+ } else {
+ val receipt = com.bitchat.android.services.AppStateStore.privateMediaReceiptState(stableID)
+ if (receipt == com.bitchat.android.services.PrivateMediaReceiptState.ACCEPTED ||
+ receipt == com.bitchat.android.services.PrivateMediaReceiptState.TOMBSTONED) {
+ sendDeliveryAck(stableID, peerID)
+ } else {
+ com.bitchat.android.features.file.FileUtils.deleteStoredMediaPaths(appContext, listOf(savedPath))
+ return false
+ }
+ }
} else {
Log.w(TAG, "Failed to decode encrypted file transfer from $peerID")
}
@@ -248,7 +271,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
senderPeerID = peerID
)
delegate?.onMessageReceived(message)
- return true
+ return com.bitchat.android.services.AppStateStore.hasPrivateTextReceipt(message)
}
/**
diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt
index 7d8b6fd3..eb7232c1 100644
--- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt
@@ -161,6 +161,9 @@ class UnifiedMeshService(
onAccepted: () -> Unit
): Boolean = bluetooth.sendBridgeCourierMessage(content, messageID, recipientNoiseKey, onAccepted)
+ override fun supportsPrivateMediaReceipts(peerID: String): Boolean =
+ bluetooth.supportsPrivateMediaReceipts(peerID) || wifiService()?.supportsPrivateMediaReceipts(peerID) == true
+
override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
when {
isBleReady(recipientPeerID) -> bluetooth.sendReadReceipt(messageID, recipientPeerID, readerNickname)
@@ -471,8 +474,10 @@ class UnifiedMeshService(
}
override fun clearAllInternalData() {
- try { bluetooth.clearAllInternalData() } catch (_: Exception) { }
- try { wifiService()?.clearAllInternalData() } catch (_: Exception) { }
+ val bluetoothResult = runCatching { bluetooth.clearAllInternalData() }
+ val wifiResult = runCatching { wifiService()?.clearAllInternalData() }
+ bluetoothResult.getOrThrow()
+ wifiResult.getOrThrow()
}
override fun clearAllEncryptionData() {
diff --git a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt
index a14444bd..bf5fb4fe 100644
--- a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt
+++ b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt
@@ -58,10 +58,16 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
/** Capabilities implemented by this Android build. */
@Deprecated("Use localSupported() so runtime bridge state is included")
- val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or PREKEYS.rawValue or GROUPS.rawValue or BOARD.rawValue or VOUCH.rawValue or MESH_DIAGNOSTICS.rawValue)
+ val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or BOARD.rawValue or VOUCH.rawValue or MESH_DIAGNOSTICS.rawValue)
@Volatile
private var bridgeEnabled: Boolean = false
+ @Volatile private var gatewayEnabled: Boolean = false
+ fun setGatewayEnabled(enabled: Boolean) { gatewayEnabled = enabled }
+
+ @Volatile private var phoneFeaturesEnabled = false
+
+ fun setPhoneFeaturesEnabled(enabled: Boolean) { phoneFeaturesEnabled = enabled }
fun setBridgeEnabled(enabled: Boolean) {
bridgeEnabled = enabled
@@ -69,8 +75,9 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
fun localSupported(): PeerCapabilities = PeerCapabilities(
LOCAL_SUPPORTED.rawValue or
- PREKEYS.rawValue or
- if (bridgeEnabled) BRIDGE.rawValue else 0L
+ (if (phoneFeaturesEnabled) PREKEYS.rawValue or GROUPS.rawValue or PRIVATE_MEDIA_RECEIPTS.rawValue else 0L) or
+ (if (bridgeEnabled) BRIDGE.rawValue else 0L) or
+ (if (gatewayEnabled) GATEWAY.rawValue else 0L)
)
/**
diff --git a/app/src/main/java/com/bitchat/android/model/PrivateMediaMessageIdentity.kt b/app/src/main/java/com/bitchat/android/model/PrivateMediaMessageIdentity.kt
new file mode 100644
index 00000000..5b2fdc6d
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/model/PrivateMediaMessageIdentity.kt
@@ -0,0 +1,38 @@
+package com.bitchat.android.model
+
+import java.io.ByteArrayOutputStream
+import java.nio.ByteBuffer
+import java.security.MessageDigest
+
+/** Stable, direction-bound IDs matching the iOS private-media receipt contract. */
+object PrivateMediaMessageIdentity {
+ private val stable = Regex("media-[0-9a-f]{32}")
+ private val uuid = Regex("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
+ private val peer = Regex("[0-9a-f]{16}")
+
+ fun isStableID(value: String): Boolean = stable.matches(value)
+
+ fun stableID(senderPeerID: String, recipientPeerID: String, fileName: String?): String? {
+ if (fileName.isNullOrEmpty() || '/' in fileName || '\\' in fileName) return null
+ val sender = senderPeerID.lowercase()
+ val recipient = recipientPeerID.lowercase()
+ if (!peer.matches(sender) || !peer.matches(recipient)) return null
+ val stem = fileName.substringBeforeLast('.', fileName)
+ val extension = fileName.substringAfterLast('.', "").lowercase()
+ val isVoice = stem.startsWith("voice_") && extension == "m4a"
+ val isImage = stem.startsWith("img_") && extension in setOf("jpg", "jpeg")
+ if (!isVoice && !isImage) return null
+ val burst = stem.removePrefix("voice_")
+ if (!uuid.matches(stem.substringAfterLast('_')) &&
+ !(isVoice && Regex("[0-9a-fA-F]{16}").matches(burst))) return null
+ val input = ByteArrayOutputStream()
+ input.write("bitchat-private-media-message-v1".toByteArray(Charsets.UTF_8))
+ listOf(sender, recipient, fileName).forEach { field ->
+ val bytes = field.toByteArray(Charsets.UTF_8)
+ input.write(ByteBuffer.allocate(4).putInt(bytes.size).array())
+ input.write(bytes)
+ }
+ return "media-" + MessageDigest.getInstance("SHA-256").digest(input.toByteArray())
+ .take(16).joinToString("") { "%02x".format(it) }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt b/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt
index c7120f5b..160ac30c 100644
--- a/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt
@@ -76,7 +76,8 @@ private class NostrBridgeCourierRelay(
event: NostrEvent,
relayUrls: List,
onAccepted: () -> Unit
- ): Boolean = relayManager.sendEvent(event, relayUrls, onAccepted = onAccepted)
+ ): Boolean = relayManager.sendEvent(event, relayUrls, onAccepted = onAccepted,
+ publicationAllowed = com.bitchat.android.services.bridge.MeshBridgeService.publicationPermit())
}
private class EncryptionBridgeCourierCipher(
diff --git a/app/src/main/java/com/bitchat/android/nostr/CustomRelayUrl.kt b/app/src/main/java/com/bitchat/android/nostr/CustomRelayUrl.kt
new file mode 100644
index 00000000..a9c9b3d9
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/nostr/CustomRelayUrl.kt
@@ -0,0 +1,14 @@
+package com.bitchat.android.nostr
+
+import java.net.URI
+
+object CustomRelayUrl {
+ fun normalize(input: String): String? = runCatching {
+ val uri = URI(input.trim())
+ require(uri.scheme.equals("wss", ignoreCase = true))
+ require(!uri.host.isNullOrBlank() && uri.userInfo == null && uri.fragment == null && uri.query == null)
+ require(uri.port == -1 || uri.port in 1..65535)
+ URI("wss", null, uri.host.lowercase(), uri.port,
+ uri.path?.takeUnless { it == "/" }, null, null).toASCIIString()
+ }.getOrNull()
+}
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt
index a895f612..758a0652 100644
--- a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt
@@ -92,6 +92,13 @@ object NostrBackgroundRuntime {
)
}
+ fun receiveGatewayEvent(event: NostrEvent, geohash: String) {
+ if (!initialized || activeGeohash != geohash) return
+ val selected = (locationChannels.selectedChannel.value as? ChannelID.Location)?.channel ?: return
+ if (selected.geohash != geohash || !locationChannels.canUseSelectedLocationChannel(selected)) return
+ eventProcessor.onGeohashMessage(event, geohash)
+ }
+
private fun subscribeAccountDm() {
val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) ?: return
subscriptions.subscribeGiftWraps(
@@ -147,7 +154,10 @@ object NostrBackgroundRuntime {
sinceMs = System.currentTimeMillis() - 3_600_000L,
limit = 200,
id = "geohash-$geohash",
- handler = { event -> eventProcessor.onGeohashMessage(event, geohash) },
+ handler = { event ->
+ eventProcessor.onGeohashMessage(event, geohash)
+ com.bitchat.android.services.bridge.MeshGatewayService.rebroadcastRelayEvent(event, geohash, liveLocationToken)
+ },
liveLocationToken = liveLocationToken
)
subscribeGeohashDm(geohash, "geo-dm-$geohash", liveLocationToken)
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt
index dd9e16d1..fb40475a 100644
--- a/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt
@@ -16,14 +16,16 @@ internal class NostrPendingEventQueue(
data class Delivery(
val queueId: Long,
val event: NostrEvent,
- val liveLocationToken: Long?
+ val liveLocationToken: Long?,
+ val publicationAllowed: () -> Boolean
)
private data class Entry(
val queueId: Long,
val event: NostrEvent,
val pendingRelayUrls: MutableSet,
- val liveLocationToken: Long?
+ val liveLocationToken: Long?,
+ val publicationAllowed: () -> Boolean
)
private val lock = Any()
@@ -33,7 +35,8 @@ internal class NostrPendingEventQueue(
fun enqueue(
event: NostrEvent,
relayUrls: Collection,
- liveLocationToken: Long?
+ liveLocationToken: Long?,
+ publicationAllowed: () -> Boolean = { true }
): Long? {
val pendingRelays = relayUrls.filterTo(linkedSetOf()) { it.isNotBlank() }
if (pendingRelays.isEmpty()) return null
@@ -46,7 +49,8 @@ internal class NostrPendingEventQueue(
queueId = queueId,
event = event,
pendingRelayUrls = pendingRelays,
- liveLocationToken = liveLocationToken
+ liveLocationToken = liveLocationToken,
+ publicationAllowed = publicationAllowed
)
)
queueId
@@ -54,10 +58,11 @@ internal class NostrPendingEventQueue(
}
fun pendingForRelay(relayUrl: String): List = synchronized(lock) {
+ entries.removeAll { !it.publicationAllowed() }
entries
.asSequence()
.filter { relayUrl in it.pendingRelayUrls }
- .map { Delivery(it.queueId, it.event, it.liveLocationToken) }
+ .map { Delivery(it.queueId, it.event, it.liveLocationToken, it.publicationAllowed) }
.toList()
}
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt
index d8e431a8..ca619345 100644
--- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt
@@ -37,6 +37,7 @@ class NostrRelayManager private constructor() {
*/
fun getInstance(context: android.content.Context): NostrRelayManager {
shared.appContext = context.applicationContext
+ shared.loadCustomRelays(context.applicationContext)
return shared
}
@@ -61,7 +62,7 @@ class NostrRelayManager private constructor() {
pendingGiftWrapIDs.add(id)
}
- fun defaultRelays(): List = DEFAULT_RELAYS
+ fun defaultRelays(): List = (DEFAULT_RELAYS + shared.customRelays.value).distinct()
}
/**
@@ -79,6 +80,67 @@ class NostrRelayManager private constructor() {
var nextReconnectTime: Long? = null
)
+ private val _customRelays = MutableStateFlow>(emptyList())
+ val customRelays: StateFlow> = _customRelays.asStateFlow()
+ private var customRelaysLoaded = false
+
+ @Synchronized
+ private fun loadCustomRelays(context: android.content.Context) {
+ if (customRelaysLoaded) return
+ customRelaysLoaded = true
+ val urls = context.getSharedPreferences("nostr_custom_relays", android.content.Context.MODE_PRIVATE)
+ .getStringSet("urls", emptySet()).orEmpty().mapNotNull(CustomRelayUrl::normalize).distinct().take(20)
+ _customRelays.value = urls
+ nonLiveRelayUrls.addAll(urls)
+ ensureConnectionsFor(urls.toSet())
+ }
+
+ @Synchronized
+ fun addCustomRelay(input: String): Boolean {
+ val url = CustomRelayUrl.normalize(input) ?: return false
+ if (url in defaultRelays()) return true
+ if (_customRelays.value.size >= 20) return false
+ val previousDefaults = defaultRelays().toSet()
+ _customRelays.value = _customRelays.value + url
+ persistCustomRelays()
+ nonLiveRelayUrls.add(url)
+ activeSubscriptions.replaceAll { _, subscription ->
+ if (subscription.targetRelayUrls == previousDefaults) {
+ subscription.copy(targetRelayUrls = previousDefaults + url)
+ } else subscription
+ }
+ ensureConnectionsFor(setOf(url))
+ return true
+ }
+
+ @Synchronized
+ fun removeCustomRelay(url: String) {
+ if (url !in _customRelays.value) return
+ _customRelays.value = _customRelays.value - url
+ persistCustomRelays()
+ activeSubscriptions.replaceAll { _, subscription ->
+ subscription.copy(targetRelayUrls = subscription.targetRelayUrls?.minus(url))
+ }
+ reconnectJobs.remove(url)?.cancel()
+ connections.remove(url)?.close(1000, "Relay removed")
+ subscriptions.remove(url)
+ nonLiveRelayUrls.remove(url)
+ synchronized(relaysList) { relaysList.removeAll { it.url == url } }
+ updateRelaysList()
+ updateConnectionStatus()
+ }
+
+ fun clearCustomRelays() {
+ _customRelays.value.toList().forEach(::removeCustomRelay)
+ check(appContext?.getSharedPreferences("nostr_custom_relays", android.content.Context.MODE_PRIVATE)
+ ?.edit()?.clear()?.commit() != false)
+ }
+
+ private fun persistCustomRelays() {
+ appContext?.getSharedPreferences("nostr_custom_relays", android.content.Context.MODE_PRIVATE)
+ ?.edit()?.putStringSet("urls", _customRelays.value.toSet())?.apply()
+ }
+
// Published state
private val _relays = MutableStateFlow>(emptyList())
val relays: StateFlow> = _relays.asStateFlow()
@@ -237,23 +299,24 @@ class NostrRelayManager private constructor() {
geohash: String,
includeDefaults: Boolean = false,
nRelays: Int = 5,
- liveLocationToken: Long? = null
+ liveLocationToken: Long? = null,
+ publicationAllowed: () -> Boolean = { true }
) {
- if (!isNetworkActionAllowed(liveLocationToken)) return
+ if (!publicationAllowed() || !isNetworkActionAllowed(liveLocationToken)) return
ensureGeohashRelaysConnected(
geohash,
nRelays,
includeDefaults,
liveLocationToken
)
- if (!isNetworkActionAllowed(liveLocationToken)) return
+ if (!publicationAllowed() || !isNetworkActionAllowed(liveLocationToken)) return
val relayUrls = getRelaysForGeohash(geohash)
if (relayUrls.isEmpty()) {
Log.w(TAG, "No target relays for geohash event; falling back to defaults")
- sendEvent(event, Companion.defaultRelays(), liveLocationToken)
+ sendEvent(event, Companion.defaultRelays(), liveLocationToken, publicationAllowed = publicationAllowed)
return
}
- sendEvent(event, relayUrls, liveLocationToken)
+ sendEvent(event, relayUrls, liveLocationToken, publicationAllowed = publicationAllowed)
}
// --- Internal helpers ---
@@ -458,8 +521,10 @@ class NostrRelayManager private constructor() {
event: NostrEvent,
relayUrls: List? = null,
liveLocationToken: Long? = null,
- onAccepted: (() -> Unit)? = null
+ onAccepted: (() -> Unit)? = null,
+ publicationAllowed: () -> Boolean = { true }
): Boolean {
+ if (!publicationAllowed()) return false
val targetRelays = (relayUrls ?: relaysList.map { it.url })
.filter { it.isNotBlank() }
.distinct()
@@ -470,7 +535,8 @@ class NostrRelayManager private constructor() {
val queueId = messageQueue.enqueue(
event = event,
relayUrls = targetRelays,
- liveLocationToken = liveLocationToken
+ liveLocationToken = liveLocationToken,
+ publicationAllowed = publicationAllowed
) ?: return@runNetworkAction
enqueued = true
if (onAccepted != null) {
@@ -484,7 +550,7 @@ class NostrRelayManager private constructor() {
targetRelays.forEach { relayUrl ->
val webSocket = connections[relayUrl]
if (webSocket != null) {
- if (sendToRelay(event, webSocket, relayUrl, liveLocationToken)) {
+ if (sendToRelay(event, webSocket, relayUrl, liveLocationToken, publicationAllowed)) {
messageQueue.markDelivered(queueId, relayUrl)
}
}
@@ -904,16 +970,17 @@ class NostrRelayManager private constructor() {
event: NostrEvent,
webSocket: WebSocket,
relayUrl: String,
- liveLocationToken: Long? = null
+ liveLocationToken: Long? = null,
+ publicationAllowed: () -> Boolean = { true }
): Boolean {
- if (!isNetworkActionAllowed(liveLocationToken)) return false
+ if (!publicationAllowed() || !isNetworkActionAllowed(liveLocationToken)) return false
return try {
val request = NostrRequest.Event(event)
val message = gson.toJson(request, NostrRequest::class.java)
var success = false
runNetworkAction(liveLocationToken) {
- success = webSocket.send(message)
+ if (publicationAllowed()) success = webSocket.send(message)
}
if (success) {
// Update relay stats
@@ -1193,7 +1260,8 @@ class NostrRelayManager private constructor() {
delivery.event,
webSocket,
relayUrl,
- delivery.liveLocationToken
+ delivery.liveLocationToken,
+ delivery.publicationAllowed
)
) {
messageQueue.markDelivered(delivery.queueId, relayUrl)
diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
index ebcaac69..86aa2f20 100644
--- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
+++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
@@ -272,6 +272,22 @@ object AppStateStore {
* Persists an incoming private message before it is admitted to UI, unread, haptic, or
* notification state. Transport callbacks invoke this from their background worker.
*/
+ suspend fun hasPrivateTextReceipt(message: BitchatMessage): Boolean {
+ val repository = synchronized(this) {
+ if (privateConversationWritesSuspended) return false
+ conversationRepository
+ } ?: return false
+ return repository.hasPrivateTextReceipt(message)
+ }
+
+ suspend fun privateMediaReceiptState(messageID: String): PrivateMediaReceiptState {
+ val repository = synchronized(this) {
+ if (privateConversationWritesSuspended) return PrivateMediaReceiptState.UNAVAILABLE
+ conversationRepository
+ } ?: return PrivateMediaReceiptState.UNAVAILABLE
+ return repository.privateMediaReceiptState(messageID)
+ }
+
suspend fun addPrivateMessageDurably(
peerID: String,
msg: BitchatMessage,
diff --git a/app/src/main/java/com/bitchat/android/services/ChannelInvitation.kt b/app/src/main/java/com/bitchat/android/services/ChannelInvitation.kt
new file mode 100644
index 00000000..f8c4e5bf
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/services/ChannelInvitation.kt
@@ -0,0 +1,15 @@
+package com.bitchat.android.services
+
+import java.net.URI
+
+/** Parses explicit channel invitations; never requests or infers device location. */
+object ChannelInvitation {
+ private val geohash = Regex("[0123456789bcdefghjkmnpqrstuvwxyz]{1,12}")
+ fun decode(link: String): String? = runCatching {
+ val uri = URI(link)
+ require(uri.scheme == "bitchat" && uri.host == "geohash" && uri.query == null && uri.fragment == null && uri.userInfo == null && uri.port == -1)
+ val cell = uri.path.removePrefix("/").lowercase()
+ cell.takeIf(geohash::matches)
+ }.getOrNull()
+ fun link(cell: String): String? = cell.lowercase().takeIf(geohash::matches)?.let { "bitchat://geohash/$it" }
+}
diff --git a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt
index a6b849d7..0b5ab27a 100644
--- a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt
+++ b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt
@@ -27,6 +27,8 @@ import java.util.Date
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
+enum class PrivateMediaReceiptState { ABSENT, ACCEPTED, TOMBSTONED, UNAVAILABLE }
+
/**
* Process-wide, serialized persistence for private conversations.
*
@@ -141,6 +143,16 @@ class ConversationRepository internal constructor(
}
}
+ suspend fun hasPrivateTextReceipt(message: BitchatMessage): Boolean = withContext(dispatcher) {
+ try { database.hasPrivateTextReceipt(message) } catch (_: Exception) { false }
+ }
+
+ suspend fun privateMediaReceiptState(messageID: String): PrivateMediaReceiptState = withContext(dispatcher) {
+ try { database.privateMediaReceiptState(messageID) } catch (_: Exception) {
+ PrivateMediaReceiptState.UNAVAILABLE
+ }
+ }
+
suspend fun upsertMessageAndWait(
conversationID: String,
aliases: Set,
@@ -743,6 +755,33 @@ internal class ConversationDatabase(
}
}
+ fun hasPrivateTextReceipt(incoming: BitchatMessage): Boolean {
+ if (!incoming.isPrivate || incoming.type != BitchatMessageType.Message) return false
+ if (isDeletedMessageLocked(readableDatabase, incoming.id)) return true
+ readableDatabase.query("private_messages", MESSAGE_COLUMNS, "message_id = ?",
+ arrayOf(incoming.id), null, null, null).use { cursor ->
+ if (!cursor.moveToFirst()) return false
+ val stored = cursor.toMessage()
+ return stored.type == BitchatMessageType.Message && stored.content == incoming.content &&
+ stored.senderPeerID == incoming.senderPeerID
+ }
+ }
+
+ fun privateMediaReceiptState(messageID: String): PrivateMediaReceiptState {
+ if (!com.bitchat.android.model.PrivateMediaMessageIdentity.isStableID(messageID)) {
+ return PrivateMediaReceiptState.UNAVAILABLE
+ }
+ if (isDeletedMessageLocked(readableDatabase, messageID)) return PrivateMediaReceiptState.TOMBSTONED
+ readableDatabase.query("private_messages", MESSAGE_COLUMNS, "message_id = ?",
+ arrayOf(messageID), null, null, null).use { cursor ->
+ if (!cursor.moveToFirst()) return PrivateMediaReceiptState.ABSENT
+ val message = cursor.toMessage()
+ return if (message.type != BitchatMessageType.Message && File(message.content).isFile) {
+ PrivateMediaReceiptState.ACCEPTED
+ } else PrivateMediaReceiptState.UNAVAILABLE
+ }
+ }
+
private fun loadDeletedMessageIDs(): Set {
readableDatabase.query(
"deleted_private_messages",
diff --git a/app/src/main/java/com/bitchat/android/services/PrivateMediaOutbox.kt b/app/src/main/java/com/bitchat/android/services/PrivateMediaOutbox.kt
new file mode 100644
index 00000000..788a3a3c
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/services/PrivateMediaOutbox.kt
@@ -0,0 +1,190 @@
+package com.bitchat.android.services
+
+import android.content.Context
+import android.util.AtomicFile
+import android.util.Base64
+import com.bitchat.android.mesh.MeshService
+import com.bitchat.android.mesh.PrivateMediaPreparation
+import com.bitchat.android.model.BitchatFilePacket
+import com.bitchat.android.model.DeliveryStatus
+import com.bitchat.android.model.PrivateMediaMessageIdentity
+import com.google.gson.Gson
+import java.io.File
+import java.security.MessageDigest
+import kotlinx.coroutines.*
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+/** One encrypted atomic record per pending media message; no plaintext retransmission spool. */
+internal class PrivateMediaOutboxStore(
+ context: Context,
+ private val cipher: ConversationStorageCipher = AndroidConversationStorageCipher("bitchat_private_media_outbox_v1")
+) {
+ data class Entry(
+ val id: String,
+ val conversationID: String,
+ val recipientPeerID: String,
+ val encodedPacket: String,
+ val createdAt: Long,
+ val attempts: Int = 1,
+ val lastAttemptAt: Long = createdAt
+ )
+
+ private val directory = File(context.filesDir, "private-media-outbox")
+ private val gson = Gson()
+
+ fun load(): List {
+ if (!directory.exists()) return emptyList()
+ return checkNotNull(directory.listFiles()).filter { PrivateMediaMessageIdentity.isStableID(it.name) }
+ .take(MAX_ENTRIES).mapNotNull { file ->
+ // A corrupt record stays quarantined in place; it is never retransmitted.
+ runCatching {
+ require(file.length() <= MAX_RECORD_BYTES)
+ val plaintext = cipher.decrypt(AtomicFile(file).readFully(), file.name.toByteArray())
+ gson.fromJson(plaintext.toString(Charsets.UTF_8), Entry::class.java)
+ .also { require(it.id == file.name && it.attempts in 1..MAX_ATTEMPTS) }
+ }.getOrNull()
+ }
+ }
+
+ fun save(entry: Entry) {
+ require(PrivateMediaMessageIdentity.isStableID(entry.id))
+ check(directory.isDirectory || directory.mkdirs())
+ val files = checkNotNull(directory.listFiles())
+ val file = File(directory, entry.id)
+ check(file.exists() || files.size < MAX_ENTRIES)
+ val encrypted = cipher.encrypt(gson.toJson(entry).toByteArray(), entry.id.toByteArray())
+ require(encrypted.size <= MAX_RECORD_BYTES)
+ check(files.sumOf { it.length() } - file.length() + encrypted.size <= MAX_TOTAL_BYTES)
+ val atomic = AtomicFile(file)
+ val stream = atomic.startWrite()
+ try {
+ stream.write(encrypted)
+ atomic.finishWrite(stream)
+ } catch (error: Exception) {
+ atomic.failWrite(stream)
+ throw error
+ }
+ }
+
+ fun remove(id: String) {
+ require(PrivateMediaMessageIdentity.isStableID(id))
+ AtomicFile(File(directory, id)).delete()
+ check(!File(directory, id).exists())
+ }
+
+ fun wipe() {
+ cipher.destroyKey()
+ check(!directory.exists() || directory.deleteRecursively())
+ }
+
+ companion object {
+ const val MAX_ATTEMPTS = 8
+ private const val MAX_ENTRIES = 100
+ private const val MAX_RECORD_BYTES = 8 * 1024 * 1024L
+ private const val MAX_TOTAL_BYTES = 64 * 1024 * 1024L
+ }
+}
+
+/** Retries the original file and message identity until a matching recipient acknowledges it. */
+class PrivateMediaOutbox private constructor(context: Context) {
+ private val store = PrivateMediaOutboxStore(context)
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val mutex = Mutex()
+ private var entries: MutableMap? = null
+ @Volatile private var paused = false
+
+ init {
+ scope.launch {
+ while (isActive) {
+ delay(15_000)
+ runCatching { retryPending() }
+ }
+ }
+ }
+
+ private fun loaded(): MutableMap =
+ entries ?: store.load().associateByTo(linkedMapOf()) { it.id }.also { entries = it }
+
+ suspend fun enqueue(id: String, conversationID: String, recipientPeerID: String, packet: BitchatFilePacket): Boolean =
+ withContext(Dispatchers.IO) {
+ mutex.withLock {
+ if (paused) return@withLock false
+ runCatching {
+ val encoded = checkNotNull(packet.encode())
+ val entry = PrivateMediaOutboxStore.Entry(id, conversationID, recipientPeerID,
+ Base64.encodeToString(encoded, Base64.NO_WRAP), System.currentTimeMillis())
+ store.save(entry)
+ loaded()[id] = entry
+ true
+ }.getOrDefault(false)
+ }
+ }
+
+ fun acknowledge(id: String, peerID: String) {
+ if (!PrivateMediaMessageIdentity.isStableID(id)) return
+ scope.launch {
+ mutex.withLock {
+ val entry = loaded()[id] ?: return@withLock
+ if (entry.recipientPeerID != peerID) return@withLock
+ runCatching { store.remove(id) }.onSuccess { loaded().remove(id) }
+ }
+ }
+ }
+
+ suspend fun wipe() {
+ paused = true
+ withContext(Dispatchers.IO) {
+ mutex.withLock {
+ store.wipe()
+ entries = linkedMapOf()
+ }
+ }
+ }
+
+ fun resume() { paused = false }
+
+ private suspend fun retryPending() = mutex.withLock {
+ if (paused) return@withLock
+ val mesh = com.bitchat.android.service.MeshServiceHolder.unifiedMeshService ?: return@withLock
+ val now = System.currentTimeMillis()
+ loaded().values.toList().forEach { entry ->
+ if (AppStateStore.privateMediaReceiptState(entry.id) == PrivateMediaReceiptState.TOMBSTONED) {
+ store.remove(entry.id)
+ loaded().remove(entry.id)
+ return@forEach
+ }
+ if (now - entry.createdAt > 24 * 60 * 60 * 1000L ||
+ (entry.attempts >= PrivateMediaOutboxStore.MAX_ATTEMPTS && now - entry.lastAttemptAt >= 300_000)) {
+ AppStateStore.updatePrivateMessageStatus(entry.id, DeliveryStatus.Failed("No delivery receipt received"))
+ store.remove(entry.id)
+ loaded().remove(entry.id)
+ return@forEach
+ }
+ if (entry.attempts >= PrivateMediaOutboxStore.MAX_ATTEMPTS) return@forEach
+ val interval = (30_000L shl (entry.attempts - 1).coerceAtMost(4)).coerceAtMost(300_000L)
+ if (now - entry.lastAttemptAt < interval) return@forEach
+ val recipient = ContactDirectory.resolve(entry.conversationID).meshPeerID ?: return@forEach
+ if (recipient != entry.recipientPeerID || !mesh.supportsPrivateMediaReceipts(recipient)) return@forEach
+ val encoded = Base64.decode(entry.encodedPacket, Base64.NO_WRAP)
+ val packet = BitchatFilePacket.decode(encoded) ?: return@forEach
+ if (PrivateMediaMessageIdentity.stableID(mesh.myPeerID, recipient, packet.fileName) != entry.id) return@forEach
+ val transferID = MessageDigest.getInstance("SHA-256").digest(encoded).joinToString("") { "%02x".format(it) }
+ val prepared = mesh.prepareFilePrivate(recipient, packet, transferID, allowLegacyFallback = false)
+ if (prepared is PrivateMediaPreparation.Ready) {
+ val attempted = entry.copy(attempts = entry.attempts + 1, lastAttemptAt = now)
+ store.save(attempted)
+ loaded()[entry.id] = attempted
+ if (prepared.transfer.commit()) AppStateStore.updatePrivateMessageStatus(entry.id, DeliveryStatus.Sent)
+ }
+ }
+ }
+
+ companion object {
+ @Volatile private var instance: PrivateMediaOutbox? = null
+ fun initialize(context: Context): PrivateMediaOutbox = instance ?: synchronized(this) {
+ instance ?: PrivateMediaOutbox(context.applicationContext).also { instance = it }
+ }
+ fun tryGetInstance(): PrivateMediaOutbox? = instance
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/services/PublicMessageReconciler.kt b/app/src/main/java/com/bitchat/android/services/PublicMessageReconciler.kt
deleted file mode 100644
index 12365c50..00000000
--- a/app/src/main/java/com/bitchat/android/services/PublicMessageReconciler.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-package com.bitchat.android.services
-
-import com.bitchat.android.model.BitchatMessage
-
-/**
- * Owns public-timeline replay deduplication and bridge/radio reconciliation.
- *
- * The store remains responsible for synchronization and cross-timeline IDs;
- * this class keeps bridge-specific alias policy independently testable.
- */
-internal class PublicMessageReconciler {
- data class Result(
- val messages: List,
- val accepted: Boolean
- )
-
- private val seenKeys = mutableSetOf()
-
- fun reconcile(
- existing: List,
- incoming: BitchatMessage,
- messageIdAlreadySeen: Boolean
- ): Result {
- val withoutBridgeAliases = if (incoming.isBridged) {
- existing
- } else {
- existing.filterNot {
- it.isBridged && it.bridgeRadioMessageIdHint == incoming.id
- }
- }
- val key = publicMessageKey(incoming)
- if (messageIdAlreadySeen || key in seenKeys) {
- return Result(withoutBridgeAliases, accepted = false)
- }
- seenKeys += key
- return Result(withoutBridgeAliases + incoming, accepted = true)
- }
-
- fun clear() {
- seenKeys.clear()
- }
-
- private fun publicMessageKey(message: BitchatMessage): String {
- val sender = message.senderPeerID ?: message.sender
- return listOf(
- sender,
- message.timestamp.time.toString(),
- message.type.name,
- message.channel ?: "",
- message.content
- ).joinToString("\u001F")
- }
-}
diff --git a/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt b/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt
index c0a0610f..2ae7da18 100644
--- a/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt
+++ b/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt
@@ -1,9 +1,5 @@
package com.bitchat.android.services.bridge
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import com.google.gson.Gson
-import com.google.gson.reflect.TypeToken
internal class BoundedIdSet(private val capacity: Int) {
private val values = LinkedHashSet()
@@ -49,52 +45,3 @@ internal class RelaySubscriptionSlot(private val idPrefix: String) {
close(id)
}
}
-
-/**
- * A small insertion-ordered expiring set. Callers own synchronization; bridge
- * coordinators keep each instance confined to their serial dispatcher.
- */
-internal class PersistentExpiringIdSet(
- private val preferences: SharedPreferences,
- private val key: String,
- private val capacity: Int
-) {
- private val gson = Gson()
- private val values: LinkedHashMap = load()
-
- fun contains(id: String, nowMs: Long = System.currentTimeMillis()): Boolean {
- prune(nowMs)
- return (values[id] ?: return false) > nowMs
- }
-
- fun add(id: String, lifetimeMs: Long, nowMs: Long = System.currentTimeMillis()) {
- prune(nowMs)
- values.remove(id)
- values[id] = nowMs + lifetimeMs
- while (values.size > capacity) values.remove(values.keys.first())
- persist()
- }
-
- fun clear() {
- values.clear()
- preferences.edit { remove(key) }
- }
-
- private fun prune(nowMs: Long) {
- val changed = values.entries.removeAll { it.value <= nowMs }
- if (changed) persist()
- }
-
- private fun load(): LinkedHashMap {
- val type = object : TypeToken