mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
Merge pull request #804 from permissionlesstech/codex/fix-private-media-contact-routing
Fix private media sending from contact conversations
This commit is contained in:
commit
f5915c4c31
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)))
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user