mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-11-01 07:51:15 +00:00
Add Mesh Lab courier and sync scenarios
This commit is contained in:
parent
39b3eaa999
commit
4ec89e0b79
@ -1,6 +1,7 @@
|
||||
package com.bitchat.android.testhook
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
@ -15,7 +16,10 @@ import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.mesh.PrivateMediaPreparation
|
||||
import com.bitchat.android.mesh.TransferProgressManager
|
||||
import com.bitchat.android.mesh.CourierDepositTier
|
||||
import com.bitchat.android.mesh.CourierStore
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.CourierEnvelope
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
@ -23,8 +27,10 @@ import com.bitchat.android.service.MeshForegroundService
|
||||
import com.bitchat.android.service.MeshServiceHolder
|
||||
import com.bitchat.android.service.TransportBridgeService
|
||||
import com.bitchat.android.services.AppStateStore
|
||||
import com.bitchat.android.services.ConversationStorageCipher
|
||||
import com.bitchat.android.ui.DataManager
|
||||
import com.bitchat.android.ui.PrivateMediaRecipientResolver
|
||||
import com.bitchat.android.ui.debug.DebugSettingsManager
|
||||
import com.bitchat.android.util.AppConstants
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@ -89,7 +95,10 @@ object TestHookDriver {
|
||||
"ptt_send" -> pttSend(context, intent)
|
||||
"ptt_recv" -> pttRecv(context, intent)
|
||||
"raw_send" -> rawSend(context, intent)
|
||||
"courier_contract" -> courierContract(context)
|
||||
"sync_request" -> syncRequest(intent.requiredString("peer"))
|
||||
"ble" -> setBle(intent.getBooleanExtra("enabled", true))
|
||||
"wifi_aware" -> setWifiAware(intent.getBooleanExtra("enabled", true))
|
||||
"inject_peers" -> injectPeers(intent.getStringExtra("peers"))
|
||||
"state" -> state(context)
|
||||
"clear_results" -> clearResults(context)
|
||||
@ -276,10 +285,11 @@ object TestHookDriver {
|
||||
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
|
||||
val contains = intent.getStringExtra("contains")
|
||||
val channel = intent.getStringExtra("channel")
|
||||
val includeExisting = intent.getBooleanExtra("include_existing", false)
|
||||
val startTime = System.currentTimeMillis()
|
||||
val mesh = mesh(context)
|
||||
val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg ->
|
||||
msg.timestamp.time >= startTime &&
|
||||
(includeExisting || msg.timestamp.time >= startTime) &&
|
||||
msg.senderPeerID != mesh.myPeerID &&
|
||||
(contains == null || msg.content.contains(contains)) &&
|
||||
(channel == null || msg.channel == channel)
|
||||
@ -653,6 +663,109 @@ object TestHookDriver {
|
||||
.put("peer", peerID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exercises the real courier wire/store implementation on a physical debug build.
|
||||
* This is intentionally local: the two-phone harness has no third identity to act as
|
||||
* both recipient and an independent courier, so transport scenarios cannot observe the
|
||||
* spray budget without weakening the assertion.
|
||||
*/
|
||||
private fun courierContract(context: Context): JSONObject {
|
||||
val filesDir = File(context.cacheDir, "testhook/courier-contract-files")
|
||||
filesDir.deleteRecursively()
|
||||
filesDir.mkdirs()
|
||||
val labContext = LabStorageContext(context, filesDir)
|
||||
val cipher = LabCipher(0x5a)
|
||||
val now = System.currentTimeMillis()
|
||||
val recipientKey = ByteArray(32) { 7 }
|
||||
val depositorKey = ByteArray(32) { 8 }
|
||||
val firstCourier = ByteArray(32) { 11 }
|
||||
val secondCourier = ByteArray(32) { 12 }
|
||||
val thirdCourier = ByteArray(32) { 13 }
|
||||
val fourthCourier = ByteArray(32) { 14 }
|
||||
val tag = CourierEnvelope.recipientTag(recipientKey, CourierEnvelope.epochDay(now))
|
||||
val store = CourierStore(labContext, cipher) { now }
|
||||
try {
|
||||
val firstEnvelope = CourierEnvelope(
|
||||
recipientTag = tag,
|
||||
expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(),
|
||||
ciphertext = ByteArray(32) { (it + 1).toByte() },
|
||||
copies = 4u,
|
||||
prekeyID = 0x11223344u
|
||||
)
|
||||
require(store.deposit(firstEnvelope, depositorKey, CourierDepositTier.VERIFIED))
|
||||
val wire = requireNotNull(firstEnvelope.encode())
|
||||
val decoded = requireNotNull(CourierEnvelope.decode(wire))
|
||||
val first = store.sprayCopiesFor(firstCourier).single()
|
||||
val second = store.sprayCopiesFor(secondCourier).single()
|
||||
val sameCourierEmpty = store.sprayCopiesFor(firstCourier).isEmpty()
|
||||
val secondCommitted = store.commitSpray(second, secondCourier)
|
||||
val firstCommitted = store.commitSpray(first, firstCourier)
|
||||
val thirdCourierEmpty = store.sprayCopiesFor(thirdCourier).isEmpty()
|
||||
|
||||
val secondEnvelope = firstEnvelope.copy(ciphertext = ByteArray(32) { (it + 65).toByte() })
|
||||
require(store.deposit(secondEnvelope, depositorKey, CourierDepositTier.VERIFIED))
|
||||
val cancelledPreview = store.sprayCopiesFor(fourthCourier).single()
|
||||
val cancelled = store.cancelSpray(cancelledPreview, fourthCourier)
|
||||
val retry = store.sprayCopiesFor(fourthCourier).single()
|
||||
val retryCommitted = store.commitSpray(retry, fourthCourier)
|
||||
|
||||
val reloaded = CourierStore(labContext, LabCipher(0x5a)) { now }
|
||||
val persistedSprayHistory = reloaded.sprayCopiesFor(fourthCourier)
|
||||
.none { it.ciphertext.contentEquals(secondEnvelope.ciphertext) }
|
||||
val remainingCopies = reloaded.sprayCopiesFor(thirdCourier)
|
||||
.firstOrNull { it.ciphertext.contentEquals(secondEnvelope.ciphertext) }
|
||||
?.copies
|
||||
?.toInt()
|
||||
reloaded.wipe()
|
||||
|
||||
require(decoded.prekeyID == firstEnvelope.prekeyID)
|
||||
require(decoded.encode()?.contentEquals(wire) == true)
|
||||
require(first.prekeyID == firstEnvelope.prekeyID)
|
||||
require(first.copies == 2u.toUByte())
|
||||
require(second.copies == 1u.toUByte())
|
||||
require(sameCourierEmpty)
|
||||
require(secondCommitted && firstCommitted && thirdCourierEmpty)
|
||||
require(cancelled && retry.copies == 2u.toUByte() && retryCommitted)
|
||||
require(persistedSprayHistory && remainingCopies == 1)
|
||||
|
||||
return ok("courier_contract")
|
||||
.put("wire_prekey_id_preserved", true)
|
||||
.put("stored_prekey_id_preserved", true)
|
||||
.put("first_reserved_copies", first.copies.toInt())
|
||||
.put("second_reserved_copies", second.copies.toInt())
|
||||
.put("same_courier_second_reservation_empty", sameCourierEmpty)
|
||||
.put("reverse_order_commits", true)
|
||||
.put("cancel_restored_eligibility", cancelled)
|
||||
.put("persisted_spray_history", persistedSprayHistory)
|
||||
.put("remaining_copies_after_restart", remainingCopies)
|
||||
} finally {
|
||||
store.wipe()
|
||||
filesDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncRequest(peerID: String): JSONObject {
|
||||
val manager = MeshServiceHolder.sharedGossipSyncManager
|
||||
?: return err("sync_request", "gossip sync manager is unavailable")
|
||||
manager.scheduleInitialSyncToPeer(peerID, 0)
|
||||
return ok("sync_request").put("peer", peerID)
|
||||
}
|
||||
|
||||
private class LabStorageContext(base: Context, private val labFilesDir: File) : ContextWrapper(base) {
|
||||
override fun getApplicationContext(): Context = this
|
||||
override fun getFilesDir(): File = labFilesDir
|
||||
}
|
||||
|
||||
private class LabCipher(private val mask: Int) : ConversationStorageCipher {
|
||||
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray =
|
||||
plaintext.map { (it.toInt() xor mask).toByte() }.toByteArray()
|
||||
|
||||
override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray =
|
||||
encrypt(envelope, associatedData)
|
||||
|
||||
override fun destroyKey() = Unit
|
||||
}
|
||||
|
||||
// MARK: - Transport / state
|
||||
|
||||
private fun setBle(enabled: Boolean): JSONObject {
|
||||
@ -661,6 +774,14 @@ object TestHookDriver {
|
||||
return ok("ble").put("enabled", enabled)
|
||||
}
|
||||
|
||||
private fun setWifiAware(enabled: Boolean): JSONObject {
|
||||
val previous = com.bitchat.android.wifiaware.WifiAwareController.enabled.value
|
||||
DebugSettingsManager.getInstance().setWifiAwareEnabled(enabled)
|
||||
return ok("wifi_aware")
|
||||
.put("enabled", enabled)
|
||||
.put("previous_enabled", previous)
|
||||
}
|
||||
|
||||
private fun state(context: Context): JSONObject {
|
||||
val mesh = mesh(context)
|
||||
val peersJson = peerInfosJson(mesh, AppStateStore.peers.value)
|
||||
|
||||
@ -298,6 +298,8 @@ python3 tools/release_gate/mesh_lab.py scenario all \
|
||||
| `dm` | Noise handshake both ways, encrypted DM round trips with content match |
|
||||
| `favorite_verification` | favorite signal, orange-outline/filled mutual state, and peer fingerprint verification |
|
||||
| `broadcast` | public mesh message A→B |
|
||||
| `sync_recovery` | message missed while B's BLE and Wi-Fi Aware transports are disabled is recovered by the gossip sync request after reconnection |
|
||||
| `courier_contract` | on-device courier wire/store contract: prekey TLV retention, split reservations, cancellation, reverse commits, and persisted spray history |
|
||||
| `ptt_dm` | Noise-encrypted 440 Hz PTT in both directions; asserts real-time capture, zero sequence gaps, decoded PCM duration/energy/continuity, and finalized-note absorption |
|
||||
| `ptt_broadcast` | signed public 440 Hz PTT with the same bidirectional packet and decoded-audio quality assertions |
|
||||
| `file` | 1 KB broadcast file, receiver SHA-256 matches fixture |
|
||||
@ -328,8 +330,12 @@ See `TestHookDriver.kt` for the full command set (`ping`, `start`, `stop`,
|
||||
`whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`, `session`,
|
||||
`announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `favorite_set`,
|
||||
`favorite_status`, `verification_set`, `verification_status`, `file_send`,
|
||||
`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `ble`, `state`,
|
||||
`clear_results`).
|
||||
`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `courier_contract`,
|
||||
`sync_request`, `ble`, `wifi_aware`, `state`, `clear_results`).
|
||||
|
||||
`msg_recv` accepts `include_existing=true` for deterministic backfill checks when
|
||||
the packet's original timestamp predates the receive hook; use a unique synthetic
|
||||
content token with this mode.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
@ -532,6 +532,79 @@ def scenario_broadcast(a: Device, b: Device) -> dict:
|
||||
return {"send": send_result, "recv": recv_result}
|
||||
|
||||
|
||||
def scenario_sync_recovery(a: Device, b: Device) -> dict:
|
||||
"""Miss a public message while B's transports are offline, then receive it via gossip sync."""
|
||||
id_a = whoami(a)["peer_id"]
|
||||
id_b = whoami(b)["peer_id"]
|
||||
token = f"sync-{uuid.uuid4().hex[:8]}"
|
||||
b.cmd_ok("ble", enabled=False)
|
||||
wifi_before = b.cmd_ok("wifi_aware", enabled=False)["previous_enabled"]
|
||||
try:
|
||||
send_result = a.cmd_ok("broadcast_msg", 30_000, content=f"sync recovery {token}")
|
||||
# The hook returns after dispatching the send coroutine. Give the sender's
|
||||
# gossip observer time to retain the packet before taking B offline.
|
||||
time.sleep(2)
|
||||
offline_result = b.cmd("msg_recv", 5_000, contains=token, include_existing=True)
|
||||
if offline_result.get("status") == "ok":
|
||||
raise MeshLabError(f"message was delivered while receiver transports were disabled: {offline_result}")
|
||||
|
||||
# Restart B so the initial sync request is scheduled from a fresh peer
|
||||
# observation instead of relying on an already-cached relationship.
|
||||
b.force_stop()
|
||||
b.wake()
|
||||
b.launch()
|
||||
b.cmd_ok("start")
|
||||
b.cmd_ok("ble", enabled=True)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||
recv = pool.submit(
|
||||
b.cmd_ok,
|
||||
"msg_recv",
|
||||
180_000,
|
||||
contains=token,
|
||||
include_existing=True,
|
||||
)
|
||||
time.sleep(2)
|
||||
ensure_direct_link(a, b, id_a, id_b)
|
||||
b.cmd_ok("sync_request", peer=id_a)
|
||||
recv_result = recv.result()
|
||||
assert recv_result["from"] == id_a, recv_result
|
||||
return {
|
||||
"send": send_result,
|
||||
"offline_delivery": {"status": offline_result.get("status", "missing")},
|
||||
"synced_recv": recv_result,
|
||||
}
|
||||
finally:
|
||||
# Leave the pair usable if an assertion or timeout interrupts the recovery.
|
||||
try:
|
||||
b.cmd_ok("ble", enabled=True)
|
||||
except MeshLabError:
|
||||
pass
|
||||
try:
|
||||
b.cmd_ok("wifi_aware", enabled=wifi_before)
|
||||
except MeshLabError:
|
||||
pass
|
||||
|
||||
|
||||
def scenario_courier_contract(a: Device, b: Device) -> dict:
|
||||
"""Run the real courier wire/store contract on device A's debug build."""
|
||||
result = a.cmd_ok("courier_contract", 60_000)
|
||||
expected = {
|
||||
"wire_prekey_id_preserved": True,
|
||||
"stored_prekey_id_preserved": True,
|
||||
"first_reserved_copies": 2,
|
||||
"second_reserved_copies": 1,
|
||||
"same_courier_second_reservation_empty": True,
|
||||
"reverse_order_commits": True,
|
||||
"cancel_restored_eligibility": True,
|
||||
"persisted_spray_history": True,
|
||||
"remaining_copies_after_restart": 1,
|
||||
}
|
||||
for field, value in expected.items():
|
||||
if result.get(field) != value:
|
||||
raise MeshLabError(f"courier contract field {field}={result.get(field)!r}, expected {value!r}")
|
||||
return result
|
||||
|
||||
|
||||
def _ptt_one_way(
|
||||
sender: Device,
|
||||
receiver: Device,
|
||||
@ -869,6 +942,8 @@ SCENARIOS = {
|
||||
"dm": scenario_dm,
|
||||
"favorite_verification": scenario_favorite_verification,
|
||||
"broadcast": scenario_broadcast,
|
||||
"sync_recovery": scenario_sync_recovery,
|
||||
"courier_contract": scenario_courier_contract,
|
||||
"ptt_dm": scenario_ptt_dm,
|
||||
"ptt_broadcast": scenario_ptt_broadcast,
|
||||
# Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user