diff --git a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt index 0f6675c4..a2e17b9b 100644 --- a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt +++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt @@ -16,6 +16,7 @@ import com.bitchat.android.service.MeshServiceHolder import com.bitchat.android.service.TransportBridgeService import com.bitchat.android.services.AppStateStore import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.PrivateMediaRecipientResolver import com.bitchat.android.util.AppConstants import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -283,6 +284,10 @@ object TestHookDriver { ) val encoded = packet.encode() ?: return err("file_send", "failed to TLV-encode packet") val transferId = sha256Hex(encoded) + val recipient = peerID?.let { + PrivateMediaRecipientResolver.resolve(it, mesh) + ?: return err("file_send", "no active mesh route for private conversation: $it") + } return coroutineScope { // Subscribe on a background dispatcher before sending so synchronous @@ -291,7 +296,14 @@ object TestHookDriver { TransferProgressManager.events.first { it.transferId == transferId && it.completed } } delay(50) - val sendError = dispatchFileSend(context, intent, mesh, peerID, packet, transferId) + val sendError = dispatchFileSend( + context, + intent, + mesh, + recipient?.meshPeerID, + packet, + transferId + ) if (sendError != null) { completion.cancel() return@coroutineScope sendError.put("cmd", "file_send") @@ -307,7 +319,8 @@ object TestHookDriver { .put("sent", event.sent) .put("total", event.total) .put("bytes", content.size) - .put("peer", peerID) + .put("peer", recipient?.meshPeerID) + .put("conversation", peerID) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index e982189f..f9297afa 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -57,7 +57,8 @@ class MediaSendingManager( private data class PendingPrivateMedia( val request: LegacyPrivateMediaConsentRequest, - val peerID: String, + val conversationID: String, + val recipientMeshPeerID: String, val filePacket: BitchatFilePacket, val filePath: String, val messageType: BitchatMessageType, @@ -68,7 +69,8 @@ class MediaSendingManager( private data class PendingAutomaticPrivateMedia( val requestId: String, - val peerID: String, + val conversationID: String, + val recipientMeshPeerID: String, val filePacket: BitchatFilePacket, val filePath: String, val messageType: BitchatMessageType, @@ -299,10 +301,19 @@ class MediaSendingManager( val transferId = withContext(mediaWorkDispatcher) { sha256Hex(payload) } + val recipient = PrivateMediaRecipientResolver.resolve(toPeerID, meshService) + ?: run { + addPrivateMediaSystemMessage( + toPeerID, + "Private media was not sent because this conversation has no active mesh route." + ) + return + } val pending = PendingAutomaticPrivateMedia( requestId = UUID.randomUUID().toString(), - peerID = toPeerID, + conversationID = recipient.conversationID, + recipientMeshPeerID = recipient.meshPeerID, filePacket = filePacket, filePath = filePath, messageType = messageType, @@ -311,7 +322,7 @@ class MediaSendingManager( ) if (!reserveAutomaticPending(pending)) { addPrivateMediaSystemMessage( - toPeerID, + recipient.conversationID, "Private media was not sent because another secure media send is still pending." ) return @@ -334,7 +345,8 @@ class MediaSendingManager( val pending = consumePendingConsent(requestId) ?: return val automatic = PendingAutomaticPrivateMedia( requestId = UUID.randomUUID().toString(), - peerID = pending.peerID, + conversationID = pending.conversationID, + recipientMeshPeerID = pending.recipientMeshPeerID, filePacket = pending.filePacket, filePath = pending.filePath, messageType = pending.messageType, @@ -343,7 +355,7 @@ class MediaSendingManager( ) if (!reserveAutomaticPending(automatic)) { addPrivateMediaSystemMessage( - pending.peerID, + pending.conversationID, "Private media was not sent because another secure media send is still pending." ) return @@ -374,7 +386,7 @@ class MediaSendingManager( private suspend fun retryPendingPrivateMediaOnScope(peerID: String) { val pending = synchronized(pendingConsentLock) { pendingAutomaticPrivateMedia - ?.takeIf { it.peerID == peerID } + ?.takeIf { it.recipientMeshPeerID == peerID } } ?: return evaluateAutomaticPending(pending) } @@ -397,7 +409,7 @@ class MediaSendingManager( val preparation = try { withContext(mediaWorkDispatcher) { meshService.prepareFilePrivate( - recipientPeerID = pending.peerID, + recipientPeerID = pending.recipientMeshPeerID, file = pending.filePacket, transferId = pending.transferId, allowLegacyFallback = pending.allowLegacyFallback @@ -438,7 +450,8 @@ class MediaSendingManager( clearAutomaticPending(pending.requestId) commitPreparedPrivateFile( preparation, - pending.peerID, + pending.conversationID, + pending.recipientMeshPeerID, pending.filePath, pending.messageType, pending.transferId @@ -450,16 +463,16 @@ class MediaSendingManager( if (pending.allowLegacyFallback) { Log.w(TAG, "Legacy consent was consumed but policy still requested consent; send aborted") addPrivateMediaSystemMessage( - pending.peerID, + pending.conversationID, "Private media was not sent because its security policy changed." ) return } val nickname = try { - meshService.getPeerNicknames()[pending.peerID] + meshService.getPeerNicknames()[pending.recipientMeshPeerID] } catch (_: Exception) { null - } ?: pending.peerID.take(8) + } ?: pending.recipientMeshPeerID.take(8) val request = LegacyPrivateMediaConsentRequest( requestId = UUID.randomUUID().toString(), recipientNickname = nickname, @@ -473,7 +486,8 @@ class MediaSendingManager( } pendingPrivateMedia = PendingPrivateMedia( request, - pending.peerID, + pending.conversationID, + pending.recipientMeshPeerID, pending.filePacket, pending.filePath, pending.messageType, @@ -487,7 +501,7 @@ class MediaSendingManager( ensureAutomaticPendingTimeout(pending) Log.d(TAG, "Private media needs a Noise handshake; retaining first-send intent") try { - meshService.initiateNoiseHandshake(pending.peerID) + meshService.initiateNoiseHandshake(pending.recipientMeshPeerID) } catch (e: Exception) { Log.w(TAG, "Could not initiate private-media Noise handshake: ${e.message}") } @@ -502,7 +516,7 @@ class MediaSendingManager( clearAutomaticPending(pending.requestId) Log.w(TAG, "Private media not sent: ${preparation.reason}") addPrivateMediaSystemMessage( - pending.peerID, + pending.conversationID, "Private media was not sent: ${preparation.reason}" ) } @@ -542,7 +556,7 @@ class MediaSendingManager( } if (expired) { addPrivateMediaSystemMessage( - pending.peerID, + pending.conversationID, "Private media was not sent because secure session setup timed out." ) } @@ -587,7 +601,8 @@ class MediaSendingManager( private fun commitPreparedPrivateFile( preparation: PrivateMediaPreparation.Ready, - toPeerID: String, + conversationID: String, + recipientMeshPeerID: String, filePath: String, messageType: BitchatMessageType, transferId: String @@ -605,13 +620,17 @@ class MediaSendingManager( timestamp = Date(), isRelay = false, isPrivate = true, - recipientNickname = try { meshService.getPeerNicknames()[toPeerID] } catch (_: Exception) { null }, + recipientNickname = try { + meshService.getPeerNicknames()[recipientMeshPeerID] + } catch (_: Exception) { + null + }, senderPeerID = meshService.myPeerID ) // Preparation already built and admitted the exact final packet. Map // progress before commit so the first asynchronous event cannot race us. - messageManager.addPrivateMessage(toPeerID, msg) + messageManager.addPrivateMessage(conversationID, msg) synchronized(transferMessageMap) { transferMessageMap[transferId] = msg.id messageTransferMap[msg.id] = transferId @@ -629,7 +648,7 @@ class MediaSendingManager( } Log.w(TAG, "Prepared private-media commit failed; local echo rolled back") addPrivateMediaSystemMessage( - toPeerID, + conversationID, "Private media was not sent because the prepared transfer could not be committed." ) return diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateMediaRecipientResolver.kt b/app/src/main/java/com/bitchat/android/ui/PrivateMediaRecipientResolver.kt new file mode 100644 index 00000000..6603795a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PrivateMediaRecipientResolver.kt @@ -0,0 +1,49 @@ +package com.bitchat.android.ui + +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.ContactIdentityResolver + +internal data class PrivateMediaRecipient( + val conversationID: String, + val meshPeerID: String +) + +/** + * Private-chat state is keyed by a stable contact/conversation ID, while mesh + * encryption and transport APIs require the current 16-hex peer ID. + */ +internal object PrivateMediaRecipientResolver { + fun resolve(requestedRecipientID: String, meshService: MeshService): PrivateMediaRecipient? { + val requested = requestedRecipientID.trim() + val conversationID = ContactDirectory.canonicalConversationId(requested) + + val directoryPeerID = runCatching { + ContactDirectory.resolve(requested).meshPeerID + }.getOrNull() + val directPeerID = requested.takeIf(ContactIdentityResolver::isMeshPeerId) + val expectedFingerprint = + ContactIdentityResolver.fingerprintFromContactConversationId(conversationID) + ?: requested + .takeIf(ContactIdentityResolver::isNoiseKeyHex) + ?.let(ContactIdentityResolver::bytesFromHex) + ?.let(ContactIdentityResolver::fingerprintHex) + val discoveredPeerID = expectedFingerprint?.let { fingerprint -> + runCatching { + meshService.getPeerNicknames().keys.firstOrNull { candidatePeerID -> + val info = meshService.getPeerInfo(candidatePeerID) + val noisePublicKey = info?.noisePublicKey + info?.isConnected == true && + noisePublicKey != null && + ContactIdentityResolver.fingerprintHex(noisePublicKey) + .equals(fingerprint, ignoreCase = true) + } + }.getOrNull() + } + + val meshPeerID = (directoryPeerID ?: directPeerID ?: discoveredPeerID) + ?.takeIf(ContactIdentityResolver::isMeshPeerId) + ?: return null + return PrivateMediaRecipient(conversationID, meshPeerID) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt index 60c57198..6d6b1b1f 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt @@ -1,9 +1,12 @@ package com.bitchat.android.ui import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.PeerInfo import com.bitchat.android.mesh.PreparedPrivateMediaTransfer import com.bitchat.android.mesh.PrivateMediaPreparation import com.bitchat.android.mesh.PrivateMediaWireMode +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.services.ContactIdentityResolver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -161,6 +164,99 @@ class MediaSendingManagerMigrationTest { verify(mesh, never()).sendFilePrivate(any(), any()) } + @Test + fun `contact conversation resolves to live mesh peer for voice image and file sends`() { + val noisePublicKey = ByteArray(32) { (it + 1).toByte() } + val conversationID = + ContactIdentityResolver.contactConversationIdForNoiseKey(noisePublicKey) + whenever(mesh.getPeerInfo(peerID)).thenReturn( + PeerInfo( + id = peerID, + nickname = "old peer", + isConnected = true, + isDirectConnection = true, + noisePublicKey = noisePublicKey, + signingPublicKey = null, + isVerifiedNickname = true, + lastSeen = System.currentTimeMillis() + ) + ) + val commits = AtomicInteger(0) + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenAnswer { invocation -> + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer( + transferId = invocation.getArgument(2), + wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 + ) { + commits.incrementAndGet() + true + } + ) + } + val genericFile = kotlin.io.path.createTempFile("private-media", ".txt").toFile().apply { + writeText("private attachment") + } + try { + manager.sendVoiceNote(conversationID, null, file.absolutePath) + manager.sendImageNote(conversationID, null, file.absolutePath) + manager.sendFileNote(conversationID, null, genericFile.absolutePath) + + assertEquals(3, commits.get()) + assertEquals( + listOf(BitchatMessageType.Audio, BitchatMessageType.Image, BitchatMessageType.File), + state.privateChats.value[conversationID].orEmpty().map { it.type } + ) + verify(mesh, times(3)) + .prepareFilePrivate(eq(peerID), any(), any(), eq(false)) + verify(mesh, never()) + .prepareFilePrivate(eq(conversationID), any(), any(), any()) + } finally { + genericFile.delete() + } + } + + @Test + fun `mesh policy callback retries contact conversation using live peer ID`() { + val noisePublicKey = ByteArray(32) { (it + 1).toByte() } + val conversationID = + ContactIdentityResolver.contactConversationIdForNoiseKey(noisePublicKey) + whenever(mesh.getPeerInfo(peerID)).thenReturn( + PeerInfo( + id = peerID, + nickname = "old peer", + isConnected = true, + isDirectConnection = true, + noisePublicKey = noisePublicKey, + signingPublicKey = null, + isVerifiedNickname = true, + lastSeen = System.currentTimeMillis() + ) + ) + val commits = AtomicInteger(0) + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenReturn(PrivateMediaPreparation.NeedsHandshake) + .thenAnswer { invocation -> + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer( + transferId = invocation.getArgument(2), + wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 + ) { + commits.incrementAndGet() + true + } + ) + } + + manager.sendVoiceNote(conversationID, null, file.absolutePath) + manager.retryPendingPrivateMedia(peerID) + + assertEquals(1, commits.get()) + assertEquals(BitchatMessageType.Audio, state.privateChats.value[conversationID]?.single()?.type) + verify(mesh).initiateNoiseHandshake(peerID) + verify(mesh, never()).initiateNoiseHandshake(conversationID) + } + @Test fun `awaiting peer state retains send and watchdog resolution offers legacy consent`() { whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) diff --git a/docs/release-gate-runbook.md b/docs/release-gate-runbook.md index 9ac18f30..00fdc98e 100644 --- a/docs/release-gate-runbook.md +++ b/docs/release-gate-runbook.md @@ -300,6 +300,7 @@ python3 tools/release_gate/mesh_lab.py scenario all \ | `file` | 1 KB broadcast file, receiver SHA-256 matches fixture | | `file_oversize` | >256-fragment broadcast file is rejected sender-side, receiver sees nothing | | `file_private` | Noise-encrypted private file, digest match | +| `media_private` | private-chat contact ID resolves to the live mesh peer; voice, image, and generic-file digests match | | `raw` | raw packet injection is accepted by the mesh | | `session_recovery` | force-stop B mid-session: identity persists, re-handshake, DMs flow again | | `identity_reset` | pm clear B mid-session: new identity, rediscovery, handshake, DMs | diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py index 821ce844..2819210c 100644 --- a/tools/release_gate/mesh_lab.py +++ b/tools/release_gate/mesh_lab.py @@ -241,6 +241,28 @@ def make_fixtures(directory: Path, seed: int = 1337, names: list[str] | None = N return fixtures +def make_private_media_fixtures(directory: Path, seed: int = 7331) -> dict[str, dict]: + """Small attachment fixtures covering every private-media UI type.""" + directory.mkdir(parents=True, exist_ok=True) + fixtures = {} + rng = random.Random(seed) + for name, mime in ( + ("voice_note.m4a", "audio/mp4"), + ("image_note.jpg", "image/jpeg"), + ("document_note.txt", "text/plain"), + ): + path = directory / name + data = rng.randbytes(1_024) + path.write_bytes(data) + fixtures[name] = { + "path": path, + "sha256": hashlib.sha256(data).hexdigest(), + "bytes": len(data), + "mime": mime, + } + return fixtures + + # MARK: - setup def setup_pair(a: Device, b: Device, apk: Path | None, nickname_a: str, nickname_b: str) -> None: @@ -330,7 +352,13 @@ def scenario_broadcast(a: Device, b: Device) -> dict: return {"send": send_result, "recv": recv_result} -def scenario_file(a: Device, b: Device, fixtures: dict[str, dict], private: bool = False) -> dict: +def scenario_file( + a: Device, + b: Device, + fixtures: dict[str, dict], + private: bool = False, + recipient: str | None = None, +) -> dict: """File transfer A -> B with sha256 integrity verification.""" id_b = whoami(b)["peer_id"] b.clear_incoming() # avoid name-uniquified collisions across runs @@ -339,7 +367,9 @@ def scenario_file(a: Device, b: Device, fixtures: dict[str, dict], private: bool remote = a.push_fixture(fixture["path"]) send_kwargs: dict[str, object] = {"path": remote} if private: - send_kwargs["peer"] = id_b + send_kwargs["peer"] = recipient or id_b + if fixture.get("mime"): + send_kwargs["mime"] = fixture["mime"] with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: recv = pool.submit(b.cmd_ok, "file_recv", 240_000, name_contains=name) time.sleep(2) @@ -357,6 +387,23 @@ def scenario_file(a: Device, b: Device, fixtures: dict[str, dict], private: bool return results +def scenario_private_media(a: Device, b: Device) -> dict: + """Voice, image, and generic file sends through the private-chat contact ID.""" + identity = whoami(b) + noise_public_key = bytes.fromhex(identity["noise_public_key"]) + conversation_id = f"contact_{hashlib.sha256(noise_public_key).hexdigest()}" + fixtures = make_private_media_fixtures( + Path(tempfile.mkdtemp(prefix="meshlab-private-media-")) + ) + return scenario_file( + a, + b, + fixtures, + private=True, + recipient=conversation_id, + ) + + def scenario_raw(a: Device, b: Device) -> dict: """Raw packet injection (unsigned announce-type packet) reaches the mesh.""" payload = b"meshlab-raw-" + uuid.uuid4().hex[:8].encode() @@ -553,6 +600,7 @@ SCENARIOS = { make_fixtures(Path(tempfile.mkdtemp(prefix="meshlab-fixtures-")), names=["small_1k.bin"]), private=True, ), + "media_private": scenario_private_media, "raw": scenario_raw, "session_recovery": scenario_session_recovery, "identity_reset": scenario_identity_reset,