Merge pull request #804 from permissionlesstech/codex/fix-private-media-contact-routing

Fix private media sending from contact conversations
This commit is contained in:
callebtc 2026-07-29 00:54:47 +02:00 committed by GitHub
commit f5915c4c31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 250 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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