mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge 6a14ff7cfceaf156c8696ed08001bb88ac04cecd into c127eb83ab94c069c32d37530d2faecd381cd2a8
This commit is contained in:
commit
3d59fe8581
@ -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,11 @@ 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.services.MessageRouter
|
||||
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
|
||||
@ -69,6 +76,13 @@ object TestHookDriver {
|
||||
"announce" -> announce(context)
|
||||
"broadcast_msg" -> broadcastMsg(context, intent.requiredString("content"), intent.getStringExtra("channel"))
|
||||
"dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id"))
|
||||
"router_private_send" -> routerPrivateSend(
|
||||
context,
|
||||
intent.requiredString("peer"),
|
||||
intent.requiredString("content"),
|
||||
intent.getStringExtra("msg_id")
|
||||
)
|
||||
"router_resume" -> routerResume(context)
|
||||
"dm_recv" -> dmRecv(context, intent)
|
||||
"msg_recv" -> msgRecv(context, intent)
|
||||
"favorite_set" -> favoriteSet(
|
||||
@ -89,7 +103,11 @@ object TestHookDriver {
|
||||
"ptt_send" -> pttSend(context, intent)
|
||||
"ptt_recv" -> pttRecv(context, intent)
|
||||
"raw_send" -> rawSend(context, intent)
|
||||
"courier_contract" -> courierContract(context)
|
||||
"cache_peer_identity" -> cachePeerIdentity(context, intent.requiredString("peer"))
|
||||
"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)
|
||||
@ -243,6 +261,27 @@ object TestHookDriver {
|
||||
return ok("dm_send").put("peer", peerID).put("msg_id", id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the same durable outbox/router path used by private-chat sends instead of the
|
||||
* lower-level direct mesh API that backs `dm_send`.
|
||||
*/
|
||||
private fun routerPrivateSend(context: Context, peerID: String, content: String, msgID: String?): JSONObject {
|
||||
val mesh = mesh(context)
|
||||
val nickname = mesh.getPeerNicknames()[peerID] ?: peerID
|
||||
val id = msgID ?: "testhook-router-${System.currentTimeMillis()}"
|
||||
val route = MessageRouter.getInstance(context, mesh).sendPrivate(content, peerID, nickname, id)
|
||||
return ok("router_private_send")
|
||||
.put("peer", peerID)
|
||||
.put("msg_id", id)
|
||||
.put("route", route.name)
|
||||
}
|
||||
|
||||
/** Recreates the durable router after a process restart without adding another message. */
|
||||
private fun routerResume(context: Context): JSONObject {
|
||||
MessageRouter.getInstance(context, mesh(context))
|
||||
return ok("router_resume")
|
||||
}
|
||||
|
||||
private suspend fun dmRecv(context: Context, intent: Intent): JSONObject {
|
||||
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
|
||||
val fromPeer = intent.getStringExtra("peer")
|
||||
@ -276,10 +315,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 +693,128 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the currently authenticated peer key exactly as the normal UI session observer does. */
|
||||
private fun cachePeerIdentity(context: Context, peerID: String): JSONObject {
|
||||
val info = mesh(context).getPeerInfo(peerID)
|
||||
?: return err("cache_peer_identity", "peer is not known")
|
||||
val noiseKey = info.noisePublicKey
|
||||
?: return err("cache_peer_identity", "peer Noise key is unavailable")
|
||||
val noiseKeyHex = noiseKey.toHex()
|
||||
val identityManager = SecureIdentityStateManager(context)
|
||||
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
|
||||
identityManager.cacheNoiseFingerprint(noiseKeyHex, com.bitchat.android.services.ContactIdentityResolver.fingerprintHex(noiseKey))
|
||||
info.nickname.takeIf { it.isNotBlank() }?.let { nickname ->
|
||||
identityManager.cacheFingerprintNickname(
|
||||
com.bitchat.android.services.ContactIdentityResolver.fingerprintHex(noiseKey),
|
||||
nickname
|
||||
)
|
||||
}
|
||||
return ok("cache_peer_identity").put("peer", peerID)
|
||||
}
|
||||
|
||||
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 +823,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)
|
||||
|
||||
@ -126,6 +126,12 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="bitchat" android:host="verify" />
|
||||
<data android:scheme="bitchat" android:host="geohash" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
@ -73,6 +73,20 @@ class BitchatApplication : Application() {
|
||||
// Initialize mesh service preferences
|
||||
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
|
||||
|
||||
// Bridge policy is process-scoped so rendezvous and courier delivery
|
||||
// continue while the activity is backgrounded.
|
||||
try {
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.initialize(this)
|
||||
com.bitchat.android.mesh.BridgeMeshPort.install(
|
||||
com.bitchat.android.services.bridge.MeshBridgeService
|
||||
)
|
||||
} 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) { }
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
212
app/src/main/java/com/bitchat/android/board/BoardManager.kt
Normal file
212
app/src/main/java/com/bitchat/android/board/BoardManager.kt
Normal file
@ -0,0 +1,212 @@
|
||||
package com.bitchat.android.board
|
||||
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Creates and removes signed board entries while keeping UI-only state out of
|
||||
* the transport layer.
|
||||
*/
|
||||
class BoardManager(
|
||||
private val store: BoardStore,
|
||||
private val scope: CoroutineScope,
|
||||
private val meshProvider: () -> MeshService,
|
||||
private val geoIdentityProvider: (String) -> BoardSigningIdentity? = { null },
|
||||
private val notesManager: LocationNotesManager = LocationNotesManager.getInstance(),
|
||||
private val nowMs: () -> ULong = { System.currentTimeMillis().coerceAtLeast(0).toULong() },
|
||||
private val random: SecureRandom = SecureRandom(),
|
||||
private val onUrgentPosts: (geohash: String, posts: List<BoardPostPacket>) -> Unit = { _, _ -> }
|
||||
) {
|
||||
private val _unseenScopes = MutableStateFlow<Set<String>>(emptySet())
|
||||
private val bridgedEventIDs = mutableMapOf<String, String>()
|
||||
private val handledPostIDs = mutableSetOf<String>()
|
||||
private val pendingUrgent = mutableMapOf<String, MutableList<BoardPostPacket>>()
|
||||
private var alertFlushJob: Job? = null
|
||||
|
||||
val posts: StateFlow<List<BoardPostPacket>> = store.postsSnapshot
|
||||
val unseenScopes: StateFlow<Set<String>> = _unseenScopes.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
store.postArrivals.collect { post ->
|
||||
handleArrival(post)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun posts(geohash: String): List<BoardPostPacket> = store.posts(geohash.lowercase())
|
||||
|
||||
fun isOwnPost(post: BoardPostPacket): Boolean =
|
||||
signingIdentityFor(post.geohash)
|
||||
?.publicKey
|
||||
?.contentEquals(post.authorSigningKey) == true
|
||||
|
||||
fun createPost(
|
||||
content: String,
|
||||
geohash: String,
|
||||
nickname: String?,
|
||||
urgent: Boolean,
|
||||
expiryDays: Int
|
||||
): Boolean {
|
||||
val trimmed = content.trim()
|
||||
val contentBytes = trimmed.toByteArray(Charsets.UTF_8)
|
||||
val normalizedGeohash = geohash.lowercase()
|
||||
if (contentBytes.size !in 1..BoardWireConstants.CONTENT_MAX_BYTES ||
|
||||
expiryDays !in 1..7 ||
|
||||
!isValidGeohash(normalizedGeohash)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
val mesh = meshProvider()
|
||||
val identity = signingIdentityFor(normalizedGeohash) ?: return false
|
||||
val signingKey = identity.publicKey.copyOf()
|
||||
val postID = ByteArray(BoardWireConstants.POST_ID_LENGTH).also(random::nextBytes)
|
||||
val createdAt = nowMs()
|
||||
val expiresAt = createdAt + expiryDays.toULong() * DAY_MS
|
||||
val authorNickname = truncateUtf8(nickname.orEmpty(), BoardWireConstants.NICKNAME_MAX_BYTES)
|
||||
val flags: UByte = if (urgent) BoardPostPacket.URGENT_FLAG else 0u
|
||||
val signingBytes = BoardPostPacket.signingBytes(
|
||||
postID = postID,
|
||||
geohash = normalizedGeohash,
|
||||
content = trimmed,
|
||||
authorSigningKey = signingKey,
|
||||
authorNickname = authorNickname,
|
||||
createdAt = createdAt,
|
||||
expiresAt = expiresAt,
|
||||
flags = flags
|
||||
)
|
||||
val signature = identity.sign(signingBytes)
|
||||
?.takeIf { it.size == BoardWireConstants.SIGNATURE_LENGTH }
|
||||
?: return false
|
||||
val post = BoardPostPacket(
|
||||
postID = postID,
|
||||
geohash = normalizedGeohash,
|
||||
content = trimmed,
|
||||
authorSigningKey = signingKey,
|
||||
authorNickname = authorNickname,
|
||||
createdAt = createdAt,
|
||||
expiresAt = expiresAt,
|
||||
flags = flags,
|
||||
signature = signature
|
||||
)
|
||||
mesh.sendBoardPayload(BoardWireCodec.encode(BoardWire.Post(post)))
|
||||
|
||||
if (normalizedGeohash.isNotEmpty()) {
|
||||
notesManager.publishBoardBridge(
|
||||
content = trimmed,
|
||||
geohash = normalizedGeohash,
|
||||
nickname = authorNickname,
|
||||
expiresAtSeconds = (expiresAt / 1_000u).coerceAtMost(Int.MAX_VALUE.toULong()).toInt(),
|
||||
urgent = urgent
|
||||
) { eventID ->
|
||||
synchronized(bridgedEventIDs) {
|
||||
bridgedEventIDs[post.identityKey()] = eventID
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun deletePost(post: BoardPostPacket): Boolean {
|
||||
val identity = signingIdentityFor(post.geohash)
|
||||
?.takeIf { it.publicKey.contentEquals(post.authorSigningKey) }
|
||||
?: return false
|
||||
val deletedAt = nowMs()
|
||||
val signature = identity.sign(
|
||||
BoardTombstonePacket.signingBytes(post.postID, deletedAt)
|
||||
)?.takeIf { it.size == BoardWireConstants.SIGNATURE_LENGTH } ?: return false
|
||||
val tombstone = BoardTombstonePacket(
|
||||
postID = post.postID,
|
||||
authorSigningKey = post.authorSigningKey,
|
||||
deletedAt = deletedAt,
|
||||
signature = signature
|
||||
)
|
||||
meshProvider().sendBoardPayload(BoardWireCodec.encode(BoardWire.Tombstone(tombstone)))
|
||||
|
||||
if (post.geohash.isNotEmpty()) {
|
||||
val eventID = synchronized(bridgedEventIDs) {
|
||||
bridgedEventIDs.remove(post.identityKey())
|
||||
}
|
||||
if (eventID != null) notesManager.deleteEvent(eventID, post.geohash)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun markSeen(scopes: Set<String>) {
|
||||
if (scopes.isEmpty()) return
|
||||
_unseenScopes.value = _unseenScopes.value - scopes
|
||||
}
|
||||
|
||||
fun clearTransientState() {
|
||||
_unseenScopes.value = emptySet()
|
||||
synchronized(bridgedEventIDs) { bridgedEventIDs.clear() }
|
||||
handledPostIDs.clear()
|
||||
pendingUrgent.clear()
|
||||
alertFlushJob?.cancel()
|
||||
alertFlushJob = null
|
||||
}
|
||||
|
||||
private fun handleArrival(post: BoardPostPacket) {
|
||||
if (!handledPostIDs.add(post.identityKey()) || isOwnPost(post)) return
|
||||
_unseenScopes.value = _unseenScopes.value + post.geohash
|
||||
val age = nowMs().toLong() -
|
||||
post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong()
|
||||
if (!post.isUrgent || age > URGENT_RECENCY_MS) return
|
||||
|
||||
pendingUrgent.getOrPut(post.geohash) { mutableListOf() } += post
|
||||
if (alertFlushJob == null) {
|
||||
alertFlushJob = scope.launch {
|
||||
delay(ALERT_COLLAPSE_MS)
|
||||
val pending = pendingUrgent.mapValues { it.value.toList() }
|
||||
pendingUrgent.clear()
|
||||
alertFlushJob = null
|
||||
pending.forEach { (geohash, posts) -> onUrgentPosts(geohash, posts) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isValidGeohash(value: String): Boolean =
|
||||
value.isEmpty() ||
|
||||
(value.length <= BoardWireConstants.GEOHASH_MAX_LENGTH &&
|
||||
value.all { it in BoardWireConstants.GEOHASH_ALPHABET })
|
||||
|
||||
private fun truncateUtf8(value: String, maxBytes: Int): String {
|
||||
var result = value
|
||||
while (result.toByteArray(Charsets.UTF_8).size > maxBytes && result.isNotEmpty()) {
|
||||
result = result.dropLast(1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun BoardPostPacket.identityKey(): String =
|
||||
"${authorSigningKey.toHex()}:${postID.toHex()}"
|
||||
|
||||
private fun signingIdentityFor(geohash: String): BoardSigningIdentity? {
|
||||
if (geohash.isNotEmpty()) {
|
||||
// Never fall back to the stable mesh identity for a location scope.
|
||||
return runCatching { geoIdentityProvider(geohash) }.getOrNull()
|
||||
}
|
||||
val mesh = meshProvider()
|
||||
val publicKey = mesh.getSigningPublicKey()
|
||||
?.takeIf { it.size == BoardWireConstants.SIGNING_KEY_LENGTH }
|
||||
?: return null
|
||||
return BoardSigningIdentity(publicKey, mesh::signData)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DAY_MS: ULong = 86_400_000uL
|
||||
const val URGENT_RECENCY_MS = 30 * 60 * 1_000L
|
||||
const val ALERT_COLLAPSE_MS = 4_000L
|
||||
}
|
||||
}
|
||||
402
app/src/main/java/com/bitchat/android/board/BoardPackets.kt
Normal file
402
app/src/main/java/com/bitchat/android/board/BoardPackets.kt
Normal file
@ -0,0 +1,402 @@
|
||||
package com.bitchat.android.board
|
||||
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.security.MessageDigest
|
||||
|
||||
object BoardWireConstants {
|
||||
const val POST_ID_LENGTH = 16
|
||||
const val SIGNING_KEY_LENGTH = 32
|
||||
const val SIGNATURE_LENGTH = 64
|
||||
const val TRANSPORT_SENDER_ID_LENGTH = 8
|
||||
const val CONTENT_MAX_BYTES = 512
|
||||
const val NICKNAME_MAX_BYTES = 64
|
||||
const val GEOHASH_MAX_LENGTH = 12
|
||||
const val MAX_LIFETIME_MS: ULong = 604_800_000uL
|
||||
const val POST_SIGNING_CONTEXT = "bitchat-board-v1"
|
||||
const val TOMBSTONE_SIGNING_CONTEXT = "bitchat-board-del-v1"
|
||||
const val GEOHASH_ALPHABET = "0123456789bcdefghjkmnpqrstuvwxyz"
|
||||
}
|
||||
|
||||
class BoardPostPacket(
|
||||
val postID: ByteArray,
|
||||
val geohash: String,
|
||||
val content: String,
|
||||
val authorSigningKey: ByteArray,
|
||||
val authorNickname: String,
|
||||
val createdAt: ULong,
|
||||
val expiresAt: ULong,
|
||||
val flags: UByte,
|
||||
val signature: ByteArray
|
||||
) {
|
||||
val isUrgent: Boolean
|
||||
get() = (flags.toInt() and URGENT_FLAG.toInt()) != 0
|
||||
|
||||
val signingBytes: ByteArray
|
||||
get() = signingBytes(
|
||||
postID = postID,
|
||||
geohash = geohash,
|
||||
content = content,
|
||||
authorSigningKey = authorSigningKey,
|
||||
authorNickname = authorNickname,
|
||||
createdAt = createdAt,
|
||||
expiresAt = expiresAt,
|
||||
flags = flags
|
||||
)
|
||||
|
||||
fun verifySignature(): Boolean =
|
||||
BoardWireCodec.verify(signature, signingBytes, authorSigningKey)
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is BoardPostPacket &&
|
||||
postID.contentEquals(other.postID) &&
|
||||
geohash == other.geohash &&
|
||||
content == other.content &&
|
||||
authorSigningKey.contentEquals(other.authorSigningKey) &&
|
||||
authorNickname == other.authorNickname &&
|
||||
createdAt == other.createdAt &&
|
||||
expiresAt == other.expiresAt &&
|
||||
flags == other.flags &&
|
||||
signature.contentEquals(other.signature)
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = postID.contentHashCode()
|
||||
result = 31 * result + geohash.hashCode()
|
||||
result = 31 * result + content.hashCode()
|
||||
result = 31 * result + authorSigningKey.contentHashCode()
|
||||
result = 31 * result + authorNickname.hashCode()
|
||||
result = 31 * result + createdAt.hashCode()
|
||||
result = 31 * result + expiresAt.hashCode()
|
||||
result = 31 * result + flags.hashCode()
|
||||
return 31 * result + signature.contentHashCode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val URGENT_FLAG: UByte = 0x01u
|
||||
|
||||
fun signingBytes(
|
||||
postID: ByteArray,
|
||||
geohash: String,
|
||||
content: String,
|
||||
authorSigningKey: ByteArray,
|
||||
authorNickname: String,
|
||||
createdAt: ULong,
|
||||
expiresAt: ULong,
|
||||
flags: UByte
|
||||
): ByteArray = ByteArrayOutputStream().apply {
|
||||
appendContext(BoardWireConstants.POST_SIGNING_CONTEXT)
|
||||
write(postID)
|
||||
appendLengthPrefixed(geohash.toByteArray(Charsets.UTF_8))
|
||||
appendLengthPrefixed(content.toByteArray(Charsets.UTF_8))
|
||||
write(authorSigningKey)
|
||||
appendLengthPrefixed(authorNickname.toByteArray(Charsets.UTF_8))
|
||||
appendULong(createdAt)
|
||||
appendULong(expiresAt)
|
||||
write(flags.toInt())
|
||||
}.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
class BoardTombstonePacket(
|
||||
val postID: ByteArray,
|
||||
val authorSigningKey: ByteArray,
|
||||
val deletedAt: ULong,
|
||||
val signature: ByteArray
|
||||
) {
|
||||
val signingBytes: ByteArray
|
||||
get() = signingBytes(postID, deletedAt)
|
||||
|
||||
fun verifySignature(): Boolean =
|
||||
BoardWireCodec.verify(signature, signingBytes, authorSigningKey)
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is BoardTombstonePacket &&
|
||||
postID.contentEquals(other.postID) &&
|
||||
authorSigningKey.contentEquals(other.authorSigningKey) &&
|
||||
deletedAt == other.deletedAt &&
|
||||
signature.contentEquals(other.signature)
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = postID.contentHashCode()
|
||||
result = 31 * result + authorSigningKey.contentHashCode()
|
||||
result = 31 * result + deletedAt.hashCode()
|
||||
return 31 * result + signature.contentHashCode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun signingBytes(postID: ByteArray, deletedAt: ULong): ByteArray =
|
||||
ByteArrayOutputStream().apply {
|
||||
appendContext(BoardWireConstants.TOMBSTONE_SIGNING_CONTEXT)
|
||||
write(postID)
|
||||
appendULong(deletedAt)
|
||||
}.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface BoardWire {
|
||||
data class Post(val packet: BoardPostPacket) : BoardWire
|
||||
data class Tombstone(val packet: BoardTombstonePacket) : BoardWire
|
||||
|
||||
fun verifySignature(): Boolean = when (this) {
|
||||
is Post -> packet.verifySignature()
|
||||
is Tombstone -> packet.verifySignature()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Board payloads authenticate their embedded author key, so their outer mesh
|
||||
* sender must not expose the device's stable peer ID. The pseudonym remains
|
||||
* stable only for one board signing identity.
|
||||
*/
|
||||
fun BoardWire.transportSenderID(): ByteArray {
|
||||
val authorKey = when (this) {
|
||||
is BoardWire.Post -> packet.authorSigningKey
|
||||
is BoardWire.Tombstone -> packet.authorSigningKey
|
||||
}
|
||||
return MessageDigest.getInstance("SHA-256")
|
||||
.digest(authorKey)
|
||||
.copyOf(BoardWireConstants.TRANSPORT_SENDER_ID_LENGTH)
|
||||
}
|
||||
|
||||
object BoardWireCodec {
|
||||
private const val TLV_KIND = 0x01
|
||||
private const val TLV_POST_ID = 0x02
|
||||
private const val TLV_GEOHASH = 0x03
|
||||
private const val TLV_CONTENT = 0x04
|
||||
private const val TLV_AUTHOR_SIGNING_KEY = 0x05
|
||||
private const val TLV_AUTHOR_NICKNAME = 0x06
|
||||
private const val TLV_CREATED_AT = 0x07
|
||||
private const val TLV_EXPIRES_AT = 0x08
|
||||
private const val TLV_FLAGS = 0x09
|
||||
private const val TLV_SIGNATURE = 0x0A
|
||||
private const val TLV_DELETED_AT = 0x0B
|
||||
private const val KIND_POST = 0x01
|
||||
private const val KIND_TOMBSTONE = 0x02
|
||||
|
||||
fun encode(wire: BoardWire): ByteArray = ByteArrayOutputStream().apply {
|
||||
when (wire) {
|
||||
is BoardWire.Post -> with(wire.packet) {
|
||||
appendTlv(TLV_KIND, byteArrayOf(KIND_POST.toByte()))
|
||||
appendTlv(TLV_POST_ID, postID)
|
||||
appendTlv(TLV_GEOHASH, geohash.toByteArray(Charsets.UTF_8))
|
||||
appendTlv(TLV_CONTENT, content.toByteArray(Charsets.UTF_8))
|
||||
appendTlv(TLV_AUTHOR_SIGNING_KEY, authorSigningKey)
|
||||
appendTlv(TLV_AUTHOR_NICKNAME, authorNickname.toByteArray(Charsets.UTF_8))
|
||||
appendTlv(TLV_CREATED_AT, createdAt.toBigEndianBytes())
|
||||
appendTlv(TLV_EXPIRES_AT, expiresAt.toBigEndianBytes())
|
||||
appendTlv(TLV_FLAGS, byteArrayOf(flags.toByte()))
|
||||
appendTlv(TLV_SIGNATURE, signature)
|
||||
}
|
||||
|
||||
is BoardWire.Tombstone -> with(wire.packet) {
|
||||
appendTlv(TLV_KIND, byteArrayOf(KIND_TOMBSTONE.toByte()))
|
||||
appendTlv(TLV_POST_ID, postID)
|
||||
appendTlv(TLV_AUTHOR_SIGNING_KEY, authorSigningKey)
|
||||
appendTlv(TLV_DELETED_AT, deletedAt.toBigEndianBytes())
|
||||
appendTlv(TLV_SIGNATURE, signature)
|
||||
}
|
||||
}
|
||||
}.toByteArray()
|
||||
|
||||
fun decode(data: ByteArray): BoardWire? {
|
||||
var offset = 0
|
||||
var kind: Int? = null
|
||||
var postID: ByteArray? = null
|
||||
var geohash: String? = null
|
||||
var content: String? = null
|
||||
var contentBytes = 0
|
||||
var authorSigningKey: ByteArray? = null
|
||||
var authorNickname: String? = null
|
||||
var nicknameBytes = 0
|
||||
var createdAt: ULong? = null
|
||||
var expiresAt: ULong? = null
|
||||
var flags: UByte? = null
|
||||
var signature: ByteArray? = null
|
||||
var deletedAt: ULong? = null
|
||||
|
||||
while (offset + 3 <= data.size) {
|
||||
val type = data[offset].toInt() and 0xFF
|
||||
offset += 1
|
||||
val length =
|
||||
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
|
||||
offset += 2
|
||||
if (length > data.size - offset) return null
|
||||
val value = data.copyOfRange(offset, offset + length)
|
||||
offset += length
|
||||
|
||||
when (type) {
|
||||
TLV_KIND -> {
|
||||
if (value.size != 1) return null
|
||||
kind = value[0].toInt() and 0xFF
|
||||
}
|
||||
TLV_POST_ID -> {
|
||||
if (value.size != BoardWireConstants.POST_ID_LENGTH) return null
|
||||
postID = value
|
||||
}
|
||||
TLV_GEOHASH -> {
|
||||
if (value.size > BoardWireConstants.GEOHASH_MAX_LENGTH) return null
|
||||
geohash = decodeUtf8(value) ?: return null
|
||||
}
|
||||
TLV_CONTENT -> {
|
||||
if (value.size > BoardWireConstants.CONTENT_MAX_BYTES) return null
|
||||
contentBytes = value.size
|
||||
content = decodeUtf8(value) ?: return null
|
||||
}
|
||||
TLV_AUTHOR_SIGNING_KEY -> {
|
||||
if (value.size != BoardWireConstants.SIGNING_KEY_LENGTH) return null
|
||||
authorSigningKey = value
|
||||
}
|
||||
TLV_AUTHOR_NICKNAME -> {
|
||||
if (value.size > BoardWireConstants.NICKNAME_MAX_BYTES) return null
|
||||
nicknameBytes = value.size
|
||||
authorNickname = decodeUtf8(value) ?: return null
|
||||
}
|
||||
TLV_CREATED_AT -> createdAt = value.toULongBigEndian() ?: return null
|
||||
TLV_EXPIRES_AT -> expiresAt = value.toULongBigEndian() ?: return null
|
||||
TLV_FLAGS -> {
|
||||
if (value.size != 1) return null
|
||||
flags = value[0].toUByte()
|
||||
}
|
||||
TLV_SIGNATURE -> {
|
||||
if (value.size != BoardWireConstants.SIGNATURE_LENGTH) return null
|
||||
signature = value
|
||||
}
|
||||
TLV_DELETED_AT -> deletedAt = value.toULongBigEndian() ?: return null
|
||||
}
|
||||
}
|
||||
|
||||
val requiredPostID = postID ?: return null
|
||||
val requiredKey = authorSigningKey ?: return null
|
||||
val requiredSignature = signature ?: return null
|
||||
return when (kind) {
|
||||
KIND_POST -> {
|
||||
val requiredGeohash = geohash ?: return null
|
||||
val requiredContent = content ?: return null
|
||||
val requiredNickname = authorNickname ?: return null
|
||||
val requiredCreatedAt = createdAt ?: return null
|
||||
val requiredExpiresAt = expiresAt ?: return null
|
||||
val requiredFlags = flags ?: return null
|
||||
if (contentBytes !in 1..BoardWireConstants.CONTENT_MAX_BYTES) return null
|
||||
if (nicknameBytes > BoardWireConstants.NICKNAME_MAX_BYTES) return null
|
||||
if (!isValidGeohash(requiredGeohash)) return null
|
||||
if (requiredExpiresAt <= requiredCreatedAt) return null
|
||||
if (requiredExpiresAt - requiredCreatedAt > BoardWireConstants.MAX_LIFETIME_MS) return null
|
||||
BoardWire.Post(
|
||||
BoardPostPacket(
|
||||
postID = requiredPostID,
|
||||
geohash = requiredGeohash,
|
||||
content = requiredContent,
|
||||
authorSigningKey = requiredKey,
|
||||
authorNickname = requiredNickname,
|
||||
createdAt = requiredCreatedAt,
|
||||
expiresAt = requiredExpiresAt,
|
||||
flags = requiredFlags,
|
||||
signature = requiredSignature
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
KIND_TOMBSTONE -> BoardWire.Tombstone(
|
||||
BoardTombstonePacket(
|
||||
postID = requiredPostID,
|
||||
authorSigningKey = requiredKey,
|
||||
deletedAt = deletedAt ?: return null,
|
||||
signature = requiredSignature
|
||||
)
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun urgentFlag(data: ByteArray): Boolean {
|
||||
var offset = 0
|
||||
while (offset + 3 <= data.size) {
|
||||
val type = data[offset].toInt() and 0xFF
|
||||
offset += 1
|
||||
val length =
|
||||
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
|
||||
offset += 2
|
||||
if (length > data.size - offset) return false
|
||||
if (type == TLV_FLAGS && length == 1) {
|
||||
return (data[offset].toInt() and BoardPostPacket.URGENT_FLAG.toInt()) != 0
|
||||
}
|
||||
offset += length
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
internal fun verify(signature: ByteArray, message: ByteArray, publicKey: ByteArray): Boolean =
|
||||
try {
|
||||
if (signature.size != BoardWireConstants.SIGNATURE_LENGTH ||
|
||||
publicKey.size != BoardWireConstants.SIGNING_KEY_LENGTH
|
||||
) {
|
||||
false
|
||||
} else {
|
||||
Ed25519Signer().run {
|
||||
init(false, Ed25519PublicKeyParameters(publicKey, 0))
|
||||
update(message, 0, message.size)
|
||||
verifySignature(signature)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
private fun isValidGeohash(value: String): Boolean =
|
||||
value.isEmpty() ||
|
||||
(value.length <= BoardWireConstants.GEOHASH_MAX_LENGTH &&
|
||||
value.all { it in BoardWireConstants.GEOHASH_ALPHABET })
|
||||
|
||||
private fun decodeUtf8(value: ByteArray): String? =
|
||||
try {
|
||||
Charsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(value))
|
||||
.toString()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArrayOutputStream.appendTlv(type: Int, value: ByteArray) {
|
||||
require(value.size <= 0xFFFF)
|
||||
write(type)
|
||||
write((value.size ushr 8) and 0xFF)
|
||||
write(value.size and 0xFF)
|
||||
write(value)
|
||||
}
|
||||
|
||||
private fun ByteArrayOutputStream.appendContext(context: String) {
|
||||
val value = context.toByteArray(Charsets.UTF_8).take(255).toByteArray()
|
||||
write(value.size)
|
||||
write(value)
|
||||
}
|
||||
|
||||
private fun ByteArrayOutputStream.appendLengthPrefixed(value: ByteArray) {
|
||||
val limited = value.take(0xFFFF).toByteArray()
|
||||
write((limited.size ushr 8) and 0xFF)
|
||||
write(limited.size and 0xFF)
|
||||
write(limited)
|
||||
}
|
||||
|
||||
private fun ByteArrayOutputStream.appendULong(value: ULong) {
|
||||
write(value.toBigEndianBytes())
|
||||
}
|
||||
|
||||
private fun ULong.toBigEndianBytes(): ByteArray =
|
||||
ByteArray(8) { index -> (this shr ((7 - index) * 8)).toByte() }
|
||||
|
||||
private fun ByteArray.toULongBigEndian(): ULong? {
|
||||
if (size != 8) return null
|
||||
var value = 0uL
|
||||
for (byte in this) {
|
||||
value = (value shl 8) or (byte.toULong() and 0xFFuL)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package com.bitchat.android.board
|
||||
|
||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Signing identity used by board payloads.
|
||||
*
|
||||
* Location boards use a key derived from the already-unlinkable per-geohash
|
||||
* Nostr secret. Domain separation keeps the board Ed25519 identity distinct
|
||||
* from the secp256k1 identity used on relays.
|
||||
*/
|
||||
class BoardSigningIdentity(
|
||||
publicKey: ByteArray,
|
||||
private val signer: (ByteArray) -> ByteArray?
|
||||
) {
|
||||
val publicKey: ByteArray = publicKey.copyOf()
|
||||
|
||||
init {
|
||||
require(publicKey.size == BoardWireConstants.SIGNING_KEY_LENGTH)
|
||||
}
|
||||
|
||||
fun sign(message: ByteArray): ByteArray? = signer(message)?.copyOf()
|
||||
|
||||
companion object {
|
||||
private const val GEO_IDENTITY_CONTEXT = "bitchat-board-geo-identity-v1"
|
||||
|
||||
fun fromNostrPrivateKeyHex(privateKeyHex: String): BoardSigningIdentity? =
|
||||
runCatching {
|
||||
val nostrSecret = privateKeyHex.hexToByteArray()
|
||||
.takeIf { it.size == BoardWireConstants.SIGNING_KEY_LENGTH }
|
||||
?: return@runCatching null
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
digest.update(GEO_IDENTITY_CONTEXT.toByteArray(Charsets.UTF_8))
|
||||
digest.update(nostrSecret)
|
||||
fromEd25519Seed(digest.digest())
|
||||
}.getOrNull()
|
||||
|
||||
fun fromEd25519Seed(seed: ByteArray): BoardSigningIdentity {
|
||||
require(seed.size == BoardWireConstants.SIGNING_KEY_LENGTH)
|
||||
val privateKey = Ed25519PrivateKeyParameters(seed.copyOf(), 0)
|
||||
return BoardSigningIdentity(privateKey.generatePublicKey().encoded) { message ->
|
||||
Ed25519Signer().run {
|
||||
init(true, privateKey)
|
||||
update(message, 0, message.size)
|
||||
generateSignature()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.hexToByteArray(): ByteArray {
|
||||
require(length % 2 == 0)
|
||||
return ByteArray(length / 2) { index ->
|
||||
substring(index * 2, index * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
382
app/src/main/java/com/bitchat/android/board/BoardStore.kt
Normal file
382
app/src/main/java/com/bitchat/android/board/BoardStore.kt
Normal file
@ -0,0 +1,382 @@
|
||||
package com.bitchat.android.board
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.io.File
|
||||
import java.util.Base64
|
||||
|
||||
enum class BoardIngestResult {
|
||||
ACCEPTED,
|
||||
DUPLICATE,
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum class BoardIngestSource {
|
||||
REMOTE,
|
||||
LOCAL,
|
||||
RESTORE
|
||||
}
|
||||
|
||||
class BoardStore(
|
||||
private val file: File? = null,
|
||||
private val nowMs: () -> ULong = { System.currentTimeMillis().coerceAtLeast(0).toULong() }
|
||||
) {
|
||||
object Limits {
|
||||
const val MAX_POSTS = 200
|
||||
const val MAX_POSTS_PER_AUTHOR = 5
|
||||
const val MAX_ORPHAN_TOMBSTONES = 100
|
||||
const val MAX_ORPHAN_TOMBSTONES_PER_AUTHOR = 5
|
||||
const val CLOCK_SKEW_MS: ULong = 3_600_000uL
|
||||
const val ORPHAN_TOMBSTONE_LIFETIME_MS: ULong = BoardWireConstants.MAX_LIFETIME_MS
|
||||
}
|
||||
|
||||
private data class StoredPost(
|
||||
val post: BoardPostPacket,
|
||||
val packet: BitchatPacket,
|
||||
val rawPacket: ByteArray
|
||||
)
|
||||
|
||||
private data class StoredTombstone(
|
||||
val tombstone: BoardTombstonePacket,
|
||||
val packet: BitchatPacket,
|
||||
val rawPacket: ByteArray,
|
||||
val retainUntil: ULong,
|
||||
val isOrphan: Boolean
|
||||
)
|
||||
|
||||
private data class PersistedEntry(
|
||||
val packet: String,
|
||||
val retainUntil: String?
|
||||
)
|
||||
|
||||
private val lock = Any()
|
||||
private val posts = mutableListOf<StoredPost>()
|
||||
private val tombstones = mutableListOf<StoredTombstone>()
|
||||
private val _postsSnapshot = MutableStateFlow<List<BoardPostPacket>>(emptyList())
|
||||
private val _postArrivals = MutableSharedFlow<BoardPostPacket>(extraBufferCapacity = 64)
|
||||
|
||||
val postsSnapshot: StateFlow<List<BoardPostPacket>> = _postsSnapshot.asStateFlow()
|
||||
val postArrivals: SharedFlow<BoardPostPacket> = _postArrivals.asSharedFlow()
|
||||
|
||||
init {
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
fun ingest(
|
||||
wire: BoardWire,
|
||||
packet: BitchatPacket,
|
||||
source: BoardIngestSource = BoardIngestSource.REMOTE
|
||||
): BoardIngestResult {
|
||||
if (packet.type != MessageType.BOARD_POST.value || !wire.verifySignature()) {
|
||||
return BoardIngestResult.REJECTED
|
||||
}
|
||||
val rawPacket = packet.toBinaryData(padding = false) ?: return BoardIngestResult.REJECTED
|
||||
val now = nowMs()
|
||||
var arrival: BoardPostPacket? = null
|
||||
val result = synchronized(lock) {
|
||||
val outcome = ingestLocked(
|
||||
wire = wire,
|
||||
packet = packet,
|
||||
rawPacket = rawPacket,
|
||||
now = now,
|
||||
retainUntilOverride = null
|
||||
)
|
||||
if (outcome == BoardIngestResult.ACCEPTED && source != BoardIngestSource.RESTORE) {
|
||||
persistLocked()
|
||||
}
|
||||
if (outcome == BoardIngestResult.ACCEPTED &&
|
||||
source == BoardIngestSource.REMOTE &&
|
||||
wire is BoardWire.Post
|
||||
) {
|
||||
arrival = wire.packet
|
||||
}
|
||||
outcome
|
||||
}
|
||||
arrival?.let(_postArrivals::tryEmit)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingests a packet received from the mesh and reports whether this exact
|
||||
* arrival may continue through the live relay path.
|
||||
*
|
||||
* A duplicate board payload must not relay again: the payload signature
|
||||
* does not authenticate mutable outer packet fields such as timestamp, so
|
||||
* accepting duplicates for relay would let one captured notice be wrapped
|
||||
* in infinitely many distinct outer packets.
|
||||
*/
|
||||
fun ingestRemoteForRelay(wire: BoardWire, packet: BitchatPacket): Boolean =
|
||||
ingest(wire, packet, BoardIngestSource.REMOTE) == BoardIngestResult.ACCEPTED
|
||||
|
||||
fun posts(forGeohash: String): List<BoardPostPacket> = synchronized(lock) {
|
||||
pruneExpiredLocked(nowMs())
|
||||
posts.asSequence()
|
||||
.map { it.post }
|
||||
.filter { it.geohash == forGeohash }
|
||||
.sortedWith(
|
||||
compareByDescending<BoardPostPacket> { it.isUrgent }
|
||||
.thenByDescending { it.createdAt }
|
||||
)
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun syncCandidates(): List<BitchatPacket> = synchronized(lock) {
|
||||
pruneExpiredLocked(nowMs())
|
||||
posts.map { it.packet } + tombstones.map { it.packet }
|
||||
}
|
||||
|
||||
fun pruneExpired() = synchronized(lock) {
|
||||
val changed = pruneExpiredLocked(nowMs())
|
||||
if (changed) persistLocked()
|
||||
}
|
||||
|
||||
fun wipe() = synchronized(lock) {
|
||||
posts.clear()
|
||||
tombstones.clear()
|
||||
file?.let { check(!it.exists() || it.delete()) }
|
||||
publishSnapshotLocked()
|
||||
}
|
||||
|
||||
private fun ingestLocked(
|
||||
wire: BoardWire,
|
||||
packet: BitchatPacket,
|
||||
rawPacket: ByteArray,
|
||||
now: ULong,
|
||||
retainUntilOverride: ULong?
|
||||
): BoardIngestResult {
|
||||
pruneExpiredLocked(now)
|
||||
return when (wire) {
|
||||
is BoardWire.Post -> ingestPostLocked(wire.packet, packet, rawPacket, now)
|
||||
is BoardWire.Tombstone -> ingestTombstoneLocked(
|
||||
wire.packet,
|
||||
packet,
|
||||
rawPacket,
|
||||
now,
|
||||
retainUntilOverride
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ingestPostLocked(
|
||||
post: BoardPostPacket,
|
||||
packet: BitchatPacket,
|
||||
rawPacket: ByteArray,
|
||||
now: ULong
|
||||
): BoardIngestResult {
|
||||
if (post.expiresAt <= now) return BoardIngestResult.REJECTED
|
||||
if (post.createdAt > now.saturatedAdd(Limits.CLOCK_SKEW_MS)) {
|
||||
return BoardIngestResult.REJECTED
|
||||
}
|
||||
if (post.expiresAt > now.saturatedAdd(BoardWireConstants.MAX_LIFETIME_MS)
|
||||
.saturatedAdd(Limits.CLOCK_SKEW_MS)
|
||||
) {
|
||||
return BoardIngestResult.REJECTED
|
||||
}
|
||||
if (tombstones.any {
|
||||
it.tombstone.postID.contentEquals(post.postID) &&
|
||||
it.tombstone.authorSigningKey.contentEquals(post.authorSigningKey)
|
||||
}
|
||||
) {
|
||||
return BoardIngestResult.REJECTED
|
||||
}
|
||||
if (posts.any {
|
||||
it.post.postID.contentEquals(post.postID) &&
|
||||
it.post.authorSigningKey.contentEquals(post.authorSigningKey)
|
||||
}
|
||||
) {
|
||||
return BoardIngestResult.DUPLICATE
|
||||
}
|
||||
|
||||
posts += StoredPost(post, packet, rawPacket)
|
||||
enforcePostCapsLocked(post.authorSigningKey)
|
||||
publishSnapshotLocked()
|
||||
return BoardIngestResult.ACCEPTED
|
||||
}
|
||||
|
||||
private fun ingestTombstoneLocked(
|
||||
tombstone: BoardTombstonePacket,
|
||||
packet: BitchatPacket,
|
||||
rawPacket: ByteArray,
|
||||
now: ULong,
|
||||
retainUntilOverride: ULong?
|
||||
): BoardIngestResult {
|
||||
if (tombstones.any {
|
||||
it.tombstone.postID.contentEquals(tombstone.postID) &&
|
||||
it.tombstone.authorSigningKey.contentEquals(tombstone.authorSigningKey)
|
||||
}
|
||||
) {
|
||||
return BoardIngestResult.DUPLICATE
|
||||
}
|
||||
|
||||
val maxRetain = minOf(
|
||||
tombstone.deletedAt.saturatedAdd(Limits.ORPHAN_TOMBSTONE_LIFETIME_MS),
|
||||
now.saturatedAdd(Limits.ORPHAN_TOMBSTONE_LIFETIME_MS)
|
||||
.saturatedAdd(Limits.CLOCK_SKEW_MS)
|
||||
)
|
||||
val matchingPostIndex = posts.indexOfFirst {
|
||||
it.post.postID.contentEquals(tombstone.postID) &&
|
||||
it.post.authorSigningKey.contentEquals(tombstone.authorSigningKey)
|
||||
}
|
||||
val retainUntil: ULong
|
||||
val isOrphan: Boolean
|
||||
if (matchingPostIndex >= 0) {
|
||||
val target = posts[matchingPostIndex].post
|
||||
retainUntil = target.expiresAt
|
||||
isOrphan = false
|
||||
posts.removeAt(matchingPostIndex)
|
||||
publishSnapshotLocked()
|
||||
} else if (retainUntilOverride != null) {
|
||||
retainUntil = minOf(retainUntilOverride, maxRetain)
|
||||
isOrphan = false
|
||||
} else {
|
||||
retainUntil = maxRetain
|
||||
isOrphan = true
|
||||
}
|
||||
if (retainUntil <= now) return BoardIngestResult.REJECTED
|
||||
|
||||
tombstones += StoredTombstone(
|
||||
tombstone = tombstone,
|
||||
packet = packet,
|
||||
rawPacket = rawPacket,
|
||||
retainUntil = retainUntil,
|
||||
isOrphan = isOrphan
|
||||
)
|
||||
if (isOrphan) enforceOrphanTombstoneCapsLocked(tombstone.authorSigningKey)
|
||||
return BoardIngestResult.ACCEPTED
|
||||
}
|
||||
|
||||
private fun enforcePostCapsLocked(author: ByteArray) {
|
||||
val authorPosts = posts.filter { it.post.authorSigningKey.contentEquals(author) }
|
||||
evictOldestPostsLocked(authorPosts, Limits.MAX_POSTS_PER_AUTHOR)
|
||||
evictOldestPostsLocked(posts.toList(), Limits.MAX_POSTS)
|
||||
}
|
||||
|
||||
private fun evictOldestPostsLocked(candidates: List<StoredPost>, keep: Int) {
|
||||
val victims = candidates.sortedBy { it.post.createdAt }
|
||||
.take((candidates.size - keep).coerceAtLeast(0))
|
||||
if (victims.isNotEmpty()) {
|
||||
posts.removeAll { stored -> victims.any { it === stored } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun enforceOrphanTombstoneCapsLocked(author: ByteArray) {
|
||||
val authorOrphans = tombstones.filter {
|
||||
it.isOrphan && it.tombstone.authorSigningKey.contentEquals(author)
|
||||
}
|
||||
removeOldestTombstonesLocked(
|
||||
authorOrphans,
|
||||
authorOrphans.size - Limits.MAX_ORPHAN_TOMBSTONES_PER_AUTHOR
|
||||
)
|
||||
val allOrphans = tombstones.filter { it.isOrphan }
|
||||
removeOldestTombstonesLocked(
|
||||
allOrphans,
|
||||
allOrphans.size - Limits.MAX_ORPHAN_TOMBSTONES
|
||||
)
|
||||
}
|
||||
|
||||
private fun removeOldestTombstonesLocked(
|
||||
candidates: List<StoredTombstone>,
|
||||
count: Int
|
||||
) {
|
||||
if (count <= 0) return
|
||||
val victims = candidates.take(count)
|
||||
tombstones.removeAll { stored -> victims.any { it === stored } }
|
||||
}
|
||||
|
||||
private fun pruneExpiredLocked(now: ULong): Boolean {
|
||||
val postsBefore = posts.size
|
||||
val tombstonesBefore = tombstones.size
|
||||
posts.removeAll { it.post.expiresAt <= now }
|
||||
tombstones.removeAll { it.retainUntil <= now }
|
||||
if (posts.size != postsBefore) publishSnapshotLocked()
|
||||
return posts.size != postsBefore || tombstones.size != tombstonesBefore
|
||||
}
|
||||
|
||||
private fun publishSnapshotLocked() {
|
||||
_postsSnapshot.value = posts.map { it.post }
|
||||
}
|
||||
|
||||
private fun persistLocked() {
|
||||
val target = file ?: return
|
||||
val entries = posts.map {
|
||||
PersistedEntry(
|
||||
packet = Base64.getEncoder().encodeToString(it.rawPacket),
|
||||
retainUntil = null
|
||||
)
|
||||
} + tombstones.map {
|
||||
PersistedEntry(
|
||||
packet = Base64.getEncoder().encodeToString(it.rawPacket),
|
||||
retainUntil = it.retainUntil.toString()
|
||||
)
|
||||
}
|
||||
runCatching {
|
||||
if (entries.isEmpty()) {
|
||||
if (target.exists()) target.delete()
|
||||
return
|
||||
}
|
||||
target.parentFile?.mkdirs()
|
||||
val temporary = File(target.parentFile, "${target.name}.tmp")
|
||||
temporary.writeText(Gson().toJson(entries))
|
||||
if (!temporary.renameTo(target)) {
|
||||
temporary.copyTo(target, overwrite = true)
|
||||
temporary.delete()
|
||||
}
|
||||
}.onFailure {
|
||||
Log.e(TAG, "Failed to persist board store: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFromDisk() {
|
||||
val target = file ?: return
|
||||
if (!target.isFile) return
|
||||
val entries = runCatching {
|
||||
val type = object : TypeToken<List<PersistedEntry>>() {}.type
|
||||
Gson().fromJson<List<PersistedEntry>>(target.readText(), type)
|
||||
}.getOrNull() ?: return
|
||||
val now = nowMs()
|
||||
synchronized(lock) {
|
||||
for (entry in entries) {
|
||||
val raw = runCatching { Base64.getDecoder().decode(entry.packet) }.getOrNull() ?: continue
|
||||
val packet = BitchatPacket.fromBinaryData(raw) ?: continue
|
||||
if (packet.type != MessageType.BOARD_POST.value) continue
|
||||
val wire = BoardWireCodec.decode(packet.payload) ?: continue
|
||||
if (!wire.verifySignature()) continue
|
||||
ingestLocked(
|
||||
wire = wire,
|
||||
packet = packet,
|
||||
rawPacket = raw,
|
||||
now = now,
|
||||
retainUntilOverride = entry.retainUntil?.toULongOrNull()
|
||||
)
|
||||
}
|
||||
publishSnapshotLocked()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BoardStore"
|
||||
|
||||
@Volatile
|
||||
private var instance: BoardStore? = null
|
||||
|
||||
fun getInstance(context: Context): BoardStore =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: BoardStore(
|
||||
File(context.applicationContext.filesDir, "board/posts.json")
|
||||
).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ULong.saturatedAdd(other: ULong): ULong =
|
||||
if (ULong.MAX_VALUE - this < other) ULong.MAX_VALUE else this + other
|
||||
@ -0,0 +1,87 @@
|
||||
package com.bitchat.android.board
|
||||
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
import kotlin.math.abs
|
||||
|
||||
enum class NoticeSource {
|
||||
MESH,
|
||||
NOSTR
|
||||
}
|
||||
|
||||
data class UnifiedNotice(
|
||||
val id: String,
|
||||
val content: String,
|
||||
val nickname: String,
|
||||
val createdAtMs: Long,
|
||||
val geohash: String,
|
||||
val urgent: Boolean,
|
||||
val expiresAtMs: Long?,
|
||||
val source: NoticeSource,
|
||||
val boardPost: BoardPostPacket? = null,
|
||||
val nostrNote: LocationNotesManager.Note? = null
|
||||
)
|
||||
|
||||
object UnifiedNotices {
|
||||
private const val DEDUPLICATION_WINDOW_MS = 15 * 60 * 1_000L
|
||||
|
||||
/**
|
||||
* Combines exact-scope mesh board posts with relay notes. When a relay
|
||||
* bridge copy matches a board post, the signed board copy is authoritative.
|
||||
*/
|
||||
fun merge(
|
||||
geohash: String,
|
||||
boardPosts: List<BoardPostPacket>,
|
||||
relayNotes: List<LocationNotesManager.Note>
|
||||
): List<UnifiedNotice> {
|
||||
val normalized = geohash.lowercase()
|
||||
val scopedPosts = boardPosts.filter { it.geohash == normalized }
|
||||
val boardNotices = scopedPosts.map { post ->
|
||||
UnifiedNotice(
|
||||
id = "mesh:${post.authorSigningKey.toHex()}:${post.postID.toHex()}",
|
||||
content = post.content,
|
||||
nickname = post.authorNickname,
|
||||
createdAtMs = post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(),
|
||||
geohash = post.geohash,
|
||||
urgent = post.isUrgent,
|
||||
expiresAtMs = post.expiresAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(),
|
||||
source = NoticeSource.MESH,
|
||||
boardPost = post
|
||||
)
|
||||
}
|
||||
val relayNotices = relayNotes.asSequence()
|
||||
.filterNot { note ->
|
||||
scopedPosts.any { post ->
|
||||
post.geohash == note.geohash.lowercase() &&
|
||||
post.content == note.content &&
|
||||
post.authorNickname.ifBlank { "anon" } ==
|
||||
note.nickname?.trim()?.takeIf { it.isNotEmpty() }.orEmpty()
|
||||
.ifEmpty { "anon" } &&
|
||||
abs(
|
||||
post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong() -
|
||||
note.createdAt.toLong() * 1_000L
|
||||
) <= DEDUPLICATION_WINDOW_MS
|
||||
}
|
||||
}
|
||||
.map { note ->
|
||||
UnifiedNotice(
|
||||
id = "nostr:${note.id}",
|
||||
content = note.content,
|
||||
nickname = note.nickname.orEmpty(),
|
||||
createdAtMs = note.createdAt.toLong() * 1_000L,
|
||||
geohash = note.geohash.lowercase(),
|
||||
urgent = note.isUrgent,
|
||||
expiresAtMs = note.expiresAt?.toLong()?.times(1_000L),
|
||||
source = NoticeSource.NOSTR,
|
||||
nostrNote = note
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
|
||||
return (boardNotices + relayNotices).sortedWith(
|
||||
compareByDescending<UnifiedNotice> { it.urgent }
|
||||
.thenByDescending { it.createdAtMs }
|
||||
)
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@ -112,6 +112,12 @@ open class EncryptionService(private val context: Context) {
|
||||
fun getStaticPublicKey(): ByteArray? {
|
||||
return noiseService.getStaticPublicKeyData()
|
||||
}
|
||||
|
||||
fun sealCourierPayload(payload: ByteArray, recipientStaticKey: ByteArray): ByteArray =
|
||||
noiseService.sealCourierPayload(payload, recipientStaticKey)
|
||||
|
||||
fun openCourierPayload(ciphertext: ByteArray): Pair<ByteArray, ByteArray> =
|
||||
noiseService.openCourierPayload(ciphertext)
|
||||
|
||||
/**
|
||||
* Get our signing public key for Ed25519 signatures (for identity announcements)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -162,9 +162,12 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
/**
|
||||
* Refresh available channels from current location
|
||||
*/
|
||||
fun refreshChannels() {
|
||||
fun refreshChannels(
|
||||
forceFresh: Boolean = false,
|
||||
updatePlaceNames: Boolean = true
|
||||
) {
|
||||
if (syncPermissionState() == PermissionState.AUTHORIZED && isLocationServicesEnabled()) {
|
||||
requestOneShotLocation()
|
||||
requestOneShotLocation(forceFresh, updatePlaceNames)
|
||||
}
|
||||
}
|
||||
|
||||
@ -355,7 +358,10 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
|
||||
// MARK: - Location Operations
|
||||
|
||||
private fun requestOneShotLocation() {
|
||||
private fun requestOneShotLocation(
|
||||
forceFresh: Boolean = false,
|
||||
updatePlaceNames: Boolean = true
|
||||
) {
|
||||
if (!isLocationServicesEnabled() ||
|
||||
syncPermissionState() != PermissionState.AUTHORIZED
|
||||
) {
|
||||
@ -367,37 +373,53 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
val token = LiveLocationPrivacyGate.captureToken() ?: return
|
||||
_isLoadingLocation.value = true
|
||||
|
||||
if (forceFresh) {
|
||||
requestFreshLocation(token, updatePlaceNames)
|
||||
return
|
||||
}
|
||||
|
||||
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
locationProvider.getLastKnownLocation { cached ->
|
||||
if (!canUseLiveLocation(token)) return@getLastKnownLocation
|
||||
|
||||
if (cached != null) {
|
||||
onLocationUpdated(cached, token)
|
||||
onLocationUpdated(cached, token, updatePlaceNames)
|
||||
} else {
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
locationProvider.requestFreshLocation { fresh ->
|
||||
if (!canUseLiveLocation(token)) return@requestFreshLocation
|
||||
|
||||
if (fresh != null) {
|
||||
onLocationUpdated(fresh, token)
|
||||
} else {
|
||||
Log.w(TAG, "Failed to get fresh location")
|
||||
_isLoadingLocation.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
requestFreshLocation(token, updatePlaceNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!started) _isLoadingLocation.value = false
|
||||
}
|
||||
|
||||
private fun onLocationUpdated(location: Location, token: Long) {
|
||||
private fun requestFreshLocation(
|
||||
token: Long,
|
||||
updatePlaceNames: Boolean
|
||||
) {
|
||||
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
locationProvider.requestFreshLocation { fresh ->
|
||||
if (!canUseLiveLocation(token)) return@requestFreshLocation
|
||||
if (fresh != null) {
|
||||
onLocationUpdated(fresh, token, updatePlaceNames)
|
||||
} else {
|
||||
Log.w(TAG, "Failed to get fresh location")
|
||||
_isLoadingLocation.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!started) _isLoadingLocation.value = false
|
||||
}
|
||||
|
||||
private fun onLocationUpdated(
|
||||
location: Location,
|
||||
token: Long,
|
||||
updatePlaceNames: Boolean = true
|
||||
) {
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
if (!_systemLocationEnabled.value || !hasRuntimeLocationPermission()) return@runIfAllowed
|
||||
_isLoadingLocation.value = false
|
||||
computeChannels(location, token)
|
||||
reverseGeocodeIfNeeded(location, token)
|
||||
if (updatePlaceNames) reverseGeocodeIfNeeded(location, token)
|
||||
}
|
||||
}
|
||||
|
||||
@ -652,6 +674,12 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
_teleported.value = false
|
||||
}
|
||||
|
||||
/** Remove exact/transient location state without destroying the singleton. */
|
||||
fun panicReset() {
|
||||
clearLiveLocationState()
|
||||
clearPersistedChannel()
|
||||
}
|
||||
|
||||
// MARK: - Location Services State Persistence
|
||||
|
||||
/**
|
||||
|
||||
715
app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt
Normal file
715
app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt
Normal file
@ -0,0 +1,715 @@
|
||||
package com.bitchat.android.groups
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import java.util.ArrayDeque
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
data class GroupPeerIdentity(
|
||||
val fingerprint: String,
|
||||
val signingKey: ByteArray
|
||||
)
|
||||
|
||||
data class GroupCommandResult(
|
||||
val success: Boolean,
|
||||
val message: String
|
||||
)
|
||||
|
||||
enum class PeerGroupCapability {
|
||||
SUPPORTED,
|
||||
UNSUPPORTED,
|
||||
UNKNOWN;
|
||||
|
||||
companion object {
|
||||
fun fromPeerState(
|
||||
capabilities: PeerCapabilities?,
|
||||
hasVerifiedAnnouncement: Boolean
|
||||
): PeerGroupCapability = when {
|
||||
capabilities?.contains(PeerCapabilities.GROUPS) == true -> SUPPORTED
|
||||
capabilities != null || hasVerifiedAnnouncement -> UNSUPPORTED
|
||||
else -> UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface GroupCoordinatorContext {
|
||||
val groupStore: GroupStore
|
||||
val nickname: String
|
||||
val myPeerID: String
|
||||
val selectedConversationID: String?
|
||||
|
||||
fun myNoiseFingerprint(): String
|
||||
fun mySigningPublicKey(): ByteArray?
|
||||
fun sign(data: ByteArray): ByteArray?
|
||||
|
||||
fun peerIDsForNickname(nickname: String): List<String>
|
||||
fun isPeerConnected(peerID: String): Boolean
|
||||
fun peerGroupCapability(peerID: String): PeerGroupCapability
|
||||
fun peerNickname(peerID: String): String?
|
||||
fun peerIdentity(peerID: String): GroupPeerIdentity?
|
||||
fun connectedPeerID(fingerprint: String): String?
|
||||
fun isFingerprintBlocked(fingerprint: String): Boolean
|
||||
|
||||
fun sendGroupInvite(payload: ByteArray, peerID: String)
|
||||
fun sendGroupKeyUpdate(payload: ByteArray, peerID: String)
|
||||
fun broadcastGroupMessage(payload: ByteArray)
|
||||
|
||||
fun appendGroupMessage(groupPeerID: String, message: BitchatMessage): Boolean
|
||||
fun markGroupUnread(groupPeerID: String)
|
||||
fun removeGroupConversation(groupPeerID: String)
|
||||
fun openGroupConversation(groupPeerID: String)
|
||||
fun closeGroupConversation()
|
||||
fun addSystemMessage(message: String)
|
||||
fun addGroupSystemMessage(groupPeerID: String, message: String)
|
||||
fun notifyGroupMessage(groupPeerID: String, sender: String, message: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creator-managed private-group state machine matching iOS v1.
|
||||
*/
|
||||
class GroupCoordinator(private val context: GroupCoordinatorContext) {
|
||||
private data class MemberSelector(
|
||||
val nickname: String,
|
||||
val identitySuffix: String?
|
||||
)
|
||||
|
||||
private sealed class PendingEvent {
|
||||
data class Invite(
|
||||
val peerID: String,
|
||||
val authenticatedRemoteStaticKey: ByteArray,
|
||||
val payload: ByteArray
|
||||
) : PendingEvent()
|
||||
|
||||
data class KeyUpdate(
|
||||
val peerID: String,
|
||||
val authenticatedRemoteStaticKey: ByteArray,
|
||||
val payload: ByteArray
|
||||
) : PendingEvent()
|
||||
|
||||
data class Message(
|
||||
val payload: ByteArray,
|
||||
val receivedAtMs: Long
|
||||
) : PendingEvent()
|
||||
|
||||
data class PeerAuthenticated(val peerID: String) : PendingEvent()
|
||||
}
|
||||
|
||||
private data class FutureMessage(
|
||||
val groupID: ByteArray,
|
||||
val epoch: Long,
|
||||
val payload: ByteArray,
|
||||
val queuedAtMs: Long
|
||||
)
|
||||
|
||||
private val lifecycleLock = Any()
|
||||
private val pendingLock = Any()
|
||||
private val pendingEvents = ArrayDeque<PendingEvent>()
|
||||
private val futureMessages = ArrayDeque<FutureMessage>()
|
||||
@Volatile
|
||||
private var acceptsInboundEvents = true
|
||||
@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 <name>")
|
||||
if (name.codePointCount(0, name.length) > MAX_GROUP_NAME_LENGTH) {
|
||||
return error("group name must be $MAX_GROUP_NAME_LENGTH characters or fewer")
|
||||
}
|
||||
val fingerprint = context.myNoiseFingerprint()
|
||||
val signingKey = context.mySigningPublicKey()
|
||||
if (!FINGERPRINT.matches(fingerprint) || signingKey?.size != 32) {
|
||||
return error("your cryptographic identity is not ready")
|
||||
}
|
||||
val creator = GroupMember(fingerprint, signingKey, context.nickname)
|
||||
val group = context.groupStore.createGroup(name, creator)
|
||||
?: return error("could not create group")
|
||||
context.openGroupConversation(group.peerID)
|
||||
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 <nickname>[#identity-suffix]")
|
||||
val nickname = selector.nickname
|
||||
val group = selectedGroup() ?: return error("open a private group first")
|
||||
if (!isCreator(group)) return error("only the group creator can change members")
|
||||
val peerID = resolvePeer(selector) ?: return ambiguousPeerError(selector)
|
||||
if (!context.isPeerConnected(peerID)) return error("$nickname is not connected")
|
||||
when (context.peerGroupCapability(peerID)) {
|
||||
PeerGroupCapability.SUPPORTED -> Unit
|
||||
PeerGroupCapability.UNSUPPORTED ->
|
||||
return error("$nickname does not support private groups")
|
||||
PeerGroupCapability.UNKNOWN ->
|
||||
return error("private-group support for $nickname is not confirmed yet; try again")
|
||||
}
|
||||
val identity = context.peerIdentity(peerID)
|
||||
?: return error("$nickname does not have a verified mesh identity")
|
||||
if (group.isMember(identity.fingerprint)) return error("$nickname is already a member")
|
||||
if (group.members.size >= BitchatGroup.MAX_MEMBERS) {
|
||||
return error("groups are limited to ${BitchatGroup.MAX_MEMBERS} members")
|
||||
}
|
||||
|
||||
val member = GroupMember(
|
||||
identity.fingerprint,
|
||||
identity.signingKey,
|
||||
context.peerNickname(peerID) ?: nickname
|
||||
)
|
||||
val (updated, key) = context.groupStore.rotateKey(
|
||||
group.groupID,
|
||||
group.members + member
|
||||
) ?: return error("could not rotate the group key")
|
||||
val payload = signedStatePayload(updated, key)
|
||||
?: return error("could not sign the group invite")
|
||||
|
||||
context.sendGroupInvite(payload, peerID)
|
||||
distributeState(payload, updated, setOf(identity.fingerprint))
|
||||
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 <nickname>[#identity-suffix]")
|
||||
val nickname = selector.nickname
|
||||
val group = selectedGroup() ?: return error("open a private group first")
|
||||
if (!isCreator(group)) return error("only the group creator can change members")
|
||||
val matchingMembers = group.members.filter {
|
||||
it.nickname.equals(nickname, ignoreCase = true)
|
||||
}.let { members ->
|
||||
selector.identitySuffix?.let { suffix ->
|
||||
members.filter { it.fingerprint.endsWith(suffix, ignoreCase = true) }
|
||||
} ?: members
|
||||
}
|
||||
val member = matchingMembers.singleOrNull() ?: return when {
|
||||
matchingMembers.isEmpty() -> error("$nickname is not in this group")
|
||||
else -> error(
|
||||
"multiple members are named '$nickname'; use ${memberChoices(matchingMembers)}"
|
||||
)
|
||||
}
|
||||
if (member.fingerprint == group.creatorFingerprint) {
|
||||
return error("the creator cannot remove themselves")
|
||||
}
|
||||
|
||||
val remaining = group.members.filterNot { it.fingerprint == member.fingerprint }
|
||||
val (rotated, key) = context.groupStore.rotateKey(group.groupID, remaining)
|
||||
?: return error("could not rotate the group key")
|
||||
val payload = signedStatePayload(rotated, key)
|
||||
?: return error("could not sign the group update")
|
||||
distributeState(payload, rotated, emptySet())
|
||||
notifyRemovedMember(member, rotated)
|
||||
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) {
|
||||
return error("remove all other members before leaving this group")
|
||||
}
|
||||
val removed = if (isCreator(group)) {
|
||||
context.groupStore.removeGroup(group.groupID)
|
||||
} else {
|
||||
context.groupStore.departGroup(group.groupID, group.epoch)
|
||||
}
|
||||
if (!removed) return error("could not leave group")
|
||||
synchronized(lifecycleLock) {
|
||||
dropFutureMessages(group.groupID)
|
||||
}
|
||||
context.closeGroupConversation()
|
||||
context.removeGroupConversation(group.peerID)
|
||||
return success("left #${group.name}")
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun listGroups(): GroupCommandResult {
|
||||
if (!context.groupStore.isReady) return loadingError()
|
||||
val groups = context.groupStore.groups.value
|
||||
if (groups.isEmpty()) return success("you are not in any private groups")
|
||||
val fingerprint = context.myNoiseFingerprint()
|
||||
val lines = groups.joinToString("\n") { group ->
|
||||
val role = if (group.creatorFingerprint == fingerprint) " (creator)" else ""
|
||||
"#${group.name}$role — ${group.members.size}/${BitchatGroup.MAX_MEMBERS}"
|
||||
}
|
||||
return success("private groups:\n$lines")
|
||||
}
|
||||
|
||||
@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 false
|
||||
}
|
||||
if (content.isEmpty() ||
|
||||
content.codePointCount(0, content.length) > MAX_MESSAGE_LENGTH
|
||||
) {
|
||||
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 false
|
||||
}
|
||||
val signingKey = context.mySigningPublicKey()
|
||||
if (signingKey?.size != 32) {
|
||||
context.addGroupSystemMessage(groupPeerID, "your signing identity is unavailable")
|
||||
return false
|
||||
}
|
||||
|
||||
val messageID = UUID.randomUUID().toString()
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val payload = try {
|
||||
GroupCrypto.sealMessage(
|
||||
content = content,
|
||||
messageID = messageID,
|
||||
senderNickname = context.nickname,
|
||||
senderSigningKey = signingKey,
|
||||
timestampMs = timestamp,
|
||||
groupID = group.groupID,
|
||||
epoch = group.epoch,
|
||||
key = key,
|
||||
sign = context::sign
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
context.addGroupSystemMessage(groupPeerID, "could not encrypt group message")
|
||||
return false
|
||||
}
|
||||
|
||||
val stored = context.appendGroupMessage(
|
||||
groupPeerID,
|
||||
BitchatMessage(
|
||||
id = messageID,
|
||||
sender = context.nickname,
|
||||
content = content,
|
||||
timestamp = Date(timestamp),
|
||||
isPrivate = true,
|
||||
recipientNickname = group.name,
|
||||
senderPeerID = context.myPeerID,
|
||||
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
|
||||
synchronized(lifecycleLock) {
|
||||
if (!acceptsInboundEvents || generation != inboundGeneration) return
|
||||
if (deferIfLoading(PendingEvent.Message(payload.copyOf(), receivedAtMs))) return
|
||||
processMessage(payload)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processMessage(payload: ByteArray, queueFutureEpoch: Boolean = true) {
|
||||
val envelope = GroupMessageEnvelope.decode(payload) ?: return
|
||||
val group = context.groupStore.group(envelope.groupID) ?: return
|
||||
if (envelope.epoch != group.epoch) {
|
||||
if (queueFutureEpoch && envelope.epoch > group.epoch) {
|
||||
queueFutureMessage(envelope, payload)
|
||||
}
|
||||
return
|
||||
}
|
||||
val key = context.groupStore.key(group.groupID) ?: return
|
||||
val plaintext = try {
|
||||
GroupCrypto.openMessage(envelope, key)
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
val member = group.memberWithSigningKey(plaintext.senderSigningKey) ?: return
|
||||
val ownSigningKey = context.mySigningPublicKey()
|
||||
if (ownSigningKey != null && plaintext.senderSigningKey.contentEquals(ownSigningKey)) return
|
||||
if (context.isFingerprintBlocked(member.fingerprint)) return
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val timestamp = plaintext.timestampMs.coerceIn(0, now)
|
||||
val sender = member.nickname.ifBlank { plaintext.senderNickname }
|
||||
val message = BitchatMessage(
|
||||
id = plaintext.messageID,
|
||||
sender = sender,
|
||||
content = plaintext.content,
|
||||
timestamp = Date(timestamp),
|
||||
isPrivate = true,
|
||||
recipientNickname = group.name,
|
||||
senderPeerID = member.fingerprint.take(16)
|
||||
)
|
||||
if (!context.appendGroupMessage(group.peerID, message)) return
|
||||
|
||||
if (context.selectedConversationID != group.peerID) {
|
||||
context.markGroupUnread(group.peerID)
|
||||
if (now - timestamp < RECENT_NOTIFICATION_WINDOW_MS) {
|
||||
context.notifyGroupMessage(group.peerID, "$sender @ ${group.name}", plaintext.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun handleInvite(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
val generation = inboundGeneration
|
||||
if (!acceptsInboundEvents) return
|
||||
synchronized(lifecycleLock) {
|
||||
if (!acceptsInboundEvents || generation != inboundGeneration) return
|
||||
if (
|
||||
deferIfLoading(
|
||||
PendingEvent.Invite(
|
||||
peerID,
|
||||
authenticatedRemoteStaticKey.copyOf(),
|
||||
payload.copyOf()
|
||||
)
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
applyState(peerID, authenticatedRemoteStaticKey, payload, isInvite = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun handleKeyUpdate(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
val generation = inboundGeneration
|
||||
if (!acceptsInboundEvents) return
|
||||
synchronized(lifecycleLock) {
|
||||
if (!acceptsInboundEvents || generation != inboundGeneration) return
|
||||
if (
|
||||
deferIfLoading(
|
||||
PendingEvent.KeyUpdate(
|
||||
peerID,
|
||||
authenticatedRemoteStaticKey.copyOf(),
|
||||
payload.copyOf()
|
||||
)
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
applyState(peerID, authenticatedRemoteStaticKey, payload, isInvite = false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays the current creator-signed state after an authenticated peer
|
||||
* 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
|
||||
synchronized(lifecycleLock) {
|
||||
if (!acceptsInboundEvents || generation != inboundGeneration) return
|
||||
if (deferIfLoading(PendingEvent.PeerAuthenticated(peerID))) return
|
||||
if (!context.isPeerConnected(peerID)) return
|
||||
if (context.peerGroupCapability(peerID) != PeerGroupCapability.SUPPORTED) return
|
||||
val identity = context.peerIdentity(peerID) ?: return
|
||||
val ownFingerprint = context.myNoiseFingerprint()
|
||||
context.groupStore.groups.value.forEach { group ->
|
||||
if (group.creatorFingerprint != ownFingerprint ||
|
||||
!group.isMember(identity.fingerprint)
|
||||
) {
|
||||
return@forEach
|
||||
}
|
||||
val key = context.groupStore.key(group.groupID) ?: return@forEach
|
||||
val payload = signedStatePayload(group, key) ?: return@forEach
|
||||
context.sendGroupKeyUpdate(payload, peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drains packets received during asynchronous store initialization.
|
||||
*/
|
||||
@Synchronized
|
||||
fun onStoreReady() {
|
||||
val generation = inboundGeneration
|
||||
if (!acceptsInboundEvents) return
|
||||
synchronized(lifecycleLock) {
|
||||
if (!acceptsInboundEvents ||
|
||||
generation != inboundGeneration ||
|
||||
!context.groupStore.isReady
|
||||
) {
|
||||
return
|
||||
}
|
||||
while (true) {
|
||||
val event = synchronized(pendingLock) {
|
||||
pendingEvents.pollFirst()
|
||||
} ?: return
|
||||
when (event) {
|
||||
is PendingEvent.Invite -> applyState(
|
||||
event.peerID,
|
||||
event.authenticatedRemoteStaticKey,
|
||||
event.payload,
|
||||
isInvite = true
|
||||
)
|
||||
is PendingEvent.KeyUpdate -> applyState(
|
||||
event.peerID,
|
||||
event.authenticatedRemoteStaticKey,
|
||||
event.payload,
|
||||
isInvite = false
|
||||
)
|
||||
is PendingEvent.Message -> processMessage(event.payload)
|
||||
is PendingEvent.PeerAuthenticated ->
|
||||
handlePeerAuthenticated(event.peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun suspendForPanic() {
|
||||
synchronized(lifecycleLock) {
|
||||
acceptsInboundEvents = false
|
||||
inboundGeneration += 1
|
||||
synchronized(pendingLock) {
|
||||
pendingEvents.clear()
|
||||
}
|
||||
futureMessages.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun resumeAfterPanic() {
|
||||
synchronized(lifecycleLock) {
|
||||
acceptsInboundEvents = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyState(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray,
|
||||
isInvite: Boolean
|
||||
) {
|
||||
val state = GroupStatePayload.decode(payload) ?: return
|
||||
val senderFingerprint = sha256(authenticatedRemoteStaticKey).toHex()
|
||||
if (senderFingerprint != state.creatorFingerprint) return
|
||||
if (!state.verifyCreatorSignature()) return
|
||||
|
||||
val ownFingerprint = context.myNoiseFingerprint()
|
||||
val existing = context.groupStore.group(state.groupID)
|
||||
// Reject stale state before interpreting a missing-self roster as a
|
||||
// removal. Otherwise an old, valid removal notice could delete a
|
||||
// membership restored by a later creator-signed re-invite.
|
||||
if (existing != null && state.epoch < existing.epoch) return
|
||||
val departureEpoch = context.groupStore.departureEpoch(state.groupID)
|
||||
if (departureEpoch != null &&
|
||||
(!isInvite || state.epoch <= departureEpoch)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (state.members.none { it.fingerprint == ownFingerprint }) {
|
||||
val removed = context.groupStore.removeGroupForState(state.groupID, state.epoch)
|
||||
?: return
|
||||
dropFutureMessages(removed.groupID)
|
||||
if (context.selectedConversationID == removed.peerID) {
|
||||
context.closeGroupConversation()
|
||||
}
|
||||
context.removeGroupConversation(removed.peerID)
|
||||
context.addSystemMessage("you were removed from #${removed.name}")
|
||||
return
|
||||
}
|
||||
val stored = if (isInvite && departureEpoch != null) {
|
||||
context.groupStore.acceptInvite(state.asGroup(), state.key)
|
||||
} else {
|
||||
context.groupStore.upsert(state.asGroup(), state.key)
|
||||
}
|
||||
if (!stored) return
|
||||
retryFutureMessages(state.groupID)
|
||||
|
||||
if (existing == null) {
|
||||
val inviter = state.members.firstOrNull {
|
||||
it.fingerprint == state.creatorFingerprint
|
||||
}?.nickname ?: context.peerNickname(peerID) ?: "unknown"
|
||||
val notice = "joined #${state.name}, invited by $inviter"
|
||||
context.addSystemMessage(notice)
|
||||
context.markGroupUnread(state.asGroup().peerID)
|
||||
context.notifyGroupMessage(state.asGroup().peerID, inviter, notice)
|
||||
}
|
||||
}
|
||||
|
||||
private fun signedStatePayload(group: BitchatGroup, key: ByteArray): ByteArray? =
|
||||
GroupStatePayload.makeSigned(group, key, context::sign)?.encode()
|
||||
|
||||
private fun distributeState(
|
||||
payload: ByteArray,
|
||||
group: BitchatGroup,
|
||||
excludedFingerprints: Set<String>
|
||||
) {
|
||||
val ownFingerprint = context.myNoiseFingerprint()
|
||||
group.members.forEach { member ->
|
||||
if (member.fingerprint == ownFingerprint ||
|
||||
member.fingerprint in excludedFingerprints
|
||||
) {
|
||||
return@forEach
|
||||
}
|
||||
context.connectedPeerID(member.fingerprint)?.let { peerID ->
|
||||
context.sendGroupKeyUpdate(payload, peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyRemovedMember(member: GroupMember, rotated: BitchatGroup) {
|
||||
val peerID = context.connectedPeerID(member.fingerprint) ?: return
|
||||
val payload = signedStatePayload(rotated, ByteArray(BitchatGroup.KEY_LENGTH)) ?: return
|
||||
context.sendGroupKeyUpdate(payload, peerID)
|
||||
}
|
||||
|
||||
private fun selectedGroup(): BitchatGroup? =
|
||||
context.selectedConversationID?.let(context.groupStore::group)
|
||||
|
||||
private fun isCreator(group: BitchatGroup): Boolean =
|
||||
group.creatorFingerprint == context.myNoiseFingerprint()
|
||||
|
||||
private fun parseMemberSelector(raw: String): MemberSelector? {
|
||||
val normalized = raw.trim().removePrefix("@")
|
||||
if (normalized.isEmpty()) return null
|
||||
val separator = normalized.lastIndexOf('#')
|
||||
if (separator < 0) return MemberSelector(normalized, null)
|
||||
if (separator == 0 || separator == normalized.lastIndex) return null
|
||||
val suffix = normalized.substring(separator + 1)
|
||||
if (!IDENTITY_SUFFIX.matches(suffix)) return null
|
||||
return MemberSelector(
|
||||
nickname = normalized.substring(0, separator),
|
||||
identitySuffix = suffix.lowercase()
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolvePeer(selector: MemberSelector): String? {
|
||||
val matches = context.peerIDsForNickname(selector.nickname).distinct()
|
||||
val narrowed = selector.identitySuffix?.let { suffix ->
|
||||
matches.filter { peerID ->
|
||||
peerID.endsWith(suffix, ignoreCase = true) ||
|
||||
context.peerIdentity(peerID)
|
||||
?.fingerprint
|
||||
?.endsWith(suffix, ignoreCase = true) == true
|
||||
}
|
||||
} ?: matches
|
||||
return narrowed.singleOrNull()
|
||||
}
|
||||
|
||||
private fun ambiguousPeerError(selector: MemberSelector): GroupCommandResult {
|
||||
val matches = context.peerIDsForNickname(selector.nickname).distinct()
|
||||
if (matches.isEmpty() || selector.identitySuffix != null) {
|
||||
return error("user '${selector.nickname}' was not found")
|
||||
}
|
||||
return error(
|
||||
"multiple users are named '${selector.nickname}'; use " +
|
||||
matches.joinToString(" or ") { peerID ->
|
||||
val suffix = context.peerIdentity(peerID)
|
||||
?.fingerprint
|
||||
?.takeLast(8)
|
||||
?: peerID.takeLast(8)
|
||||
"@${selector.nickname}#$suffix"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun memberChoices(members: List<GroupMember>): String =
|
||||
members.joinToString(" or ") { "@${it.nickname}#${it.fingerprint.takeLast(8)}" }
|
||||
|
||||
private fun queueFutureMessage(envelope: GroupMessageEnvelope, payload: ByteArray) {
|
||||
val now = System.currentTimeMillis()
|
||||
pruneExpiredFutureMessages(now)
|
||||
if (futureMessages.any {
|
||||
it.epoch == envelope.epoch &&
|
||||
it.groupID.contentEquals(envelope.groupID) &&
|
||||
it.payload.contentEquals(payload)
|
||||
}
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (futureMessages.size >= MAX_FUTURE_MESSAGES) futureMessages.pollFirst()
|
||||
futureMessages.addLast(
|
||||
FutureMessage(
|
||||
envelope.groupID.copyOf(),
|
||||
envelope.epoch,
|
||||
payload.copyOf(),
|
||||
now
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun retryFutureMessages(groupID: ByteArray) {
|
||||
val current = context.groupStore.group(groupID) ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
val ready = mutableListOf<ByteArray>()
|
||||
val iterator = futureMessages.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val message = iterator.next()
|
||||
if (now - message.queuedAtMs > FUTURE_MESSAGE_TTL_MS) {
|
||||
iterator.remove()
|
||||
} else if (message.groupID.contentEquals(groupID) &&
|
||||
message.epoch <= current.epoch
|
||||
) {
|
||||
iterator.remove()
|
||||
if (message.epoch == current.epoch) ready += message.payload
|
||||
}
|
||||
}
|
||||
ready.forEach { processMessage(it, queueFutureEpoch = false) }
|
||||
}
|
||||
|
||||
private fun dropFutureMessages(groupID: ByteArray) {
|
||||
val iterator = futureMessages.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
if (iterator.next().groupID.contentEquals(groupID)) iterator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pruneExpiredFutureMessages(now: Long) {
|
||||
val iterator = futureMessages.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
if (now - iterator.next().queuedAtMs > FUTURE_MESSAGE_TTL_MS) iterator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deferIfLoading(event: PendingEvent): Boolean =
|
||||
synchronized(pendingLock) {
|
||||
if (context.groupStore.isReady) return@synchronized false
|
||||
if (pendingEvents.size >= MAX_PENDING_EVENTS) pendingEvents.pollFirst()
|
||||
pendingEvents.addLast(event)
|
||||
true
|
||||
}
|
||||
|
||||
private fun loadingError() =
|
||||
error("private groups are still loading; try again")
|
||||
|
||||
private fun success(message: String) = GroupCommandResult(true, message)
|
||||
private fun error(message: String) = GroupCommandResult(false, message)
|
||||
|
||||
private fun ByteArray.toHex(): String =
|
||||
joinToString("") { "%02x".format(it) }
|
||||
|
||||
companion object {
|
||||
private const val MAX_GROUP_NAME_LENGTH = 40
|
||||
private const val MAX_MESSAGE_LENGTH = 60_000
|
||||
private const val RECENT_NOTIFICATION_WINDOW_MS = 30_000L
|
||||
private const val MAX_PENDING_EVENTS = 64
|
||||
private const val MAX_FUTURE_MESSAGES = 32
|
||||
private const val FUTURE_MESSAGE_TTL_MS = 2 * 60_000L
|
||||
private val FINGERPRINT = Regex("^[0-9a-fA-F]{64}$")
|
||||
private val IDENTITY_SUFFIX = Regex("^[0-9a-fA-F]{4,64}$")
|
||||
}
|
||||
}
|
||||
663
app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt
Normal file
663
app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt
Normal file
@ -0,0 +1,663 @@
|
||||
package com.bitchat.android.groups
|
||||
|
||||
import com.bitchat.android.util.dataFromHexString
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.Arrays
|
||||
import java.util.UUID
|
||||
import org.bouncycastle.crypto.InvalidCipherTextException
|
||||
import org.bouncycastle.crypto.modes.ChaCha20Poly1305
|
||||
import org.bouncycastle.crypto.params.AEADParameters
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.params.KeyParameter
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
|
||||
data class GroupMember(
|
||||
val fingerprint: String,
|
||||
val signingKey: ByteArray,
|
||||
val nickname: String
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean =
|
||||
this === other ||
|
||||
(other is GroupMember &&
|
||||
fingerprint == other.fingerprint &&
|
||||
signingKey.contentEquals(other.signingKey) &&
|
||||
nickname == other.nickname)
|
||||
|
||||
override fun hashCode(): Int =
|
||||
31 * (31 * fingerprint.hashCode() + signingKey.contentHashCode()) + nickname.hashCode()
|
||||
}
|
||||
|
||||
data class BitchatGroup(
|
||||
val groupID: ByteArray,
|
||||
val name: String,
|
||||
val epoch: Long,
|
||||
val members: List<GroupMember>,
|
||||
val creatorFingerprint: String
|
||||
) {
|
||||
val peerID: String get() = GroupIds.peerID(groupID)
|
||||
val creator: GroupMember? get() = members.firstOrNull { it.fingerprint == creatorFingerprint }
|
||||
|
||||
fun isMember(fingerprint: String): Boolean =
|
||||
members.any { it.fingerprint == fingerprint }
|
||||
|
||||
fun memberWithSigningKey(signingKey: ByteArray): GroupMember? =
|
||||
members.firstOrNull { it.signingKey.contentEquals(signingKey) }
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
this === other ||
|
||||
(other is BitchatGroup &&
|
||||
groupID.contentEquals(other.groupID) &&
|
||||
name == other.name &&
|
||||
epoch == other.epoch &&
|
||||
members == other.members &&
|
||||
creatorFingerprint == other.creatorFingerprint)
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = groupID.contentHashCode()
|
||||
result = 31 * result + name.hashCode()
|
||||
result = 31 * result + epoch.hashCode()
|
||||
result = 31 * result + members.hashCode()
|
||||
result = 31 * result + creatorFingerprint.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_MEMBERS = 16
|
||||
const val GROUP_ID_LENGTH = 16
|
||||
const val KEY_LENGTH = 32
|
||||
const val MAX_EPOCH = 0xffff_ffffL
|
||||
}
|
||||
}
|
||||
|
||||
object GroupIds {
|
||||
private const val PREFIX = "group_"
|
||||
private val pattern = Regex("^group_[0-9a-f]{32}$")
|
||||
|
||||
fun peerID(groupID: ByteArray): String {
|
||||
require(groupID.size == BitchatGroup.GROUP_ID_LENGTH)
|
||||
return PREFIX + groupID.hexEncodedString()
|
||||
}
|
||||
|
||||
fun groupID(peerID: String): ByteArray? {
|
||||
val normalized = peerID.lowercase()
|
||||
if (!pattern.matches(normalized)) return null
|
||||
return normalized.removePrefix(PREFIX).dataFromHexString()
|
||||
}
|
||||
|
||||
fun isGroup(peerID: String?): Boolean =
|
||||
peerID != null && pattern.matches(peerID.lowercase())
|
||||
}
|
||||
|
||||
class GroupTlvValueTooLongException : IllegalArgumentException("group TLV value exceeds UInt16")
|
||||
|
||||
internal object GroupTLV {
|
||||
data class Field(val type: Int, val value: ByteArray)
|
||||
|
||||
fun put(type: Int, value: ByteArray, output: ByteArrayOutputStream) {
|
||||
if (value.size > 0xffff) throw GroupTlvValueTooLongException()
|
||||
output.write(type and 0xff)
|
||||
output.write((value.size ushr 8) and 0xff)
|
||||
output.write(value.size and 0xff)
|
||||
output.write(value)
|
||||
}
|
||||
|
||||
fun encode(vararg fields: Pair<Int, ByteArray>): ByteArray {
|
||||
val output = ByteArrayOutputStream()
|
||||
fields.forEach { (type, value) -> put(type, value, output) }
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
fun parse(data: ByteArray): List<Field>? {
|
||||
val fields = mutableListOf<Field>()
|
||||
var offset = 0
|
||||
while (offset < data.size) {
|
||||
if (offset + 3 > data.size) return null
|
||||
val type = data[offset].toInt() and 0xff
|
||||
val length =
|
||||
((data[offset + 1].toInt() and 0xff) shl 8) or
|
||||
(data[offset + 2].toInt() and 0xff)
|
||||
offset += 3
|
||||
if (offset + length > data.size) return null
|
||||
fields += Field(type, data.copyOfRange(offset, offset + length))
|
||||
offset += length
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
fun epochData(epoch: Long): ByteArray {
|
||||
require(epoch in 0..BitchatGroup.MAX_EPOCH)
|
||||
return ByteBuffer.allocate(Int.SIZE_BYTES)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
.putInt(epoch.toInt())
|
||||
.array()
|
||||
}
|
||||
|
||||
fun epoch(data: ByteArray): Long? {
|
||||
if (data.size != Int.SIZE_BYTES) return null
|
||||
return ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN).int.toLong() and 0xffff_ffffL
|
||||
}
|
||||
|
||||
fun timestampData(timestampMs: Long): ByteArray =
|
||||
ByteBuffer.allocate(Long.SIZE_BYTES)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
.putLong(timestampMs)
|
||||
.array()
|
||||
|
||||
fun timestamp(data: ByteArray): Long? {
|
||||
if (data.size != Long.SIZE_BYTES) return null
|
||||
return ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN).long
|
||||
}
|
||||
|
||||
fun strictUtf8(data: ByteArray): String? = try {
|
||||
Charsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(data))
|
||||
.toString()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
object GroupRosterCoding {
|
||||
private const val FINGERPRINT_LENGTH = 32
|
||||
private const val SIGNING_KEY_LENGTH = 32
|
||||
private const val MAX_NICKNAME_BYTES = 64
|
||||
|
||||
fun encode(members: List<GroupMember>): ByteArray? {
|
||||
if (members.size > BitchatGroup.MAX_MEMBERS) return null
|
||||
val output = ByteArrayOutputStream()
|
||||
output.write(members.size)
|
||||
members.forEach { member ->
|
||||
val fingerprint = member.fingerprint.dataFromHexString()
|
||||
if (fingerprint?.size != FINGERPRINT_LENGTH ||
|
||||
member.signingKey.size != SIGNING_KEY_LENGTH
|
||||
) {
|
||||
return null
|
||||
}
|
||||
output.write(fingerprint)
|
||||
output.write(member.signingKey)
|
||||
val nickname = truncatedNicknameBytes(member.nickname)
|
||||
output.write(nickname.size)
|
||||
output.write(nickname)
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
fun decode(data: ByteArray): List<GroupMember>? {
|
||||
if (data.isEmpty()) return null
|
||||
val count = data[0].toInt() and 0xff
|
||||
if (count > BitchatGroup.MAX_MEMBERS) return null
|
||||
val members = mutableListOf<GroupMember>()
|
||||
var offset = 1
|
||||
repeat(count) {
|
||||
val fixedLength = FINGERPRINT_LENGTH + SIGNING_KEY_LENGTH + 1
|
||||
if (offset + fixedLength > data.size) return null
|
||||
val fingerprint =
|
||||
data.copyOfRange(offset, offset + FINGERPRINT_LENGTH).hexEncodedString()
|
||||
offset += FINGERPRINT_LENGTH
|
||||
val signingKey = data.copyOfRange(offset, offset + SIGNING_KEY_LENGTH)
|
||||
offset += SIGNING_KEY_LENGTH
|
||||
val nicknameLength = data[offset].toInt() and 0xff
|
||||
offset += 1
|
||||
if (offset + nicknameLength > data.size) return null
|
||||
val nickname = GroupTLV.strictUtf8(
|
||||
data.copyOfRange(offset, offset + nicknameLength)
|
||||
) ?: return null
|
||||
offset += nicknameLength
|
||||
members += GroupMember(fingerprint, signingKey, nickname)
|
||||
}
|
||||
if (offset != data.size) return null
|
||||
return members
|
||||
}
|
||||
|
||||
private fun truncatedNicknameBytes(nickname: String): ByteArray {
|
||||
val output = StringBuilder()
|
||||
var offset = 0
|
||||
while (offset < nickname.length) {
|
||||
val codePoint = nickname.codePointAt(offset)
|
||||
val candidate = output.toString() + String(Character.toChars(codePoint))
|
||||
if (candidate.toByteArray(Charsets.UTF_8).size > MAX_NICKNAME_BYTES) break
|
||||
output.appendCodePoint(codePoint)
|
||||
offset += Character.charCount(codePoint)
|
||||
}
|
||||
return output.toString().toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
|
||||
class GroupStatePayload(
|
||||
val groupID: ByteArray,
|
||||
val name: String,
|
||||
val key: ByteArray,
|
||||
val epoch: Long,
|
||||
val members: List<GroupMember>,
|
||||
val creatorFingerprint: String,
|
||||
val signature: ByteArray
|
||||
) {
|
||||
fun encode(): ByteArray? {
|
||||
val roster = GroupRosterCoding.encode(members) ?: return null
|
||||
val creator = creatorFingerprint.dataFromHexString()
|
||||
if (creator?.size != 32) return null
|
||||
return try {
|
||||
GroupTLV.encode(
|
||||
FIELD_GROUP_ID to groupID,
|
||||
FIELD_NAME to name.toByteArray(Charsets.UTF_8),
|
||||
FIELD_KEY to key,
|
||||
FIELD_EPOCH to GroupTLV.epochData(epoch),
|
||||
FIELD_ROSTER to roster,
|
||||
FIELD_CREATOR_FINGERPRINT to creator,
|
||||
FIELD_SIGNATURE to signature
|
||||
)
|
||||
} catch (_: GroupTlvValueTooLongException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyCreatorSignature(): Boolean {
|
||||
if (members.size > BitchatGroup.MAX_MEMBERS) return false
|
||||
val creator = members.firstOrNull { it.fingerprint == creatorFingerprint } ?: return false
|
||||
val roster = GroupRosterCoding.encode(members) ?: return false
|
||||
return GroupCrypto.verify(
|
||||
signature,
|
||||
signingContent(groupID, epoch, key, roster, name),
|
||||
creator.signingKey
|
||||
)
|
||||
}
|
||||
|
||||
fun asGroup(): BitchatGroup =
|
||||
BitchatGroup(groupID, name, epoch, members, creatorFingerprint)
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
this === other ||
|
||||
(other is GroupStatePayload &&
|
||||
groupID.contentEquals(other.groupID) &&
|
||||
name == other.name &&
|
||||
key.contentEquals(other.key) &&
|
||||
epoch == other.epoch &&
|
||||
members == other.members &&
|
||||
creatorFingerprint == other.creatorFingerprint &&
|
||||
signature.contentEquals(other.signature))
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = Arrays.hashCode(groupID)
|
||||
result = 31 * result + name.hashCode()
|
||||
result = 31 * result + Arrays.hashCode(key)
|
||||
result = 31 * result + epoch.hashCode()
|
||||
result = 31 * result + members.hashCode()
|
||||
result = 31 * result + creatorFingerprint.hashCode()
|
||||
result = 31 * result + Arrays.hashCode(signature)
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FIELD_GROUP_ID = 0x01
|
||||
private const val FIELD_NAME = 0x02
|
||||
private const val FIELD_KEY = 0x03
|
||||
private const val FIELD_EPOCH = 0x04
|
||||
private const val FIELD_ROSTER = 0x05
|
||||
private const val FIELD_CREATOR_FINGERPRINT = 0x06
|
||||
private const val FIELD_SIGNATURE = 0x07
|
||||
private val SIGNING_DOMAIN = "bitchat-group-v1".toByteArray(Charsets.UTF_8)
|
||||
|
||||
fun signingContent(
|
||||
groupID: ByteArray,
|
||||
epoch: Long,
|
||||
key: ByteArray,
|
||||
rosterBlob: ByteArray,
|
||||
name: String
|
||||
): ByteArray = concat(
|
||||
SIGNING_DOMAIN,
|
||||
groupID,
|
||||
GroupTLV.epochData(epoch),
|
||||
sha256(key),
|
||||
sha256(rosterBlob),
|
||||
sha256(name.toByteArray(Charsets.UTF_8))
|
||||
)
|
||||
|
||||
fun makeSigned(
|
||||
group: BitchatGroup,
|
||||
key: ByteArray,
|
||||
sign: (ByteArray) -> ByteArray?
|
||||
): GroupStatePayload? {
|
||||
val roster = GroupRosterCoding.encode(group.members) ?: return null
|
||||
val signature = sign(
|
||||
signingContent(group.groupID, group.epoch, key, roster, group.name)
|
||||
) ?: return null
|
||||
if (signature.size != 64) return null
|
||||
return GroupStatePayload(
|
||||
group.groupID,
|
||||
group.name,
|
||||
key,
|
||||
group.epoch,
|
||||
group.members,
|
||||
group.creatorFingerprint,
|
||||
signature
|
||||
)
|
||||
}
|
||||
|
||||
fun decode(data: ByteArray): GroupStatePayload? {
|
||||
val fields = GroupTLV.parse(data) ?: return null
|
||||
var groupID: ByteArray? = null
|
||||
var name: String? = null
|
||||
var key: ByteArray? = null
|
||||
var epoch: Long? = null
|
||||
var members: List<GroupMember>? = null
|
||||
var creatorFingerprint: String? = null
|
||||
var signature: ByteArray? = null
|
||||
fields.forEach { field ->
|
||||
when (field.type) {
|
||||
FIELD_GROUP_ID ->
|
||||
if (field.value.size == BitchatGroup.GROUP_ID_LENGTH) groupID = field.value
|
||||
FIELD_NAME -> name = GroupTLV.strictUtf8(field.value)
|
||||
FIELD_KEY ->
|
||||
if (field.value.size == BitchatGroup.KEY_LENGTH) key = field.value
|
||||
FIELD_EPOCH -> epoch = GroupTLV.epoch(field.value)
|
||||
FIELD_ROSTER -> members = GroupRosterCoding.decode(field.value)
|
||||
FIELD_CREATOR_FINGERPRINT ->
|
||||
if (field.value.size == 32) creatorFingerprint = field.value.hexEncodedString()
|
||||
FIELD_SIGNATURE -> if (field.value.size == 64) signature = field.value
|
||||
}
|
||||
}
|
||||
val decodedMembers = members ?: return null
|
||||
if (decodedMembers.isEmpty()) return null
|
||||
return GroupStatePayload(
|
||||
groupID ?: return null,
|
||||
name ?: return null,
|
||||
key ?: return null,
|
||||
epoch ?: return null,
|
||||
decodedMembers,
|
||||
creatorFingerprint ?: return null,
|
||||
signature ?: return null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GroupMessageEnvelope(
|
||||
val groupID: ByteArray,
|
||||
val epoch: Long,
|
||||
val nonce: ByteArray,
|
||||
val ciphertext: ByteArray
|
||||
) {
|
||||
fun encode(): ByteArray = GroupTLV.encode(
|
||||
FIELD_GROUP_ID to groupID,
|
||||
FIELD_EPOCH to GroupTLV.epochData(epoch),
|
||||
FIELD_NONCE to nonce,
|
||||
FIELD_CIPHERTEXT to ciphertext
|
||||
)
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
this === other ||
|
||||
(other is GroupMessageEnvelope &&
|
||||
groupID.contentEquals(other.groupID) &&
|
||||
epoch == other.epoch &&
|
||||
nonce.contentEquals(other.nonce) &&
|
||||
ciphertext.contentEquals(other.ciphertext))
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = groupID.contentHashCode()
|
||||
result = 31 * result + epoch.hashCode()
|
||||
result = 31 * result + nonce.contentHashCode()
|
||||
result = 31 * result + ciphertext.contentHashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FIELD_GROUP_ID = 0x01
|
||||
private const val FIELD_EPOCH = 0x02
|
||||
private const val FIELD_NONCE = 0x03
|
||||
private const val FIELD_CIPHERTEXT = 0x04
|
||||
|
||||
fun decode(data: ByteArray): GroupMessageEnvelope? {
|
||||
val fields = GroupTLV.parse(data) ?: return null
|
||||
var groupID: ByteArray? = null
|
||||
var epoch: Long? = null
|
||||
var nonce: ByteArray? = null
|
||||
var ciphertext: ByteArray? = null
|
||||
fields.forEach { field ->
|
||||
when (field.type) {
|
||||
FIELD_GROUP_ID ->
|
||||
if (field.value.size == BitchatGroup.GROUP_ID_LENGTH) groupID = field.value
|
||||
FIELD_EPOCH -> epoch = GroupTLV.epoch(field.value)
|
||||
FIELD_NONCE -> if (field.value.size == 12) nonce = field.value
|
||||
FIELD_CIPHERTEXT -> if (field.value.isNotEmpty()) ciphertext = field.value
|
||||
}
|
||||
}
|
||||
return GroupMessageEnvelope(
|
||||
groupID ?: return null,
|
||||
epoch ?: return null,
|
||||
nonce ?: return null,
|
||||
ciphertext ?: return null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class GroupMessagePlaintext(
|
||||
val messageID: String,
|
||||
val senderSigningKey: ByteArray,
|
||||
val senderNickname: String,
|
||||
val timestampMs: Long,
|
||||
val content: String
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean =
|
||||
this === other ||
|
||||
(other is GroupMessagePlaintext &&
|
||||
messageID == other.messageID &&
|
||||
senderSigningKey.contentEquals(other.senderSigningKey) &&
|
||||
senderNickname == other.senderNickname &&
|
||||
timestampMs == other.timestampMs &&
|
||||
content == other.content)
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = messageID.hashCode()
|
||||
result = 31 * result + senderSigningKey.contentHashCode()
|
||||
result = 31 * result + senderNickname.hashCode()
|
||||
result = 31 * result + timestampMs.hashCode()
|
||||
result = 31 * result + content.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
sealed class GroupCryptoException(message: String) : Exception(message) {
|
||||
class MalformedPayload : GroupCryptoException("malformed group payload")
|
||||
class SigningFailed : GroupCryptoException("group message signing failed")
|
||||
class SealFailed : GroupCryptoException("group message sealing failed")
|
||||
class DecryptionFailed : GroupCryptoException("group message decryption failed")
|
||||
class BadSenderSignature : GroupCryptoException("bad group sender signature")
|
||||
}
|
||||
|
||||
object GroupCrypto {
|
||||
private const val FIELD_MESSAGE_ID = 0x01
|
||||
private const val FIELD_SENDER_SIGNING_KEY = 0x02
|
||||
private const val FIELD_SENDER_NICKNAME = 0x03
|
||||
private const val FIELD_TIMESTAMP = 0x04
|
||||
private const val FIELD_CONTENT = 0x05
|
||||
private const val FIELD_SIGNATURE = 0x06
|
||||
private val MESSAGE_SIGNING_DOMAIN =
|
||||
"bitchat-group-msg-v1".toByteArray(Charsets.UTF_8)
|
||||
private val random = SecureRandom()
|
||||
|
||||
fun messageSigningContent(
|
||||
groupID: ByteArray,
|
||||
epoch: Long,
|
||||
messageID: String,
|
||||
timestampMs: Long,
|
||||
content: String
|
||||
): ByteArray = concat(
|
||||
MESSAGE_SIGNING_DOMAIN,
|
||||
groupID,
|
||||
GroupTLV.epochData(epoch),
|
||||
messageID.toByteArray(Charsets.UTF_8),
|
||||
GroupTLV.timestampData(timestampMs),
|
||||
content.toByteArray(Charsets.UTF_8)
|
||||
)
|
||||
|
||||
fun verify(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean {
|
||||
if (signature.size != 64 || publicKey.size != 32) return false
|
||||
return try {
|
||||
val verifier = Ed25519Signer()
|
||||
verifier.init(false, Ed25519PublicKeyParameters(publicKey, 0))
|
||||
verifier.update(data, 0, data.size)
|
||||
verifier.verifySignature(signature)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(GroupCryptoException::class, GroupTlvValueTooLongException::class)
|
||||
fun sealMessage(
|
||||
content: String,
|
||||
messageID: String,
|
||||
senderNickname: String,
|
||||
senderSigningKey: ByteArray,
|
||||
timestampMs: Long,
|
||||
groupID: ByteArray,
|
||||
epoch: Long,
|
||||
key: ByteArray,
|
||||
sign: (ByteArray) -> ByteArray?
|
||||
): ByteArray {
|
||||
if (!isCanonicalMessageID(messageID)) {
|
||||
throw GroupCryptoException.MalformedPayload()
|
||||
}
|
||||
val signature = sign(
|
||||
messageSigningContent(groupID, epoch, messageID, timestampMs, content)
|
||||
)
|
||||
if (signature?.size != 64) throw GroupCryptoException.SigningFailed()
|
||||
|
||||
val inner = GroupTLV.encode(
|
||||
FIELD_MESSAGE_ID to messageID.toByteArray(Charsets.UTF_8),
|
||||
FIELD_SENDER_SIGNING_KEY to senderSigningKey,
|
||||
FIELD_SENDER_NICKNAME to senderNickname.toByteArray(Charsets.UTF_8),
|
||||
FIELD_TIMESTAMP to GroupTLV.timestampData(timestampMs),
|
||||
FIELD_CONTENT to content.toByteArray(Charsets.UTF_8),
|
||||
FIELD_SIGNATURE to signature
|
||||
)
|
||||
if (key.size != BitchatGroup.KEY_LENGTH ||
|
||||
groupID.size != BitchatGroup.GROUP_ID_LENGTH
|
||||
) {
|
||||
throw GroupCryptoException.SealFailed()
|
||||
}
|
||||
|
||||
return try {
|
||||
val nonce = ByteArray(12).also(random::nextBytes)
|
||||
val aad = concat(groupID, GroupTLV.epochData(epoch))
|
||||
val ciphertext = crypt(encrypt = true, key, nonce, aad, inner)
|
||||
GroupMessageEnvelope(groupID, epoch, nonce, ciphertext).encode()
|
||||
} catch (error: GroupTlvValueTooLongException) {
|
||||
throw error
|
||||
} catch (_: Exception) {
|
||||
throw GroupCryptoException.SealFailed()
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(GroupCryptoException::class)
|
||||
fun openMessage(envelope: GroupMessageEnvelope, key: ByteArray): GroupMessagePlaintext {
|
||||
if (key.size != BitchatGroup.KEY_LENGTH || envelope.ciphertext.size <= 16) {
|
||||
throw GroupCryptoException.DecryptionFailed()
|
||||
}
|
||||
val inner = try {
|
||||
crypt(
|
||||
encrypt = false,
|
||||
key,
|
||||
envelope.nonce,
|
||||
concat(envelope.groupID, GroupTLV.epochData(envelope.epoch)),
|
||||
envelope.ciphertext
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
throw GroupCryptoException.DecryptionFailed()
|
||||
}
|
||||
|
||||
val fields = GroupTLV.parse(inner) ?: throw GroupCryptoException.MalformedPayload()
|
||||
var messageID: String? = null
|
||||
var senderSigningKey: ByteArray? = null
|
||||
var senderNickname: String? = null
|
||||
var timestampMs: Long? = null
|
||||
var content: String? = null
|
||||
var signature: ByteArray? = null
|
||||
fields.forEach { field ->
|
||||
when (field.type) {
|
||||
FIELD_MESSAGE_ID -> messageID = GroupTLV.strictUtf8(field.value)
|
||||
FIELD_SENDER_SIGNING_KEY ->
|
||||
if (field.value.size == 32) senderSigningKey = field.value
|
||||
FIELD_SENDER_NICKNAME -> senderNickname = GroupTLV.strictUtf8(field.value)
|
||||
FIELD_TIMESTAMP -> timestampMs = GroupTLV.timestamp(field.value)
|
||||
FIELD_CONTENT -> content = GroupTLV.strictUtf8(field.value)
|
||||
FIELD_SIGNATURE -> if (field.value.size == 64) signature = field.value
|
||||
}
|
||||
}
|
||||
val decodedMessageID = messageID?.takeIf(::isCanonicalMessageID)
|
||||
?: throw GroupCryptoException.MalformedPayload()
|
||||
val decodedSigningKey = senderSigningKey ?: throw GroupCryptoException.MalformedPayload()
|
||||
val decodedNickname = senderNickname ?: throw GroupCryptoException.MalformedPayload()
|
||||
val decodedTimestamp = timestampMs ?: throw GroupCryptoException.MalformedPayload()
|
||||
val decodedContent = content ?: throw GroupCryptoException.MalformedPayload()
|
||||
val decodedSignature = signature ?: throw GroupCryptoException.MalformedPayload()
|
||||
|
||||
val signingContent = messageSigningContent(
|
||||
envelope.groupID,
|
||||
envelope.epoch,
|
||||
decodedMessageID,
|
||||
decodedTimestamp,
|
||||
decodedContent
|
||||
)
|
||||
if (!verify(decodedSignature, signingContent, decodedSigningKey)) {
|
||||
throw GroupCryptoException.BadSenderSignature()
|
||||
}
|
||||
return GroupMessagePlaintext(
|
||||
decodedMessageID,
|
||||
decodedSigningKey,
|
||||
decodedNickname,
|
||||
decodedTimestamp,
|
||||
decodedContent
|
||||
)
|
||||
}
|
||||
|
||||
private fun isCanonicalMessageID(messageID: String): Boolean {
|
||||
val encoded = messageID.toByteArray(Charsets.UTF_8)
|
||||
if (encoded.size != UUID_TEXT_LENGTH || !UUID_PATTERN.matches(messageID)) return false
|
||||
return try {
|
||||
UUID.fromString(messageID)
|
||||
true
|
||||
} catch (_: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(InvalidCipherTextException::class)
|
||||
private fun crypt(
|
||||
encrypt: Boolean,
|
||||
key: ByteArray,
|
||||
nonce: ByteArray,
|
||||
aad: ByteArray,
|
||||
input: ByteArray
|
||||
): ByteArray {
|
||||
val cipher = ChaCha20Poly1305()
|
||||
cipher.init(encrypt, AEADParameters(KeyParameter(key), 128, nonce, aad))
|
||||
val output = ByteArray(cipher.getOutputSize(input.size))
|
||||
var length = cipher.processBytes(input, 0, input.size, output, 0)
|
||||
length += cipher.doFinal(output, length)
|
||||
return output.copyOf(length)
|
||||
}
|
||||
|
||||
private const val UUID_TEXT_LENGTH = 36
|
||||
private val UUID_PATTERN = 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}$"
|
||||
)
|
||||
}
|
||||
|
||||
internal fun sha256(data: ByteArray): ByteArray =
|
||||
MessageDigest.getInstance("SHA-256").digest(data)
|
||||
|
||||
internal fun concat(vararg arrays: ByteArray): ByteArray {
|
||||
val output = ByteArrayOutputStream(arrays.sumOf(ByteArray::size))
|
||||
arrays.forEach(output::write)
|
||||
return output.toByteArray()
|
||||
}
|
||||
96
app/src/main/java/com/bitchat/android/groups/GroupRuntime.kt
Normal file
96
app/src/main/java/com/bitchat/android/groups/GroupRuntime.kt
Normal file
@ -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<GroupUiDelegate>(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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
552
app/src/main/java/com/bitchat/android/groups/GroupStore.kt
Normal file
552
app/src/main/java/com/bitchat/android/groups/GroupStore.kt
Normal file
@ -0,0 +1,552 @@
|
||||
package com.bitchat.android.groups
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonParser
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.security.SecureRandom
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
internal interface GroupKeyStorage {
|
||||
fun get(key: String): ByteArray?
|
||||
fun put(key: String, value: ByteArray): Boolean
|
||||
fun remove(key: String): Boolean
|
||||
fun clear(): Boolean
|
||||
}
|
||||
|
||||
internal interface GroupMetadataStorage {
|
||||
fun read(): String?
|
||||
fun write(contents: String): Boolean
|
||||
fun delete(): Boolean
|
||||
}
|
||||
|
||||
@SuppressLint("ApplySharedPref", "UseKtx")
|
||||
private class EncryptedPreferencesGroupKeyStorage(context: Context) : GroupKeyStorage {
|
||||
private val preferences: SharedPreferences
|
||||
|
||||
init {
|
||||
val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
preferences = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"bitchat_private_groups",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
override fun get(key: String): ByteArray? = try {
|
||||
preferences.getString(key, null)?.let {
|
||||
Base64.decode(it, Base64.NO_WRAP)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
override fun put(key: String, value: ByteArray): Boolean = try {
|
||||
// The metadata must not advance unless the epoch key is durably stored.
|
||||
preferences.edit()
|
||||
.putString(key, Base64.encodeToString(value, Base64.NO_WRAP))
|
||||
.commit()
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
override fun remove(key: String): Boolean = try {
|
||||
// Removal is a security boundary, so report the synchronous result.
|
||||
preferences.edit().remove(key).commit()
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
override fun clear(): Boolean = try {
|
||||
preferences.edit().clear().commit() && preferences.all.isEmpty()
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private class FileGroupMetadataStorage(private val file: File) : GroupMetadataStorage {
|
||||
override fun read(): String? = try {
|
||||
file.takeIf(File::exists)?.readText(Charsets.UTF_8)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
override fun write(contents: String): Boolean {
|
||||
var temporary: File? = null
|
||||
return try {
|
||||
val parent = file.parentFile
|
||||
if (parent != null && !parent.exists() && !parent.mkdirs()) return false
|
||||
temporary = File(parent, "${file.name}.tmp")
|
||||
FileOutputStream(temporary).use { output ->
|
||||
output.write(contents.toByteArray(Charsets.UTF_8))
|
||||
output.fd.sync()
|
||||
}
|
||||
try {
|
||||
Files.move(
|
||||
temporary.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
} catch (_: AtomicMoveNotSupportedException) {
|
||||
Files.move(
|
||||
temporary.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
}
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
temporary?.delete()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun delete(): Boolean = try {
|
||||
val deleted = !file.exists() || file.delete()
|
||||
if (deleted) {
|
||||
file.parentFile
|
||||
?.takeIf { it.listFiles().isNullOrEmpty() }
|
||||
?.delete()
|
||||
}
|
||||
deleted
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent private-group metadata and epoch keys.
|
||||
*
|
||||
* Metadata is kept in app-private no-backup storage. Symmetric group keys are
|
||||
* stored separately in EncryptedSharedPreferences backed by Android Keystore.
|
||||
*
|
||||
* The Android constructor is intentionally inert. [initialize] performs
|
||||
* Keystore and disk access and must be called from a background dispatcher.
|
||||
*/
|
||||
class GroupStore private constructor(
|
||||
private val keyStorageFactory: () -> GroupKeyStorage,
|
||||
private val metadataStorageFactory: () -> GroupMetadataStorage?,
|
||||
autoInitialize: Boolean
|
||||
) {
|
||||
private data class StoredMember(
|
||||
val fingerprint: String,
|
||||
val signingKey: String,
|
||||
val nickname: String
|
||||
)
|
||||
|
||||
private data class StoredGroup(
|
||||
val groupID: String,
|
||||
val name: String,
|
||||
val epoch: Long,
|
||||
val members: List<StoredMember>,
|
||||
val creatorFingerprint: String
|
||||
)
|
||||
|
||||
private data class StoredState(
|
||||
val version: Int = 1,
|
||||
val groups: List<StoredGroup>,
|
||||
val departures: Map<String, Long>
|
||||
)
|
||||
|
||||
private val lock = Any()
|
||||
private val gson = Gson()
|
||||
private val random by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { SecureRandom() }
|
||||
private val _groups = MutableStateFlow<List<BitchatGroup>>(emptyList())
|
||||
private val departures = mutableMapOf<String, Long>()
|
||||
private var keyStorage: GroupKeyStorage? = null
|
||||
private var metadataStorage: GroupMetadataStorage? = null
|
||||
|
||||
@Volatile
|
||||
private var initialized = false
|
||||
|
||||
val groups: StateFlow<List<BitchatGroup>> = _groups.asStateFlow()
|
||||
val isReady: Boolean
|
||||
get() = initialized
|
||||
|
||||
constructor(context: Context) : this(
|
||||
keyStorageFactory = {
|
||||
EncryptedPreferencesGroupKeyStorage(context.applicationContext)
|
||||
},
|
||||
metadataStorageFactory = {
|
||||
FileGroupMetadataStorage(
|
||||
File(context.applicationContext.noBackupFilesDir, "groups/groups.json")
|
||||
)
|
||||
},
|
||||
autoInitialize = false
|
||||
)
|
||||
|
||||
internal constructor(
|
||||
keyStorage: GroupKeyStorage,
|
||||
metadataFile: File? = null,
|
||||
testOnly: Boolean,
|
||||
autoInitialize: Boolean = true
|
||||
) : this(
|
||||
keyStorageFactory = { keyStorage },
|
||||
metadataStorageFactory = {
|
||||
metadataFile?.let(::FileGroupMetadataStorage)
|
||||
},
|
||||
autoInitialize = autoInitialize
|
||||
) {
|
||||
require(testOnly)
|
||||
}
|
||||
|
||||
internal constructor(
|
||||
keyStorage: GroupKeyStorage,
|
||||
metadataStorage: GroupMetadataStorage,
|
||||
testOnly: Boolean,
|
||||
autoInitialize: Boolean = true
|
||||
) : this(
|
||||
keyStorageFactory = { keyStorage },
|
||||
metadataStorageFactory = { metadataStorage },
|
||||
autoInitialize = autoInitialize
|
||||
) {
|
||||
require(testOnly)
|
||||
}
|
||||
|
||||
init {
|
||||
if (autoInitialize) initialize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes encrypted key storage and loads metadata. Callers using the
|
||||
* Android constructor must invoke this away from the main thread.
|
||||
*/
|
||||
fun initialize(): Boolean = synchronized(lock) {
|
||||
if (initialized) return@synchronized true
|
||||
val keys = try {
|
||||
keyStorageFactory()
|
||||
} catch (error: Exception) {
|
||||
Log.e(TAG, "Failed to initialize private-group key storage", error)
|
||||
return@synchronized false
|
||||
}
|
||||
val metadata = try {
|
||||
metadataStorageFactory()
|
||||
} catch (error: Exception) {
|
||||
Log.e(TAG, "Failed to initialize private-group metadata storage", error)
|
||||
return@synchronized false
|
||||
}
|
||||
keyStorage = keys
|
||||
metadataStorage = metadata
|
||||
loadLocked(keys, metadata)
|
||||
initialized = true
|
||||
true
|
||||
}
|
||||
|
||||
fun group(groupID: ByteArray): BitchatGroup? = synchronized(lock) {
|
||||
_groups.value.firstOrNull { it.groupID.contentEquals(groupID) }?.deepCopy()
|
||||
}
|
||||
|
||||
fun group(peerID: String): BitchatGroup? =
|
||||
GroupIds.groupID(peerID)?.let(::group)
|
||||
|
||||
fun key(groupID: ByteArray): ByteArray? = synchronized(lock) {
|
||||
keyStorage
|
||||
?.get(keyName(groupID))
|
||||
?.takeIf { it.size == BitchatGroup.KEY_LENGTH }
|
||||
?.copyOf()
|
||||
}
|
||||
|
||||
fun departureEpoch(groupID: ByteArray): Long? = synchronized(lock) {
|
||||
departures[groupID.hexEncodedString()]
|
||||
}
|
||||
|
||||
fun createGroup(name: String, creator: GroupMember): BitchatGroup? {
|
||||
if (!isReady) return null
|
||||
val groupID = ByteArray(BitchatGroup.GROUP_ID_LENGTH).also(random::nextBytes)
|
||||
val key = ByteArray(BitchatGroup.KEY_LENGTH).also(random::nextBytes)
|
||||
val group = BitchatGroup(
|
||||
groupID = groupID,
|
||||
name = name,
|
||||
epoch = 1,
|
||||
members = listOf(creator),
|
||||
creatorFingerprint = creator.fingerprint
|
||||
)
|
||||
return group.takeIf { upsert(it, key) }
|
||||
}
|
||||
|
||||
fun upsert(group: BitchatGroup, key: ByteArray): Boolean = synchronized(lock) {
|
||||
upsertLocked(group, key, clearDeparture = false)
|
||||
}
|
||||
|
||||
fun acceptInvite(group: BitchatGroup, key: ByteArray): Boolean = synchronized(lock) {
|
||||
upsertLocked(group, key, clearDeparture = true)
|
||||
}
|
||||
|
||||
fun rotateKey(
|
||||
groupID: ByteArray,
|
||||
members: List<GroupMember>
|
||||
): Pair<BitchatGroup, ByteArray>? = synchronized(lock) {
|
||||
if (!initialized) return@synchronized null
|
||||
val existing = _groups.value.firstOrNull { it.groupID.contentEquals(groupID) }
|
||||
?: return@synchronized null
|
||||
val newKey = ByteArray(BitchatGroup.KEY_LENGTH).also(random::nextBytes)
|
||||
val rotated = existing.copy(
|
||||
epoch = (existing.epoch + 1) and BitchatGroup.MAX_EPOCH,
|
||||
members = members.map { it.deepCopy() }
|
||||
)
|
||||
if (!upsertLocked(rotated, newKey, clearDeparture = false)) {
|
||||
return@synchronized null
|
||||
}
|
||||
rotated.deepCopy() to newKey.copyOf()
|
||||
}
|
||||
|
||||
fun removeGroup(groupID: ByteArray): Boolean = synchronized(lock) {
|
||||
removeLocked(groupID, departureEpoch = null)
|
||||
}
|
||||
|
||||
fun removeGroupForState(groupID: ByteArray, stateEpoch: Long): BitchatGroup? =
|
||||
synchronized(lock) {
|
||||
if (stateEpoch !in 0..BitchatGroup.MAX_EPOCH) return@synchronized null
|
||||
val existing = _groups.value.firstOrNull { it.groupID.contentEquals(groupID) }
|
||||
?: return@synchronized null
|
||||
if (stateEpoch < existing.epoch) return@synchronized null
|
||||
if (!removeLocked(groupID, departureEpoch = null)) return@synchronized null
|
||||
existing.deepCopy()
|
||||
}
|
||||
|
||||
fun departGroup(groupID: ByteArray, epoch: Long): Boolean = synchronized(lock) {
|
||||
if (epoch !in 0..BitchatGroup.MAX_EPOCH) return@synchronized false
|
||||
removeLocked(groupID, departureEpoch = epoch)
|
||||
}
|
||||
|
||||
fun wipe(): Boolean = synchronized(lock) {
|
||||
if (!initialized && !initialize()) return@synchronized false
|
||||
val keys = keyStorage ?: return@synchronized false
|
||||
val keysCleared = keys.clear()
|
||||
val metadataDeleted = metadataStorage?.delete() ?: true
|
||||
_groups.value = emptyList()
|
||||
departures.clear()
|
||||
keysCleared && metadataDeleted
|
||||
}
|
||||
|
||||
private fun upsertLocked(
|
||||
group: BitchatGroup,
|
||||
key: ByteArray,
|
||||
clearDeparture: Boolean
|
||||
): Boolean {
|
||||
val keys = keyStorage ?: return false
|
||||
if (!initialized || !isValid(group, key)) return false
|
||||
|
||||
val updatedGroups = _groups.value.toMutableList()
|
||||
val index = updatedGroups.indexOfFirst { it.groupID.contentEquals(group.groupID) }
|
||||
if (index >= 0 && group.epoch < updatedGroups[index].epoch) return false
|
||||
|
||||
if (index >= 0) {
|
||||
updatedGroups[index] = group.deepCopy()
|
||||
} else {
|
||||
updatedGroups += group.deepCopy()
|
||||
}
|
||||
val updatedDepartures = departures.toMutableMap()
|
||||
val groupID = group.groupID.hexEncodedString()
|
||||
val departureEpoch = updatedDepartures[groupID]
|
||||
if (clearDeparture) {
|
||||
if (departureEpoch != null && group.epoch <= departureEpoch) return false
|
||||
updatedDepartures.remove(groupID)
|
||||
} else if (departureEpoch != null) {
|
||||
return false
|
||||
}
|
||||
|
||||
val name = keyName(group.groupID)
|
||||
val previousKey = keys.get(name)?.copyOf()
|
||||
if (!keys.put(name, key.copyOf())) {
|
||||
Log.e(TAG, "Failed to store private-group epoch key")
|
||||
return false
|
||||
}
|
||||
if (!persistLocked(updatedGroups, updatedDepartures)) {
|
||||
restoreKey(keys, name, previousKey)
|
||||
return false
|
||||
}
|
||||
|
||||
departures.clear()
|
||||
departures.putAll(updatedDepartures)
|
||||
_groups.value = updatedGroups.map { it.deepCopy() }
|
||||
return true
|
||||
}
|
||||
|
||||
private fun removeLocked(groupID: ByteArray, departureEpoch: Long?): Boolean {
|
||||
val keys = keyStorage ?: return false
|
||||
if (!initialized) return false
|
||||
|
||||
val updatedGroups = _groups.value.filterNot { it.groupID.contentEquals(groupID) }
|
||||
val updatedDepartures = departures.toMutableMap()
|
||||
if (departureEpoch != null) {
|
||||
val id = groupID.hexEncodedString()
|
||||
updatedDepartures[id] = maxOf(updatedDepartures[id] ?: -1, departureEpoch)
|
||||
}
|
||||
|
||||
val name = keyName(groupID)
|
||||
val previousKey = keys.get(name)?.copyOf()
|
||||
if (previousKey != null && !keys.remove(name)) {
|
||||
Log.e(TAG, "Failed to remove private-group epoch key")
|
||||
return false
|
||||
}
|
||||
if (!persistLocked(updatedGroups, updatedDepartures)) {
|
||||
restoreKey(keys, name, previousKey)
|
||||
return false
|
||||
}
|
||||
|
||||
departures.clear()
|
||||
departures.putAll(updatedDepartures)
|
||||
_groups.value = updatedGroups.map { it.deepCopy() }
|
||||
return true
|
||||
}
|
||||
|
||||
private fun restoreKey(
|
||||
storage: GroupKeyStorage,
|
||||
name: String,
|
||||
previousKey: ByteArray?
|
||||
) {
|
||||
val restored = if (previousKey == null) {
|
||||
storage.remove(name)
|
||||
} else {
|
||||
storage.put(name, previousKey)
|
||||
}
|
||||
if (!restored && previousKey != null) {
|
||||
Log.e(TAG, "Failed to restore private-group epoch key after metadata failure")
|
||||
}
|
||||
}
|
||||
|
||||
private fun isValid(group: BitchatGroup, key: ByteArray): Boolean =
|
||||
group.groupID.size == BitchatGroup.GROUP_ID_LENGTH &&
|
||||
key.size == BitchatGroup.KEY_LENGTH &&
|
||||
group.epoch in 0..BitchatGroup.MAX_EPOCH &&
|
||||
group.members.isNotEmpty() &&
|
||||
group.members.size <= BitchatGroup.MAX_MEMBERS &&
|
||||
group.creator != null &&
|
||||
group.members.all {
|
||||
it.fingerprint.matches(Regex("^[0-9a-fA-F]{64}$")) &&
|
||||
it.signingKey.size == 32
|
||||
}
|
||||
|
||||
private fun persistLocked(
|
||||
groups: List<BitchatGroup>,
|
||||
departures: Map<String, Long>
|
||||
): Boolean {
|
||||
val storage = metadataStorage ?: return true
|
||||
if (groups.isEmpty() && departures.isEmpty()) return storage.delete()
|
||||
|
||||
val state = StoredState(
|
||||
groups = groups.map { group ->
|
||||
StoredGroup(
|
||||
groupID = Base64.encodeToString(group.groupID, Base64.NO_WRAP),
|
||||
name = group.name,
|
||||
epoch = group.epoch,
|
||||
members = group.members.map { member ->
|
||||
StoredMember(
|
||||
fingerprint = member.fingerprint,
|
||||
signingKey = Base64.encodeToString(
|
||||
member.signingKey,
|
||||
Base64.NO_WRAP
|
||||
),
|
||||
nickname = member.nickname
|
||||
)
|
||||
},
|
||||
creatorFingerprint = group.creatorFingerprint
|
||||
)
|
||||
},
|
||||
departures = departures.toSortedMap()
|
||||
)
|
||||
val persisted = storage.write(gson.toJson(state))
|
||||
if (!persisted) Log.e(TAG, "Failed to persist private-group metadata")
|
||||
return persisted
|
||||
}
|
||||
|
||||
private fun loadLocked(
|
||||
keys: GroupKeyStorage,
|
||||
metadata: GroupMetadataStorage?
|
||||
) {
|
||||
val raw = metadata?.read() ?: return
|
||||
val parsed = try {
|
||||
val json = JsonParser.parseString(raw)
|
||||
if (json.isJsonArray) {
|
||||
val type = object : TypeToken<List<StoredGroup>>() {}.type
|
||||
StoredState(groups = gson.fromJson(json, type), departures = emptyMap())
|
||||
} else {
|
||||
val objectValue = json.asJsonObject
|
||||
val groupType = object : TypeToken<List<StoredGroup>>() {}.type
|
||||
val departureType = object : TypeToken<Map<String, Long>>() {}.type
|
||||
StoredState(
|
||||
version = objectValue.get("version")?.asInt ?: 1,
|
||||
groups = objectValue.get("groups")?.let {
|
||||
gson.fromJson(it, groupType)
|
||||
} ?: emptyList(),
|
||||
departures = objectValue.get("departures")?.let {
|
||||
gson.fromJson(it, departureType)
|
||||
} ?: emptyMap()
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return
|
||||
|
||||
departures.clear()
|
||||
parsed.departures.forEach { (groupID, epoch) ->
|
||||
if (GROUP_ID_HEX.matches(groupID) && epoch in 0..BitchatGroup.MAX_EPOCH) {
|
||||
departures[groupID.lowercase()] = epoch
|
||||
}
|
||||
}
|
||||
|
||||
_groups.value = parsed.groups.mapNotNull { item ->
|
||||
try {
|
||||
val group = BitchatGroup(
|
||||
groupID = Base64.decode(item.groupID, Base64.NO_WRAP),
|
||||
name = item.name,
|
||||
epoch = item.epoch,
|
||||
members = item.members.map { member ->
|
||||
GroupMember(
|
||||
member.fingerprint,
|
||||
Base64.decode(member.signingKey, Base64.NO_WRAP),
|
||||
member.nickname
|
||||
)
|
||||
},
|
||||
creatorFingerprint = item.creatorFingerprint
|
||||
)
|
||||
if (departures.containsKey(group.groupID.hexEncodedString())) {
|
||||
keys.remove(keyName(group.groupID))
|
||||
return@mapNotNull null
|
||||
}
|
||||
val storedKey = keys.get(keyName(group.groupID))
|
||||
?.takeIf { it.size == BitchatGroup.KEY_LENGTH }
|
||||
?: return@mapNotNull null
|
||||
group.takeIf { isValid(it, storedKey) }?.deepCopy()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun keyName(groupID: ByteArray): String =
|
||||
"groupKey-${groupID.hexEncodedString()}"
|
||||
|
||||
private fun BitchatGroup.deepCopy(): BitchatGroup = copy(
|
||||
groupID = groupID.copyOf(),
|
||||
members = members.map { it.deepCopy() }
|
||||
)
|
||||
|
||||
private fun GroupMember.deepCopy(): GroupMember =
|
||||
copy(signingKey = signingKey.copyOf())
|
||||
|
||||
companion object {
|
||||
private const val TAG = "GroupStore"
|
||||
private val GROUP_ID_HEX = Regex("^[0-9a-fA-F]{32}$")
|
||||
}
|
||||
}
|
||||
@ -10,8 +10,11 @@ import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.AuthenticatedPeerState
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.model.VouchAttestation
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import androidx.core.content.edit
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
/**
|
||||
* Manages persistent identity storage and peer ID rotation - 100% compatible with iOS implementation
|
||||
@ -37,22 +40,38 @@ class SecureIdentityStateManager {
|
||||
private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames"
|
||||
private const val KEY_PRIVATE_MEDIA_CAPABILITY_PINS = "private_media_capability_pins_v1"
|
||||
private const val KEY_AUTHENTICATED_PEER_STATES = "authenticated_peer_states_v1"
|
||||
private const val KEY_VOUCH_RECORDS = "vouch_records_v1"
|
||||
private const val KEY_VOUCH_BATCH_SENT_AT = "vouch_batch_sent_at_v1"
|
||||
private const val KEY_VERIFIED_AT = "verified_at_v1"
|
||||
const val MAX_VOUCHERS_PER_VOUCHEE = 8
|
||||
private const val VOUCH_RECORD_FIELD_COUNT = 4
|
||||
private const val RECORD_SEPARATOR = ':'
|
||||
private const val RECORD_SEPARATOR_LENGTH = 1
|
||||
private const val VOUCHEE_FIELD_INDEX = 0
|
||||
private const val VOUCHER_FIELD_INDEX = 1
|
||||
private const val VOUCHEE_SIGNING_KEY_FIELD_INDEX = 2
|
||||
private const val INDEX_NOT_FOUND = -1
|
||||
private const val IDENTITY_EVENT_BUFFER_CAPACITY = 1
|
||||
private const val EXPIRY_TRANSITION_OFFSET_MS = 1L
|
||||
|
||||
// BLE, Wi-Fi Aware, and Noise services each hold their own manager
|
||||
// instance over the same encrypted preferences. Serialize pin updates
|
||||
// process-wide so concurrent promotions cannot lose one another or
|
||||
// race a panic wipe.
|
||||
private val privateMediaPinsLock = Any()
|
||||
private var privateMediaPinsEpoch = 0L
|
||||
private val identityPersistenceLock = Any()
|
||||
private var identityPersistenceEpoch = 0L
|
||||
private val identityChanges =
|
||||
MutableSharedFlow<Unit>(extraBufferCapacity = IDENTITY_EVENT_BUFFER_CAPACITY)
|
||||
val changes = identityChanges.asSharedFlow()
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences
|
||||
private val lock = Any()
|
||||
private var privateMediaPinsEpochAtCreation: Long
|
||||
private var identityPersistenceEpochAtCreation: Long
|
||||
|
||||
constructor(context: Context) {
|
||||
privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) {
|
||||
privateMediaPinsEpoch
|
||||
identityPersistenceEpochAtCreation = synchronized(identityPersistenceLock) {
|
||||
identityPersistenceEpoch
|
||||
}
|
||||
// Create master key for encryption
|
||||
val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||
@ -72,8 +91,8 @@ class SecureIdentityStateManager {
|
||||
/** Test-only storage injection; production always uses encrypted prefs. */
|
||||
internal constructor(prefs: SharedPreferences, testOnly: Boolean) {
|
||||
require(testOnly) { "Plain SharedPreferences are test-only" }
|
||||
privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) {
|
||||
privateMediaPinsEpoch
|
||||
identityPersistenceEpochAtCreation = synchronized(identityPersistenceLock) {
|
||||
identityPersistenceEpoch
|
||||
}
|
||||
this.prefs = prefs
|
||||
}
|
||||
@ -223,19 +242,197 @@ class SecureIdentityStateManager {
|
||||
return getVerifiedFingerprints().contains(fingerprint)
|
||||
}
|
||||
|
||||
@SuppressLint("UseKtx")
|
||||
fun setVerifiedFingerprint(fingerprint: String, verified: Boolean) {
|
||||
if (!isValidFingerprint(fingerprint)) return
|
||||
synchronized(lock) {
|
||||
val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
|
||||
val normalizedFingerprint = fingerprint.lowercase()
|
||||
synchronized(identityPersistenceLock) {
|
||||
if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return
|
||||
val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())
|
||||
?.mapTo(mutableSetOf()) { it.lowercase() } ?: mutableSetOf()
|
||||
if (verified) {
|
||||
current.add(fingerprint)
|
||||
current.add(normalizedFingerprint)
|
||||
} else {
|
||||
current.remove(fingerprint)
|
||||
current.remove(normalizedFingerprint)
|
||||
}
|
||||
prefs.edit { putStringSet(KEY_VERIFIED_FINGERPRINTS, current) }
|
||||
val verifiedAt = readTimestampMap(KEY_VERIFIED_AT).toMutableMap()
|
||||
if (verified) verifiedAt[normalizedFingerprint] = System.currentTimeMillis()
|
||||
else verifiedAt.remove(normalizedFingerprint)
|
||||
val committed = prefs.edit()
|
||||
.putStringSet(KEY_VERIFIED_FINGERPRINTS, current)
|
||||
.putStringSet(KEY_VERIFIED_AT, encodeTimestampMap(verifiedAt))
|
||||
.commit()
|
||||
if (!committed) return
|
||||
identityChanges.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
data class VouchRecord(
|
||||
val voucherFingerprint: String,
|
||||
val voucheeSigningKeyHex: String,
|
||||
val timestampMs: Long
|
||||
)
|
||||
|
||||
@SuppressLint("UseKtx")
|
||||
fun recordVouch(
|
||||
voucheeFingerprint: String,
|
||||
voucherFingerprint: String,
|
||||
voucheeSigningKey: ByteArray,
|
||||
timestampMs: Long,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): Boolean {
|
||||
val vouchee = voucheeFingerprint.lowercase()
|
||||
val voucher = voucherFingerprint.lowercase()
|
||||
if (!isValidFingerprint(vouchee) || !isValidFingerprint(voucher) ||
|
||||
voucheeSigningKey.size != VouchAttestation.SIGNING_KEY_SIZE
|
||||
) return false
|
||||
synchronized(identityPersistenceLock) {
|
||||
if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return false
|
||||
val verified = getVerifiedFingerprints().mapTo(mutableSetOf()) { it.lowercase() }
|
||||
val age = nowMs - timestampMs
|
||||
if (vouchee == voucher || voucher !in verified || vouchee in verified ||
|
||||
age > VouchAttestation.MAX_AGE_MS ||
|
||||
age < -VouchAttestation.MAX_CLOCK_SKEW_MS
|
||||
) return false
|
||||
|
||||
val all = readVouchRecords().toMutableMap()
|
||||
val records = all[vouchee].orEmpty().toMutableList()
|
||||
val existing = records.indexOfFirst { it.voucherFingerprint == voucher }
|
||||
val proposed = VouchRecord(
|
||||
voucherFingerprint = voucher,
|
||||
voucheeSigningKeyHex = voucheeSigningKey.hexEncodedString(),
|
||||
timestampMs = timestampMs
|
||||
)
|
||||
val record = records.getOrNull(existing)
|
||||
?.takeIf { it.timestampMs >= timestampMs }
|
||||
?: proposed
|
||||
if (existing > INDEX_NOT_FOUND) records[existing] = record else records += record
|
||||
val capped = records.sortedByDescending { it.timestampMs }.take(MAX_VOUCHERS_PER_VOUCHEE)
|
||||
if (capped.none { it.voucherFingerprint == voucher }) return false
|
||||
all[vouchee] = capped
|
||||
val committed = prefs.edit()
|
||||
.putStringSet(KEY_VOUCH_RECORDS, encodeVouchRecords(all))
|
||||
.commit()
|
||||
if (!committed) return false
|
||||
identityChanges.tryEmit(Unit)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fun validVouchers(
|
||||
fingerprint: String,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): List<VouchRecord> {
|
||||
val normalized = fingerprint.lowercase()
|
||||
if (!isValidFingerprint(normalized)) return emptyList()
|
||||
synchronized(identityPersistenceLock) {
|
||||
if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return emptyList()
|
||||
val verified = getVerifiedFingerprints().mapTo(mutableSetOf()) { it.lowercase() }
|
||||
val authenticatedSigningKeyHex = getAuthenticatedSigningKey(normalized)
|
||||
?.hexEncodedString() ?: return emptyList()
|
||||
return readVouchRecords()[normalized].orEmpty().filter {
|
||||
it.voucherFingerprint != normalized &&
|
||||
it.voucherFingerprint in verified &&
|
||||
it.voucheeSigningKeyHex == authenticatedSigningKeyHex &&
|
||||
nowMs - it.timestampMs <= VouchAttestation.MAX_AGE_MS &&
|
||||
nowMs - it.timestampMs >= -VouchAttestation.MAX_CLOCK_SKEW_MS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isVouched(fingerprint: String, nowMs: Long = System.currentTimeMillis()): Boolean {
|
||||
val normalized = fingerprint.lowercase()
|
||||
val isExplicitlyVerified = getVerifiedFingerprints().any {
|
||||
it.equals(normalized, ignoreCase = true)
|
||||
}
|
||||
return !isExplicitlyVerified && validVouchers(normalized, nowMs).isNotEmpty()
|
||||
}
|
||||
|
||||
fun getVouchedFingerprints(nowMs: Long = System.currentTimeMillis()): Set<String> =
|
||||
synchronized(identityPersistenceLock) {
|
||||
readVouchRecords().keys.filterTo(mutableSetOf()) { isVouched(it, nowMs) }
|
||||
}
|
||||
|
||||
fun nextVouchExpiryMs(nowMs: Long = System.currentTimeMillis()): Long? =
|
||||
synchronized(identityPersistenceLock) {
|
||||
readVouchRecords().keys
|
||||
.flatMap { validVouchers(it, nowMs) }
|
||||
.minOfOrNull {
|
||||
it.timestampMs +
|
||||
VouchAttestation.MAX_AGE_MS +
|
||||
EXPIRY_TRANSITION_OFFSET_MS
|
||||
}
|
||||
}
|
||||
|
||||
fun mostRecentlyVerifiedFingerprints(limit: Int, excluding: String): List<String> {
|
||||
return synchronized(identityPersistenceLock) {
|
||||
val verifiedAt = readTimestampMap(KEY_VERIFIED_AT)
|
||||
getVerifiedFingerprints()
|
||||
.map { it.lowercase() }
|
||||
.filterNot { it == excluding.lowercase() }
|
||||
.sortedWith(compareByDescending<String> { verifiedAt[it] ?: Long.MIN_VALUE }.thenByDescending { it })
|
||||
.take(limit)
|
||||
}
|
||||
}
|
||||
|
||||
fun lastVouchBatchSent(fingerprint: String): Long? =
|
||||
synchronized(identityPersistenceLock) {
|
||||
readTimestampMap(KEY_VOUCH_BATCH_SENT_AT)[fingerprint.lowercase()]
|
||||
}
|
||||
|
||||
@SuppressLint("UseKtx")
|
||||
fun markVouchBatchSent(fingerprint: String, timestampMs: Long) {
|
||||
synchronized(identityPersistenceLock) {
|
||||
if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return
|
||||
val sent = readTimestampMap(KEY_VOUCH_BATCH_SENT_AT).toMutableMap()
|
||||
sent[fingerprint.lowercase()] = timestampMs
|
||||
if (!prefs.edit()
|
||||
.putStringSet(KEY_VOUCH_BATCH_SENT_AT, encodeTimestampMap(sent))
|
||||
.commit()
|
||||
) {
|
||||
Log.e(TAG, "Vouch batch timestamp could not be committed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readTimestampMap(key: String): Map<String, Long> =
|
||||
prefs.getStringSet(key, emptySet()).orEmpty().mapNotNull { entry ->
|
||||
val split = entry.lastIndexOf(RECORD_SEPARATOR)
|
||||
if (split <= VOUCHEE_FIELD_INDEX) {
|
||||
null
|
||||
} else {
|
||||
entry.substring(split + RECORD_SEPARATOR_LENGTH).toLongOrNull()?.let {
|
||||
entry.substring(VOUCHEE_FIELD_INDEX, split) to it
|
||||
}
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
private fun encodeTimestampMap(values: Map<String, Long>): Set<String> =
|
||||
values.mapTo(mutableSetOf()) { (fingerprint, timestamp) ->
|
||||
"$fingerprint$RECORD_SEPARATOR$timestamp"
|
||||
}
|
||||
|
||||
private fun readVouchRecords(): Map<String, List<VouchRecord>> =
|
||||
prefs.getStringSet(KEY_VOUCH_RECORDS, emptySet()).orEmpty().mapNotNull { entry ->
|
||||
val fields = entry.split(RECORD_SEPARATOR)
|
||||
if (fields.size != VOUCH_RECORD_FIELD_COUNT) null else fields.last().toLongOrNull()?.let {
|
||||
fields[VOUCHEE_FIELD_INDEX] to VouchRecord(
|
||||
voucherFingerprint = fields[VOUCHER_FIELD_INDEX],
|
||||
voucheeSigningKeyHex = fields[VOUCHEE_SIGNING_KEY_FIELD_INDEX],
|
||||
timestampMs = it
|
||||
)
|
||||
}
|
||||
}.groupBy({ it.first }, { it.second })
|
||||
|
||||
private fun encodeVouchRecords(values: Map<String, List<VouchRecord>>): Set<String> =
|
||||
values.flatMapTo(mutableSetOf()) { (vouchee, records) ->
|
||||
records.map {
|
||||
"$vouchee$RECORD_SEPARATOR${it.voucherFingerprint}" +
|
||||
"$RECORD_SEPARATOR${it.voucheeSigningKeyHex}" +
|
||||
"$RECORD_SEPARATOR${it.timestampMs}"
|
||||
}
|
||||
}
|
||||
|
||||
fun getCachedPeerFingerprint(peerID: String): String? {
|
||||
val pid = peerID.lowercase()
|
||||
// Reading is safe without lock for SharedPreferences, but synchronizing ensures memory visibility
|
||||
@ -323,8 +520,8 @@ class SecureIdentityStateManager {
|
||||
|
||||
fun isPrivateMediaCapable(fingerprint: String): Boolean {
|
||||
if (!isValidFingerprint(fingerprint)) return false
|
||||
return synchronized(privateMediaPinsLock) {
|
||||
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) {
|
||||
return synchronized(identityPersistenceLock) {
|
||||
if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) {
|
||||
return@synchronized false
|
||||
}
|
||||
prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet())
|
||||
@ -339,11 +536,13 @@ class SecureIdentityStateManager {
|
||||
state: AuthenticatedPeerState,
|
||||
onCommitted: () -> Unit = {}
|
||||
): Boolean {
|
||||
if (!isValidFingerprint(fingerprint) || state.signingPublicKey.size != 32) return false
|
||||
if (!isValidFingerprint(fingerprint) ||
|
||||
state.signingPublicKey.size != VouchAttestation.SIGNING_KEY_SIZE
|
||||
) return false
|
||||
val normalizedFingerprint = fingerprint.lowercase()
|
||||
return synchronized(privateMediaPinsLock) {
|
||||
return synchronized(identityPersistenceLock) {
|
||||
// A controller that survived panic must not republish pre-wipe proof state.
|
||||
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized false
|
||||
if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return@synchronized false
|
||||
val records = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet())
|
||||
?.toMutableSet() ?: mutableSetOf()
|
||||
records.removeAll { it.startsWith("$normalizedFingerprint:") }
|
||||
@ -362,15 +561,18 @@ class SecureIdentityStateManager {
|
||||
// This result is a security boundary: do not publish the Ed key in memory unless the
|
||||
// encrypted identity record and its HSTS pin were durably committed together.
|
||||
editor.commit().also { committed ->
|
||||
if (committed) onCommitted()
|
||||
if (committed) {
|
||||
identityChanges.tryEmit(Unit)
|
||||
onCommitted()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getAuthenticatedPeerState(fingerprint: String): AuthenticatedPeerState? {
|
||||
if (!isValidFingerprint(fingerprint)) return null
|
||||
return synchronized(privateMediaPinsLock) {
|
||||
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized null
|
||||
return synchronized(identityPersistenceLock) {
|
||||
if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return@synchronized null
|
||||
val prefix = "${fingerprint.lowercase()}:"
|
||||
val record = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet())
|
||||
?.firstOrNull { it.startsWith(prefix) } ?: return@synchronized null
|
||||
@ -466,21 +668,18 @@ class SecureIdentityStateManager {
|
||||
* Clear all identity data (for panic mode)
|
||||
*/
|
||||
@SuppressLint("UseKtx")
|
||||
fun clearIdentityData() {
|
||||
try {
|
||||
synchronized(privateMediaPinsLock) {
|
||||
privateMediaPinsEpoch += 1
|
||||
privateMediaPinsEpochAtCreation = privateMediaPinsEpoch
|
||||
if (!prefs.edit().clear().commit()) {
|
||||
Log.e(TAG, "Identity preference wipe could not be committed")
|
||||
}
|
||||
}
|
||||
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
|
||||
*/
|
||||
@ -493,6 +692,10 @@ class SecureIdentityStateManager {
|
||||
/**
|
||||
* Store a string value in secure preferences
|
||||
*/
|
||||
/** Persist keys and consumption state before making their use observable. */
|
||||
fun storeSecureValueSynchronously(key: String, value: String): Boolean =
|
||||
prefs.edit().putString(key, value).commit()
|
||||
|
||||
fun storeSecureValue(key: String, value: String) {
|
||||
prefs.edit().putString(key, value).apply()
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import com.bitchat.android.protocol.MessageType
|
||||
object BLEPacketPaddingPolicy {
|
||||
fun shouldPadForBLE(type: UByte): Boolean {
|
||||
return when (MessageType.fromValue(type)) {
|
||||
MessageType.NOISE_ENCRYPTED, MessageType.NOISE_HANDSHAKE -> true
|
||||
MessageType.NOISE_ENCRYPTED, MessageType.NOISE_HANDSHAKE, MessageType.COURIER_ENVELOPE -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@ -367,6 +367,26 @@ class BluetoothConnectionManager(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun sendToPeerAndAwaitAcceptance(peerID: String, routed: RoutedPacket): Boolean {
|
||||
if (!isActive || !isBleTransportEnabled()) return false
|
||||
return packetBroadcaster.sendPacketToPeerAndAwaitAcceptance(
|
||||
routed,
|
||||
peerID,
|
||||
serverManager.getGattServer(),
|
||||
serverManager.getCharacteristic()
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun sendToPeerAndAwaitCompletion(peerID: String, routed: RoutedPacket): Boolean {
|
||||
if (!isActive || !isBleTransportEnabled()) return false
|
||||
return packetBroadcaster.sendPacketToPeerAndAwaitCompletion(
|
||||
routed,
|
||||
peerID,
|
||||
serverManager.getGattServer(),
|
||||
serverManager.getCharacteristic()
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelTransfer(transferId: String): Boolean {
|
||||
return packetBroadcaster.cancelTransfer(transferId)
|
||||
}
|
||||
|
||||
@ -2,13 +2,13 @@ package com.bitchat.android.mesh
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.board.transportSenderID
|
||||
import com.bitchat.android.crypto.EncryptionService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.AuthenticatedPeerState
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.protocol.MessagePadding
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
@ -19,6 +19,8 @@ import com.bitchat.android.sync.GossipSyncManager
|
||||
import com.bitchat.android.util.toHexString
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.bitchat.android.service.TransportBridgeService
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.services.bridge.BridgeProtocolPacketFactory
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import java.util.*
|
||||
@ -49,10 +51,17 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// Core components - each handling specific responsibilities
|
||||
private val encryptionService = EncryptionService(context)
|
||||
private val courierStore = CourierStore(context)
|
||||
private val bridgeCourierService by lazy {
|
||||
com.bitchat.android.nostr.BridgeCourierService(context, encryptionService) { envelope ->
|
||||
handleLocalCourierEnvelope(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
// My peer identification - derived from persisted Noise identity fingerprint (first 16 hex chars)
|
||||
val myPeerID: String = encryptionService.getIdentityFingerprint().take(16)
|
||||
private val peerManager = PeerManager()
|
||||
private val identityState = SecureIdentityStateManager(context.applicationContext)
|
||||
private val fragmentManager = FragmentManager()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val readReceiptRetrySender = RetryingControlPacketSender(serviceScope)
|
||||
@ -65,13 +74,14 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
store = authenticatedPeerStateStore,
|
||||
localStateProvider = {
|
||||
AuthenticatedPeerState(
|
||||
PeerCapabilities.LOCAL_SUPPORTED,
|
||||
PeerCapabilities.localSupported(),
|
||||
requireNotNull(encryptionService.getSigningPublicKey())
|
||||
)
|
||||
},
|
||||
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(
|
||||
@ -109,11 +119,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
private val securityManager = SecurityManager(encryptionService, myPeerID)
|
||||
private val storeForwardManager = StoreForwardManager()
|
||||
private val boardStore = com.bitchat.android.board.BoardStore.getInstance(context)
|
||||
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
|
||||
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
|
||||
private val packetProcessor = PacketProcessor(myPeerID)
|
||||
private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray)
|
||||
private val voiceFrameQueue = Channel<VoiceFrameRequest>(capacity = 128)
|
||||
private val meshPingManager = MeshPingManager(myPeerID, serviceScope) { packet ->
|
||||
broadcastRoutedPacket(RoutedPacket(packet))
|
||||
}
|
||||
private lateinit var gossipSyncManager: GossipSyncManager
|
||||
// Service-level notification manager for background (no-UI) DMs
|
||||
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
|
||||
@ -130,6 +144,20 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
// Coroutines
|
||||
// Tracks whether this instance has been terminated via stopServices()
|
||||
private var terminated = false
|
||||
private val vouchCoordinator by lazy {
|
||||
VouchCoordinator(
|
||||
scope = serviceScope,
|
||||
identity = identityState,
|
||||
connectedPeerIDs = peerManager::getActivePeerIDs,
|
||||
fingerprintForPeer = peerManager::getFingerprintForPeer,
|
||||
peerInfo = peerManager::getPeerInfo,
|
||||
signingKeyForFingerprint = ::signingKeyForFingerprint,
|
||||
hasEstablishedSession = encryptionService::hasEstablishedSession,
|
||||
sign = encryptionService::signData,
|
||||
verify = encryptionService::verifyEd25519Signature,
|
||||
send = ::sendVouchPayload
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
serviceScope.launch {
|
||||
@ -156,10 +184,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
gossipSyncManager = GossipSyncManager(
|
||||
myPeerID = myPeerID,
|
||||
scope = serviceScope,
|
||||
context = context,
|
||||
configProvider = object : GossipSyncManager.ConfigProvider {
|
||||
override fun seenCapacity(): Int = try {
|
||||
com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(500)
|
||||
} catch (_: Exception) { 500 }
|
||||
com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(1000)
|
||||
} catch (_: Exception) { 1000 }
|
||||
|
||||
override fun gcsMaxBytes(): Int = try {
|
||||
com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsMaxFilterBytes(400)
|
||||
@ -170,6 +199,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
} catch (_: Exception) { 0.01 }
|
||||
}
|
||||
)
|
||||
gossipSyncManager.boardPacketsProvider = boardStore::syncCandidates
|
||||
|
||||
com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet ->
|
||||
signPacketBeforeBroadcast(packet)
|
||||
@ -177,6 +207,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
if (isBleTransportEnabled()) {
|
||||
TransportBridgeService.register("BLE", this)
|
||||
}
|
||||
serviceScope.launch {
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.isEnabled.collect { enabled ->
|
||||
if (enabled) bridgeCourierService.start() else bridgeCourierService.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Inject dynamic direct connection check into PeerManager
|
||||
// Matches iOS logic: checks if we have an active hardware mapping for this peer
|
||||
@ -200,6 +235,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
connectionManager.sendPacketToPeer(peerID, packet)
|
||||
}
|
||||
|
||||
override fun sendToPeerAndReport(peerID: String, packet: BitchatPacket): Boolean {
|
||||
if (!isBleTransportEnabled()) return false
|
||||
return connectionManager.sendPacketToPeer(peerID, packet)
|
||||
}
|
||||
|
||||
private fun broadcastRoutedPacket(routed: RoutedPacket): Boolean {
|
||||
if (!isBleTransportEnabled()) return false
|
||||
val queued = connectionManager.broadcastPacket(routed)
|
||||
@ -259,6 +299,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
override fun onPeerListUpdated(peerIDs: List<String>) {
|
||||
// Update process-wide state first
|
||||
try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peerIDs) } catch (_: Exception) { }
|
||||
vouchCoordinator.peersUpdated(peerIDs)
|
||||
// Then notify UI delegate if attached
|
||||
delegate?.didUpdatePeerList(peerIDs)
|
||||
}
|
||||
@ -291,6 +332,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
authenticatedRemoteStaticKey,
|
||||
authenticatedSessionToken
|
||||
)
|
||||
vouchCoordinator.peerAuthenticated(
|
||||
peerID,
|
||||
identityState.generateFingerprint(authenticatedRemoteStaticKey)
|
||||
)
|
||||
// Send announcement and cached messages after key exchange
|
||||
serviceScope.launch {
|
||||
delay(100)
|
||||
@ -396,7 +441,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
override fun getBroadcastRecipient(): ByteArray {
|
||||
return SpecialRecipients.BROADCAST
|
||||
}
|
||||
|
||||
|
||||
// Cryptographic operations
|
||||
override fun verifySignature(packet: BitchatPacket, peerID: String): Boolean {
|
||||
return securityManager.verifySignature(packet, peerID)
|
||||
@ -514,6 +559,8 @@ 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.
|
||||
try {
|
||||
@ -526,6 +573,8 @@ 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(
|
||||
messageID,
|
||||
@ -542,6 +591,30 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun onGroupInviteReceived(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
GroupMessagePort.receiver?.invite(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun onGroupKeyUpdateReceived(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
GroupMessagePort.receiver?.keyUpdate(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {
|
||||
GroupMessagePort.receiver?.message(payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun onVouchPayloadReceived(peerID: String, payload: ByteArray) {
|
||||
vouchCoordinator.handlePayload(peerID, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// PacketProcessor delegates
|
||||
@ -566,6 +639,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
override fun getBroadcastRecipient(): ByteArray {
|
||||
return SpecialRecipients.BROADCAST
|
||||
}
|
||||
|
||||
override fun isPeerDirectlyConnected(peerID: String): Boolean =
|
||||
peerManager.getPeerInfo(peerID)?.isDirectConnection == true
|
||||
|
||||
override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
|
||||
return runBlocking { securityManager.handleNoiseHandshake(routed) }
|
||||
@ -574,6 +650,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean {
|
||||
return runBlocking { messageHandler.handleNoiseEncrypted(routed) }
|
||||
}
|
||||
|
||||
override fun handleCourierEnvelope(routed: RoutedPacket): Boolean {
|
||||
return handleCourierEnvelopePacket(routed)
|
||||
}
|
||||
|
||||
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
|
||||
val result = messageHandler.handleAnnounceWithResult(routed)
|
||||
@ -599,6 +679,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
}
|
||||
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
|
||||
handleCourierAnnounce(routed)
|
||||
return true
|
||||
}
|
||||
|
||||
@ -608,7 +689,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
try {
|
||||
val pkt = routed.packet
|
||||
val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST))
|
||||
if (isBroadcast && pkt.type == MessageType.MESSAGE.value) {
|
||||
if (isBroadcast && pkt.type in setOf(MessageType.MESSAGE.value, MessageType.FILE_TRANSFER.value)) {
|
||||
gossipSyncManager.onPublicPacketSeen(pkt)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
@ -616,6 +697,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
override fun handleVoiceFrame(routed: RoutedPacket): Boolean =
|
||||
messageHandler.handlePublicVoiceFrame(routed)
|
||||
override fun handleGroupMessage(routed: RoutedPacket) {
|
||||
messageHandler.handleGroupMessage(routed)
|
||||
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun handleLeave(routed: RoutedPacket) {
|
||||
serviceScope.launch { messageHandler.handleLeave(routed) }
|
||||
@ -656,6 +741,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
val req = RequestSyncPacket.decode(routed.packet.payload) ?: return
|
||||
gossipSyncManager.handleRequestSync(fromPeer, req)
|
||||
}
|
||||
|
||||
override fun handleBoardPost(routed: RoutedPacket): Boolean {
|
||||
val wire = com.bitchat.android.board.BoardWireCodec.decode(routed.packet.payload)
|
||||
?: return false
|
||||
return boardStore.ingestRemoteForRelay(wire, routed.packet)
|
||||
}
|
||||
override fun handlePing(routed: RoutedPacket) = meshPingManager.handlePing(routed)
|
||||
|
||||
override fun handlePong(routed: RoutedPacket) = meshPingManager.handlePong(routed)
|
||||
}
|
||||
|
||||
// BluetoothConnectionManager delegates
|
||||
@ -746,6 +840,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit) {
|
||||
meshPingManager.ping(peerID, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the mesh service
|
||||
*/
|
||||
@ -809,6 +907,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
* Stop all mesh services
|
||||
*/
|
||||
fun stopServices() {
|
||||
if (bridgeCourierService.isStarted) bridgeCourierService.stop()
|
||||
if (!isActive) {
|
||||
Log.w(TAG, "Mesh service not active, ignoring stop request")
|
||||
return
|
||||
@ -857,6 +956,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
*/
|
||||
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
|
||||
if (content.isEmpty()) return
|
||||
val bridgePolicyAtSend = BridgeMeshPort.outboundPolicy()
|
||||
|
||||
serviceScope.launch {
|
||||
val packet = BitchatPacket(
|
||||
@ -875,6 +975,84 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
// Track our own broadcast message for sync
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
if (channel == null) {
|
||||
val nickname = runCatching {
|
||||
com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID)
|
||||
}.getOrNull()
|
||||
BridgeMeshPort.bridgeOutgoing(
|
||||
content,
|
||||
myPeerID,
|
||||
packet.timestamp.toLong(),
|
||||
nickname,
|
||||
bridgePolicyAtSend
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String?) {
|
||||
sendRawProtocolPacket(MessageType.NOSTR_CARRIER, payload, recipientPeerID, sign = true)
|
||||
}
|
||||
|
||||
fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) {
|
||||
sendRawProtocolPacket(MessageType.COURIER_ENVELOPE, payload, recipientPeerID, sign = true)
|
||||
}
|
||||
|
||||
fun sendPrekeyBundle(payload: ByteArray) {
|
||||
sendRawProtocolPacket(MessageType.PREKEY_BUNDLE, payload, null, sign = true)
|
||||
}
|
||||
|
||||
private fun sendRawProtocolPacket(
|
||||
type: MessageType,
|
||||
payload: ByteArray,
|
||||
recipientPeerID: String?,
|
||||
sign: Boolean
|
||||
) {
|
||||
if (payload.isEmpty()) return
|
||||
serviceScope.launch {
|
||||
val packet = BridgeProtocolPacketFactory.protocolPacket(
|
||||
type = type,
|
||||
payload = payload,
|
||||
senderPeerId = myPeerID,
|
||||
recipientPeerId = recipientPeerID,
|
||||
ttl = MAX_TTL
|
||||
) ?: return@launch
|
||||
val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet
|
||||
if (sign &&
|
||||
type != MessageType.COURIER_ENVELOPE &&
|
||||
outgoing.signature?.size != 64
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
gossipSyncManager.onPublicPacketSeen(outgoing)
|
||||
broadcastRoutedPacket(RoutedPacket(outgoing))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun sendBoardPayload(payload: ByteArray) {
|
||||
val wire = com.bitchat.android.board.BoardWireCodec.decode(payload) ?: return
|
||||
if (!wire.verifySignature()) return
|
||||
serviceScope.launch {
|
||||
// The inner board signature is authoritative. A stable outer
|
||||
// sender/signature would re-link otherwise isolated location scopes.
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.BOARD_POST.value,
|
||||
senderID = wire.transportSenderID(),
|
||||
recipientID = null,
|
||||
timestamp = System.currentTimeMillis().coerceAtLeast(0).toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
boardStore.ingest(
|
||||
wire,
|
||||
packet,
|
||||
com.bitchat.android.board.BoardIngestSource.LOCAL
|
||||
)
|
||||
broadcastRoutedPacket(RoutedPacket(packet))
|
||||
}
|
||||
}
|
||||
|
||||
@ -907,6 +1085,31 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
return true
|
||||
}
|
||||
|
||||
private fun signingKeyForFingerprint(fingerprint: String): ByteArray? {
|
||||
identityState.getAuthenticatedSigningKey(fingerprint)?.let { return it }
|
||||
return peerManager.getActivePeerIDs().firstNotNullOfOrNull { peerID ->
|
||||
val peerFingerprint = peerManager.getFingerprintForPeer(peerID)
|
||||
peerManager.getPeerInfo(peerID)?.signingPublicKey
|
||||
?.takeIf { peerFingerprint.equals(fingerprint, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendVouchPayload(peerID: String, payload: ByteArray): Boolean {
|
||||
val plaintext = NoisePayload(NoisePayloadType.VOUCH, payload).encode()
|
||||
val encrypted = securityManager.encryptForPeer(plaintext, peerID) ?: return false
|
||||
val packet = BitchatPacket(
|
||||
version = VouchCoordinator.NOISE_PACKET_VERSION,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(peerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = encrypted,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
broadcastRoutedPacket(RoutedPacket(signPacketBeforeBroadcast(packet)))
|
||||
return true
|
||||
}
|
||||
|
||||
fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) {
|
||||
try {
|
||||
val payload = file.encode()
|
||||
@ -937,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(
|
||||
@ -1206,6 +1415,42 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
sendNoisePayloadToPeer(payload, peerID, "verify response")
|
||||
}
|
||||
|
||||
fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) {
|
||||
sendNoisePayloadToPeer(
|
||||
NoisePayload(NoisePayloadType.GROUP_INVITE, payload),
|
||||
recipientPeerID,
|
||||
"group invite"
|
||||
)
|
||||
}
|
||||
|
||||
fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) {
|
||||
sendNoisePayloadToPeer(
|
||||
NoisePayload(NoisePayloadType.GROUP_KEY_UPDATE, payload),
|
||||
recipientPeerID,
|
||||
"group key update"
|
||||
)
|
||||
}
|
||||
|
||||
fun broadcastGroupMessage(payload: ByteArray) {
|
||||
if (payload.isEmpty()) return
|
||||
serviceScope.launch {
|
||||
val packet = BitchatPacket(
|
||||
version = if (payload.size > 0xffff) 2u else 1u,
|
||||
type = MessageType.GROUP_MESSAGE.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
// The outer packet is intentionally unsigned. Authenticity is
|
||||
// verified from the Ed25519 signature inside the ciphertext.
|
||||
broadcastRoutedPacket(RoutedPacket(packet))
|
||||
try { gossipSyncManager.onPublicPacketSeen(packet) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) {
|
||||
serviceScope.launch {
|
||||
try {
|
||||
@ -1251,7 +1496,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
|
||||
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
|
||||
nickname,
|
||||
staticKey,
|
||||
signingKey
|
||||
)
|
||||
var tlvPayload = announcement.encode()
|
||||
if (tlvPayload == null) {
|
||||
Log.e(TAG, "Failed to encode announcement as TLV")
|
||||
@ -1313,7 +1562,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
|
||||
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
|
||||
nickname,
|
||||
staticKey,
|
||||
signingKey
|
||||
)
|
||||
var tlvPayload = announcement.encode()
|
||||
if (tlvPayload == null) {
|
||||
Log.e(TAG, "Failed to encode peer announcement as TLV")
|
||||
@ -1442,6 +1695,218 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
return peerManager.getPeerInfo(peerID)
|
||||
}
|
||||
|
||||
fun getPeerInfos(): List<PeerInfo> = peerManager.getAllPeerNicknames().keys.mapNotNull(peerManager::getPeerInfo)
|
||||
|
||||
fun sendCourierMessage(
|
||||
content: String,
|
||||
messageID: String,
|
||||
recipientNoiseKey: ByteArray,
|
||||
courierPeerIDs: List<String>
|
||||
): List<String> {
|
||||
val privateMessage = com.bitchat.android.model.PrivateMessagePacket(messageID, content).encode() ?: return emptyList()
|
||||
val typedPayload = com.bitchat.android.model.NoisePayload(
|
||||
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE,
|
||||
privateMessage
|
||||
).encode()
|
||||
val sealed = try { com.bitchat.android.services.bridge.PrekeyManager.getInstance(context).seal(typedPayload, messageID, recipientNoiseKey, true) } catch (_: Exception) { return emptyList() }
|
||||
val now = System.currentTimeMillis()
|
||||
val couriers = courierPeerIDs.distinct().take(4)
|
||||
if (couriers.isEmpty()) return emptyList()
|
||||
return couriers.filter { courierID ->
|
||||
val envelope = com.bitchat.android.model.CourierEnvelope(
|
||||
recipientTag = com.bitchat.android.model.CourierEnvelope.recipientTag(
|
||||
recipientNoiseKey,
|
||||
com.bitchat.android.model.CourierEnvelope.epochDay(now)
|
||||
),
|
||||
expiry = (now + com.bitchat.android.model.CourierEnvelope.MAX_LIFETIME_MS).toULong(),
|
||||
ciphertext = sealed.ciphertext,
|
||||
prekeyID = sealed.prekeyId?.toUInt(),
|
||||
copies = 4u
|
||||
)
|
||||
val payload = envelope.encode() ?: return@filter false
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.COURIER_ENVELOPE.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(courierID),
|
||||
timestamp = now.toULong(),
|
||||
payload = payload,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
TransportBridgeService.sendToPeerFromLocalAndReport(courierID, signPacketBeforeBroadcast(packet))
|
||||
}
|
||||
}
|
||||
|
||||
fun sendBridgeCourierMessage(
|
||||
content: String,
|
||||
messageID: String,
|
||||
recipientNoiseKey: ByteArray,
|
||||
onAccepted: () -> Unit = {}
|
||||
): Boolean = bridgeCourierService.deposit(content, messageID, recipientNoiseKey, onAccepted)
|
||||
|
||||
private fun handleLocalCourierEnvelope(envelope: com.bitchat.android.model.CourierEnvelope) {
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.COURIER_ENVELOPE.value,
|
||||
senderID = ByteArray(8),
|
||||
recipientID = hexStringToByteArray(myPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = envelope.encode() ?: return,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
handleCourierEnvelopePacket(RoutedPacket(packet, peerID = "bridge"))
|
||||
}
|
||||
|
||||
private fun handleCourierEnvelopePacket(routed: RoutedPacket): Boolean {
|
||||
val envelope = com.bitchat.android.model.CourierEnvelope.decode(routed.packet.payload) ?: return false
|
||||
val now = System.currentTimeMillis()
|
||||
if (envelope.expiry.toLong() <= now) return false
|
||||
val localKey = encryptionService.getStaticPublicKey() ?: return false
|
||||
if (envelope.matchesRecipient(localKey, now)) {
|
||||
val opened = try {
|
||||
com.bitchat.android.services.bridge.PrekeyManager.getInstance(context)
|
||||
.open(envelope.ciphertext, envelope.prekeyID?.toLong())
|
||||
} catch (_: Exception) { return false }
|
||||
val senderKey = opened.senderStaticKey
|
||||
val typedPayload = opened.payload
|
||||
if (opened.consumedPrekey) {
|
||||
com.bitchat.android.services.bridge.MeshBridgeService.refreshPrekeys()
|
||||
}
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload.decode(typedPayload) ?: return false
|
||||
if (noisePayload.type !in setOf(
|
||||
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE,
|
||||
com.bitchat.android.model.NoisePayloadType.DELIVERED
|
||||
)
|
||||
) return false
|
||||
val senderPeerID = com.bitchat.android.services.ContactIdentityResolver.peerIdForNoiseKey(senderKey)
|
||||
val synthetic = routed.copy(
|
||||
packet = routed.packet.copy(
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(senderPeerID),
|
||||
payload = typedPayload,
|
||||
timestamp = System.currentTimeMillis().toULong()
|
||||
),
|
||||
peerID = senderPeerID
|
||||
)
|
||||
val delivered = runBlocking { messageHandler.handleOpenedCourierPayload(synthetic) }
|
||||
if (delivered && noisePayload.type == com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) {
|
||||
val messageID = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data)?.messageID
|
||||
if (messageID != null) sendCourierDeliveryAck(messageID, senderKey, routed)
|
||||
}
|
||||
return delivered
|
||||
}
|
||||
val peerID = routed.peerID ?: return false
|
||||
if (!DirectCourierDepositPolicy.accepts(
|
||||
routed,
|
||||
MAX_TTL,
|
||||
connectionManager::getCurrentLinkID,
|
||||
connectionManager.addressPeerMap::get
|
||||
)
|
||||
) return false
|
||||
val depositor = peerManager.getPeerInfo(peerID) ?: return false
|
||||
val key = depositor.noisePublicKey ?: return false
|
||||
val favorite = try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(key)?.isMutual == true
|
||||
} catch (_: Exception) { false }
|
||||
val tier = if (favorite) CourierDepositTier.FAVORITE
|
||||
else if (depositor.hasVerifiedAnnouncement) CourierDepositTier.VERIFIED
|
||||
else return false
|
||||
return courierStore.deposit(envelope, key, tier)
|
||||
}
|
||||
|
||||
private fun sendCourierDeliveryAck(messageID: String, senderNoiseKey: ByteArray, ingress: RoutedPacket) {
|
||||
val typedPayload = com.bitchat.android.model.NoisePayload(
|
||||
com.bitchat.android.model.NoisePayloadType.DELIVERED,
|
||||
messageID.toByteArray(Charsets.UTF_8)
|
||||
).encode()
|
||||
if (ingress.peerID == "bridge") {
|
||||
bridgeCourierService.depositPayload(typedPayload, senderNoiseKey)
|
||||
return
|
||||
}
|
||||
val courierPeerID = ingress.peerID ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
val sealed = try { encryptionService.sealCourierPayload(typedPayload, senderNoiseKey) } catch (_: Exception) { return }
|
||||
val envelope = com.bitchat.android.model.CourierEnvelope(
|
||||
recipientTag = com.bitchat.android.model.CourierEnvelope.recipientTag(
|
||||
senderNoiseKey,
|
||||
com.bitchat.android.model.CourierEnvelope.epochDay(now)
|
||||
),
|
||||
expiry = (now + com.bitchat.android.model.CourierEnvelope.MAX_LIFETIME_MS).toULong(),
|
||||
ciphertext = sealed,
|
||||
copies = 4u
|
||||
)
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.COURIER_ENVELOPE.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(courierPeerID),
|
||||
timestamp = now.toULong(),
|
||||
payload = envelope.encode() ?: return,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
TransportBridgeService.sendToPeerFromLocalAndReport(courierPeerID, signPacketBeforeBroadcast(packet))
|
||||
}
|
||||
|
||||
private fun handleCourierAnnounce(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: return
|
||||
val info = peerManager.getPeerInfo(peerID) ?: return
|
||||
val noiseKey = info.noisePublicKey ?: return
|
||||
val direct = routed.packet.ttl >= MAX_TTL
|
||||
val envelopes = if (direct) courierStore.copiesForRecipient(noiseKey) else courierStore.copiesForRemoteHandover(noiseKey)
|
||||
envelopes.forEach { envelope ->
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.COURIER_ENVELOPE.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(peerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = envelope.encode() ?: return@forEach,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
if (direct) {
|
||||
serviceScope.launch {
|
||||
if (connectionManager.sendToPeerAndAwaitCompletion(
|
||||
peerID,
|
||||
RoutedPacket(signPacketBeforeBroadcast(packet))
|
||||
)
|
||||
) {
|
||||
courierStore.remove(envelope)
|
||||
}
|
||||
}
|
||||
} else TransportBridgeService.broadcastFromLocal(RoutedPacket(signPacketBeforeBroadcast(packet)))
|
||||
}
|
||||
if (direct) {
|
||||
courierStore.sprayCopiesFor(noiseKey).forEach { envelope ->
|
||||
val payload = envelope.encode()
|
||||
if (payload == null) {
|
||||
courierStore.cancelSpray(envelope, noiseKey)
|
||||
return@forEach
|
||||
}
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.COURIER_ENVELOPE.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(peerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
serviceScope.launch {
|
||||
var committed = false
|
||||
try {
|
||||
if (connectionManager.sendToPeerAndAwaitCompletion(
|
||||
peerID,
|
||||
RoutedPacket(signPacketBeforeBroadcast(packet))
|
||||
)
|
||||
) {
|
||||
committed = courierStore.commitSpray(envelope, noiseKey)
|
||||
}
|
||||
} finally {
|
||||
if (!committed) courierStore.cancelSpray(envelope, noiseKey)
|
||||
}
|
||||
}.invokeOnCompletion {
|
||||
// A coroutine cancelled before its body starts never reaches finally.
|
||||
courierStore.cancelSpray(envelope, noiseKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update peer information with verification data
|
||||
*/
|
||||
@ -1465,6 +1930,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
fun getStaticNoisePublicKey(): ByteArray? {
|
||||
return encryptionService.getStaticPublicKey()
|
||||
}
|
||||
|
||||
fun getSigningPublicKey(): ByteArray? =
|
||||
encryptionService.getSigningPublicKey()?.copyOf()
|
||||
|
||||
fun signData(data: ByteArray): ByteArray? =
|
||||
encryptionService.signData(data)
|
||||
|
||||
/**
|
||||
* Check if encryption icon should be shown for a peer
|
||||
@ -1620,20 +2091,23 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
*/
|
||||
fun clearAllInternalData() {
|
||||
Log.w(TAG, "Clearing all mesh service internal data")
|
||||
try {
|
||||
// Stop services to cease broadcasting old ID immediately
|
||||
stopServices()
|
||||
|
||||
// Clear all managers
|
||||
fragmentManager.clearAllFragments()
|
||||
storeForwardManager.clearAllCache()
|
||||
securityManager.clearAllData()
|
||||
peerManager.clearAllPeers()
|
||||
peerManager.clearAllFingerprints()
|
||||
try { gossipSyncManager.clear() } catch (_: Exception) { }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error clearing mesh service internal data: ${e.message}")
|
||||
val operations = listOf<() -> Unit>(
|
||||
::stopServices,
|
||||
fragmentManager::clearAllFragments,
|
||||
storeForwardManager::clearAllCache,
|
||||
securityManager::clearAllData,
|
||||
peerManager::clearAllPeers,
|
||||
peerManager::clearAllFingerprints,
|
||||
courierStore::wipe,
|
||||
{ boardStore.wipe() },
|
||||
bridgeCourierService::stop,
|
||||
gossipSyncManager::clear
|
||||
)
|
||||
var failure: Exception? = null
|
||||
operations.forEach { operation ->
|
||||
try { operation() } catch (error: Exception) { failure = error }
|
||||
}
|
||||
failure?.let { throw IllegalStateException("Mesh data wipe incomplete", it) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -22,6 +22,7 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.coroutines.channels.actor
|
||||
import java.util.ArrayDeque
|
||||
|
||||
@ -55,6 +56,7 @@ class BluetoothPacketBroadcaster(
|
||||
private const val MAX_PENDING_BYTES_PER_LINK = 1_048_576
|
||||
private const val SEND_RETRY_DELAY_MS = 15L
|
||||
private const val MAX_CALLBACK_RETRIES = 3
|
||||
private const val SEND_COMPLETION_TIMEOUT_MS = 30_000L
|
||||
}
|
||||
|
||||
// Optional nickname resolver injected by higher layer (peerID -> nickname?)
|
||||
@ -140,6 +142,7 @@ class BluetoothPacketBroadcaster(
|
||||
val gatt: BluetoothGatt? = null,
|
||||
val gattServer: BluetoothGattServer? = null,
|
||||
val characteristic: BluetoothGattCharacteristic,
|
||||
val completion: CompletableDeferred<Boolean>? = null,
|
||||
var callbackFailures: Int = 0
|
||||
)
|
||||
|
||||
@ -204,6 +207,30 @@ class BluetoothPacketBroadcaster(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendPacketToPeerAndAwaitAcceptance(
|
||||
routed: RoutedPacket,
|
||||
targetPeerID: String,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
if (!hasPeerConnection(targetPeerID)) return false
|
||||
return fragmentingSender.sendAndAwaitAcceptance(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
|
||||
sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendPacketToPeerAndAwaitCompletion(
|
||||
routed: RoutedPacket,
|
||||
targetPeerID: String,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
if (!hasPeerConnection(targetPeerID)) return false
|
||||
return fragmentingSender.sendAndAwaitAcceptance(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
|
||||
sendSinglePacketToPeerAndAwaitCompletion(packet, targetPeerID, gattServer, characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPacketToLink(
|
||||
routed: RoutedPacket,
|
||||
deviceAddress: String,
|
||||
@ -267,6 +294,35 @@ class BluetoothPacketBroadcaster(
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun sendSinglePacketToPeerAndAwaitCompletion(
|
||||
routed: RoutedPacket,
|
||||
targetPeerID: String,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
val packet = routed.packet
|
||||
val data = packet.toBinaryData(
|
||||
padding = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type)
|
||||
) ?: return false
|
||||
val completion = CompletableDeferred<Boolean>()
|
||||
|
||||
val serverTarget = connectionTracker.getSubscribedDevices()
|
||||
.firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID }
|
||||
val queuedOnServer = serverTarget != null &&
|
||||
notifyDevice(serverTarget, data, gattServer, characteristic, completion)
|
||||
val queued = if (queuedOnServer) {
|
||||
true
|
||||
} else {
|
||||
val clientTarget = connectionTracker.getConnectedDevices().values
|
||||
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
|
||||
?: return false
|
||||
writeToDeviceConn(clientTarget, data, completion)
|
||||
}
|
||||
if (!queued) return false
|
||||
|
||||
return withTimeoutOrNull(SEND_COMPLETION_TIMEOUT_MS) { completion.await() } ?: false
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Public entry point for broadcasting - submits request to actor for serialization
|
||||
@ -474,7 +530,8 @@ class BluetoothPacketBroadcaster(
|
||||
device: BluetoothDevice,
|
||||
data: ByteArray,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
characteristic: BluetoothGattCharacteristic?,
|
||||
completion: CompletableDeferred<Boolean>? = null
|
||||
): Boolean {
|
||||
val server = gattServer ?: return false
|
||||
val char = characteristic ?: return false
|
||||
@ -484,7 +541,13 @@ class BluetoothPacketBroadcaster(
|
||||
?: return false
|
||||
return enqueueSend(
|
||||
SendKey(device.address, linkID, SendDirection.SERVER_NOTIFICATION),
|
||||
PendingSend(data.copyOf(), device, gattServer = server, characteristic = char)
|
||||
PendingSend(
|
||||
data.copyOf(),
|
||||
device,
|
||||
gattServer = server,
|
||||
characteristic = char,
|
||||
completion = completion
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -493,13 +556,20 @@ class BluetoothPacketBroadcaster(
|
||||
*/
|
||||
private fun writeToDeviceConn(
|
||||
deviceConn: BluetoothConnectionTracker.DeviceConnection,
|
||||
data: ByteArray
|
||||
data: ByteArray,
|
||||
completion: CompletableDeferred<Boolean>? = null
|
||||
): Boolean {
|
||||
val gatt = deviceConn.gatt ?: return false
|
||||
val char = deviceConn.characteristic ?: return false
|
||||
return enqueueSend(
|
||||
SendKey(deviceConn.device.address, deviceConn.linkID, SendDirection.CLIENT_WRITE),
|
||||
PendingSend(data.copyOf(), deviceConn.device, gatt = gatt, characteristic = char)
|
||||
PendingSend(
|
||||
data.copyOf(),
|
||||
deviceConn.device,
|
||||
gatt = gatt,
|
||||
characteristic = char,
|
||||
completion = completion
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -631,6 +701,7 @@ class BluetoothPacketBroadcaster(
|
||||
}
|
||||
state.pending.removeFirst()
|
||||
state.pendingBytes -= head.data.size
|
||||
head.completion?.complete(status == BluetoothGatt.GATT_SUCCESS)
|
||||
if (state.pending.isEmpty()) {
|
||||
sendStates.remove(key)
|
||||
false
|
||||
@ -645,8 +716,13 @@ class BluetoothPacketBroadcaster(
|
||||
|
||||
fun onLinkDisconnected(deviceAddress: String, linkID: String?) {
|
||||
synchronized(sendLock) {
|
||||
sendStates.keys.removeAll { key ->
|
||||
key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID)
|
||||
val iterator = sendStates.entries.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (key, state) = iterator.next()
|
||||
if (key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID)) {
|
||||
state.pending.forEach { it.completion?.complete(false) }
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -667,7 +743,12 @@ class BluetoothPacketBroadcaster(
|
||||
* Shutdown the broadcaster actor gracefully
|
||||
*/
|
||||
fun shutdown() {
|
||||
synchronized(sendLock) { sendStates.clear() }
|
||||
synchronized(sendLock) {
|
||||
sendStates.values.forEach { state ->
|
||||
state.pending.forEach { it.completion?.complete(false) }
|
||||
}
|
||||
sendStates.clear()
|
||||
}
|
||||
// Close the actor gracefully
|
||||
broadcasterActor.close()
|
||||
|
||||
|
||||
98
app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt
Normal file
98
app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt
Normal file
@ -0,0 +1,98 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
|
||||
/**
|
||||
* Immutable privacy decision captured when a public message is accepted.
|
||||
*
|
||||
* A later opt-in must not authorize a message that was composed while
|
||||
* bridging was disabled or nearby-only. Publication therefore requires both
|
||||
* this send-time decision and the current policy to allow bridging.
|
||||
*/
|
||||
class BridgeOutboundPolicy internal constructor(
|
||||
internal val enabled: Boolean,
|
||||
internal val nearbyOnly: Boolean
|
||||
) {
|
||||
val allowsBridging: Boolean
|
||||
get() = enabled && !nearbyOnly
|
||||
|
||||
fun permitsPublication(current: BridgeOutboundPolicy): Boolean =
|
||||
allowsBridging && current.allowsBridging
|
||||
|
||||
companion object {
|
||||
val Denied = BridgeOutboundPolicy(enabled = false, nearbyOnly = false)
|
||||
|
||||
internal fun capture(enabled: Boolean, nearbyOnly: Boolean): BridgeOutboundPolicy =
|
||||
BridgeOutboundPolicy(enabled = enabled, nearbyOnly = nearbyOnly)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport-facing bridge boundary.
|
||||
*
|
||||
* BLE/Wi-Fi packet code depends only on this protocol surface; application
|
||||
* bootstrap installs the process bridge controller. Tests can install a fake
|
||||
* without constructing relay or persistence infrastructure.
|
||||
*/
|
||||
interface BridgeMeshDelegate {
|
||||
fun advertisedCell(): String?
|
||||
fun outboundPolicy(): BridgeOutboundPolicy
|
||||
|
||||
fun bridgeOutgoing(
|
||||
content: String,
|
||||
senderPeerId: String,
|
||||
timestampMs: Long,
|
||||
nickname: String?,
|
||||
policyAtSend: BridgeOutboundPolicy
|
||||
)
|
||||
|
||||
fun handleAuthenticatedRadioMessage(messageId: String)
|
||||
fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement)
|
||||
fun handlePrekeyPacket(packet: BitchatPacket)
|
||||
fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean)
|
||||
}
|
||||
|
||||
object BridgeMeshPort : BridgeMeshDelegate {
|
||||
@Volatile
|
||||
private var delegate: BridgeMeshDelegate? = null
|
||||
|
||||
fun install(delegate: BridgeMeshDelegate) {
|
||||
this.delegate = delegate
|
||||
}
|
||||
|
||||
override fun advertisedCell(): String? = delegate?.advertisedCell()
|
||||
|
||||
override fun outboundPolicy(): BridgeOutboundPolicy =
|
||||
delegate?.outboundPolicy() ?: BridgeOutboundPolicy.Denied
|
||||
|
||||
override fun bridgeOutgoing(
|
||||
content: String,
|
||||
senderPeerId: String,
|
||||
timestampMs: Long,
|
||||
nickname: String?,
|
||||
policyAtSend: BridgeOutboundPolicy
|
||||
) {
|
||||
delegate?.bridgeOutgoing(content, senderPeerId, timestampMs, nickname, policyAtSend)
|
||||
}
|
||||
|
||||
override fun handleAuthenticatedRadioMessage(messageId: String) {
|
||||
delegate?.handleAuthenticatedRadioMessage(messageId)
|
||||
}
|
||||
|
||||
override fun handleVerifiedAnnouncement(
|
||||
peerId: String,
|
||||
announcement: IdentityAnnouncement
|
||||
) {
|
||||
delegate?.handleVerifiedAnnouncement(peerId, announcement)
|
||||
}
|
||||
|
||||
override fun handlePrekeyPacket(packet: BitchatPacket) {
|
||||
delegate?.handlePrekeyPacket(packet)
|
||||
}
|
||||
|
||||
override fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) {
|
||||
delegate?.handleCarrier(payload, fromPeerId, directedToUs)
|
||||
}
|
||||
|
||||
}
|
||||
241
app/src/main/java/com/bitchat/android/mesh/CourierStore.kt
Normal file
241
app/src/main/java/com/bitchat/android/mesh/CourierStore.kt
Normal file
@ -0,0 +1,241 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.content.Context
|
||||
import com.bitchat.android.model.CourierEnvelope
|
||||
import com.bitchat.android.services.AndroidConversationStorageCipher
|
||||
import com.bitchat.android.services.ConversationStorageCipher
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.io.File
|
||||
import java.util.Base64
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
enum class CourierDepositTier { FAVORITE, VERIFIED }
|
||||
|
||||
/** Bounded persistent mailbag for opaque envelopes deposited by other peers. */
|
||||
internal class CourierStore(
|
||||
context: Context,
|
||||
private val cipher: ConversationStorageCipher = AndroidConversationStorageCipher(KEY_ALIAS),
|
||||
private val now: () -> Long = System::currentTimeMillis
|
||||
) {
|
||||
private data class Stored(
|
||||
val encoded: String,
|
||||
val depositorKey: String,
|
||||
val tier: CourierDepositTier,
|
||||
var copies: Int,
|
||||
val sprayedTo: MutableSet<String> = mutableSetOf(),
|
||||
var lastRemoteHandoverAtMs: Long = 0
|
||||
)
|
||||
|
||||
private data class SprayReservation(
|
||||
val envelopeKey: String,
|
||||
val courierKey: String,
|
||||
val copies: Int
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val KEY_ALIAS = "bitchat_courier_store_v1"
|
||||
private val AAD = "bitchat-courier-store-v1".toByteArray(Charsets.UTF_8)
|
||||
private const val MAX_ENVELOPES = 40
|
||||
private const val MAX_VERIFIED_ENVELOPES = 20
|
||||
private const val MAX_PER_FAVORITE = 5
|
||||
private const val MAX_PER_VERIFIED = 2
|
||||
private const val EXPIRY_SLACK_MS = 60 * 60 * 1000L
|
||||
private const val REMOTE_HANDOVER_COOLDOWN_MS = 10 * 60 * 1000L
|
||||
}
|
||||
|
||||
private val gson = Gson()
|
||||
private val file = File(context.applicationContext.filesDir, "courier-store.sealed")
|
||||
private val stored = load()
|
||||
private val sprayReservations = mutableListOf<SprayReservation>()
|
||||
|
||||
@Synchronized
|
||||
fun deposit(envelope: CourierEnvelope, depositorNoiseKey: ByteArray, tier: CourierDepositTier): Boolean {
|
||||
pruneExpired()
|
||||
val nowMs = now()
|
||||
if (envelope.expiry.toLong() <= nowMs ||
|
||||
envelope.expiry.toLong() > nowMs + CourierEnvelope.MAX_LIFETIME_MS + EXPIRY_SLACK_MS
|
||||
) return false
|
||||
val encodedBytes = envelope.encode() ?: return false
|
||||
val encoded = Base64.getEncoder().encodeToString(encodedBytes)
|
||||
if (stored.any { it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true }) return true
|
||||
val depositor = depositorNoiseKey.toHex()
|
||||
val perDepositor = if (tier == CourierDepositTier.FAVORITE) MAX_PER_FAVORITE else MAX_PER_VERIFIED
|
||||
if (stored.count { it.depositorKey == depositor && it.tier == tier } >= perDepositor) return false
|
||||
if (tier == CourierDepositTier.VERIFIED && stored.count { it.tier == tier } >= MAX_VERIFIED_ENVELOPES) {
|
||||
stored.removeAt(stored.indexOfFirst { it.tier == CourierDepositTier.VERIFIED })
|
||||
}
|
||||
if (stored.size >= MAX_ENVELOPES) {
|
||||
val verifiedIndex = stored.indexOfFirst { it.tier == CourierDepositTier.VERIFIED }
|
||||
if (tier == CourierDepositTier.VERIFIED && verifiedIndex < 0) return false
|
||||
val index = verifiedIndex.takeIf { it >= 0 } ?: 0
|
||||
stored.removeAt(index)
|
||||
}
|
||||
stored += Stored(encoded, depositor, tier, envelope.copies.toInt())
|
||||
persist()
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun copiesForRecipient(recipientNoiseKey: ByteArray): List<CourierEnvelope> {
|
||||
pruneExpired()
|
||||
val nowMs = now()
|
||||
return stored.mapNotNull { record ->
|
||||
record.envelope()
|
||||
?.takeIf { it.matchesRecipient(recipientNoiseKey, nowMs) }
|
||||
?.copy(copies = 1u)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun remove(envelope: CourierEnvelope): Boolean {
|
||||
val envelopeKey = envelope.ciphertext.storageKey()
|
||||
val removed = stored.removeAll {
|
||||
it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true
|
||||
}
|
||||
if (removed) {
|
||||
sprayReservations.removeAll { it.envelopeKey == envelopeKey }
|
||||
persist()
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun copiesForRemoteHandover(recipientNoiseKey: ByteArray): List<CourierEnvelope> {
|
||||
pruneExpired()
|
||||
val nowMs = now()
|
||||
val result = mutableListOf<CourierEnvelope>()
|
||||
stored.forEach { record ->
|
||||
val envelope = record.envelope() ?: return@forEach
|
||||
if (envelope.matchesRecipient(recipientNoiseKey, nowMs) &&
|
||||
nowMs - record.lastRemoteHandoverAtMs >= REMOTE_HANDOVER_COOLDOWN_MS
|
||||
) {
|
||||
record.lastRemoteHandoverAtMs = nowMs
|
||||
result += envelope.copy(copies = 1u)
|
||||
}
|
||||
}
|
||||
if (result.isNotEmpty()) persist()
|
||||
return result
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sprayCopiesFor(courierNoiseKey: ByteArray): List<CourierEnvelope> {
|
||||
pruneExpired()
|
||||
val key = courierNoiseKey.toHex()
|
||||
val nowMs = now()
|
||||
val courierTags = listOf(-1, 0, 1).map {
|
||||
CourierEnvelope.recipientTag(courierNoiseKey, CourierEnvelope.epochDay(nowMs) + it.toUInt())
|
||||
}
|
||||
val result = mutableListOf<CourierEnvelope>()
|
||||
stored.forEach { record ->
|
||||
val envelope = record.envelope() ?: return@forEach
|
||||
val envelopeKey = envelope.ciphertext.storageKey()
|
||||
if (record.copies <= 1 || record.depositorKey == key || key in record.sprayedTo ||
|
||||
sprayReservations.any { it.envelopeKey == envelopeKey && it.courierKey == key } ||
|
||||
courierTags.any { it.contentEquals(envelope.recipientTag) }
|
||||
) return@forEach
|
||||
val reserved = sprayReservations
|
||||
.filter { it.envelopeKey == envelopeKey }
|
||||
.sumOf { it.copies }
|
||||
val available = record.copies - reserved
|
||||
if (available <= 1) return@forEach
|
||||
val given = available / 2
|
||||
sprayReservations += SprayReservation(envelopeKey, key, given)
|
||||
result += envelope.copy(copies = given.toUByte())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun commitSpray(envelope: CourierEnvelope, courierNoiseKey: ByteArray): Boolean {
|
||||
val key = courierNoiseKey.toHex()
|
||||
val envelopeKey = envelope.ciphertext.storageKey()
|
||||
val reservationIndex = sprayReservations.indexOfFirst {
|
||||
it.envelopeKey == envelopeKey && it.courierKey == key && it.copies == envelope.copies.toInt()
|
||||
}
|
||||
if (reservationIndex < 0) return false
|
||||
val reservation = sprayReservations[reservationIndex]
|
||||
val record = stored.firstOrNull {
|
||||
it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true
|
||||
}
|
||||
val otherReservedCopies = sprayReservations.withIndex()
|
||||
.filter { (index, candidate) -> index != reservationIndex && candidate.envelopeKey == envelopeKey }
|
||||
.sumOf { it.value.copies }
|
||||
if (record == null || key in record.sprayedTo ||
|
||||
record.copies - otherReservedCopies <= reservation.copies
|
||||
) {
|
||||
sprayReservations.removeAt(reservationIndex)
|
||||
return false
|
||||
}
|
||||
record.copies -= reservation.copies
|
||||
record.sprayedTo += key
|
||||
sprayReservations.removeAt(reservationIndex)
|
||||
persist()
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun cancelSpray(envelope: CourierEnvelope, courierNoiseKey: ByteArray): Boolean {
|
||||
val envelopeKey = envelope.ciphertext.storageKey()
|
||||
val courierKey = courierNoiseKey.toHex()
|
||||
return sprayReservations.removeAll {
|
||||
it.envelopeKey == envelopeKey && it.courierKey == courierKey && it.copies == envelope.copies.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun wipe() {
|
||||
stored.clear()
|
||||
sprayReservations.clear()
|
||||
file.delete()
|
||||
cipher.destroyKey()
|
||||
}
|
||||
|
||||
private fun pruneExpired() {
|
||||
val nowMs = now().toULong()
|
||||
if (stored.removeAll { (it.envelope()?.expiry ?: 0u) <= nowMs }) {
|
||||
val retainedEnvelopeKeys = stored.mapNotNull { it.envelope()?.ciphertext?.storageKey() }.toSet()
|
||||
sprayReservations.removeAll { it.envelopeKey !in retainedEnvelopeKeys }
|
||||
persist()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Stored.envelope(): CourierEnvelope? = try {
|
||||
CourierEnvelope.decode(Base64.getDecoder().decode(encoded))
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
private fun load(): MutableList<Stored> = try {
|
||||
if (!file.exists()) return mutableListOf()
|
||||
val plaintext = cipher.decrypt(file.readBytes(), AAD)
|
||||
val type = object : TypeToken<MutableList<Stored>>() {}.type
|
||||
gson.fromJson<MutableList<Stored>>(plaintext.toString(Charsets.UTF_8), type) ?: mutableListOf()
|
||||
} catch (_: Exception) { mutableListOf() }
|
||||
|
||||
private fun persist() {
|
||||
if (stored.isEmpty()) {
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
val encrypted = cipher.encrypt(gson.toJson(stored).toByteArray(Charsets.UTF_8), AAD)
|
||||
val temporary = File(file.parentFile, "${file.name}.tmp")
|
||||
temporary.writeBytes(encrypted)
|
||||
try {
|
||||
java.nio.file.Files.move(
|
||||
temporary.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
java.nio.file.Files.move(
|
||||
temporary.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun ByteArray.storageKey(): String = Base64.getEncoder().encodeToString(this)
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
|
||||
/** Accepts courier custody only from the authenticated peer on the current direct link. */
|
||||
internal object DirectCourierDepositPolicy {
|
||||
fun accepts(
|
||||
routed: RoutedPacket,
|
||||
maxTtl: UByte,
|
||||
currentLinkID: (String) -> String?,
|
||||
peerForAddress: (String) -> String?
|
||||
): Boolean {
|
||||
val peerID = routed.peerID ?: return false
|
||||
val relayAddress = routed.relayAddress ?: return false
|
||||
val ingressLinkID = routed.ingressLinkID ?: return false
|
||||
return routed.packet.ttl == maxTtl &&
|
||||
currentLinkID(relayAddress) == ingressLinkID &&
|
||||
peerForAddress(relayAddress) == peerID
|
||||
}
|
||||
}
|
||||
@ -112,6 +112,23 @@ class FragmentingPacketSender(
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun sendAndAwaitAcceptance(
|
||||
routed: RoutedPacket,
|
||||
description: String,
|
||||
sendSingle: suspend (RoutedPacket) -> Boolean
|
||||
): Boolean {
|
||||
val packets = packetsForTransport(routed) ?: return false
|
||||
Log.d(logTag, "Sending ${packets.size} packet(s) for $description")
|
||||
for ((index, packet) in packets.withIndex()) {
|
||||
val accepted = sendSingle(
|
||||
routed.copy(packet = packet, transferId = null, preparedPackets = null)
|
||||
)
|
||||
if (!accepted) return false
|
||||
if (index < packets.lastIndex) delay(interFragmentDelayMs)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun cancelTransfer(transferId: String): Boolean {
|
||||
val job = transferJobs.remove(transferId) ?: return false
|
||||
job.cancel()
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -2,6 +2,7 @@ package com.bitchat.android.mesh
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.board.transportSenderID
|
||||
import com.bitchat.android.crypto.EncryptionService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
@ -16,7 +17,9 @@ import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.service.TransportBridgeService
|
||||
import com.bitchat.android.services.bridge.BridgeProtocolPacketFactory
|
||||
import com.bitchat.android.sync.GossipSyncManager
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -47,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,
|
||||
@ -55,6 +59,7 @@ class MeshCore(
|
||||
)
|
||||
|
||||
private val peerManager = PeerManager()
|
||||
private val identityState = SecureIdentityStateManager(context.applicationContext)
|
||||
val fragmentManager = FragmentManager()
|
||||
private val readReceiptRetrySender = RetryingControlPacketSender(scope)
|
||||
private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context)
|
||||
@ -66,13 +71,14 @@ class MeshCore(
|
||||
store = authenticatedPeerStateStore,
|
||||
localStateProvider = {
|
||||
AuthenticatedPeerState(
|
||||
PeerCapabilities.LOCAL_SUPPORTED,
|
||||
PeerCapabilities.localSupported(),
|
||||
requireNotNull(encryptionService.getSigningPublicKey())
|
||||
)
|
||||
},
|
||||
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(
|
||||
@ -110,11 +116,27 @@ class MeshCore(
|
||||
}
|
||||
private val securityManager = SecurityManager(encryptionService, myPeerID)
|
||||
private val storeForwardManager = StoreForwardManager()
|
||||
private val boardStore = com.bitchat.android.board.BoardStore.getInstance(context)
|
||||
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
|
||||
private val packetProcessor = PacketProcessor(myPeerID)
|
||||
private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray)
|
||||
private val voiceFrameQueue = Channel<VoiceFrameRequest>(capacity = 128)
|
||||
private val directPeers = ConcurrentHashMap.newKeySet<String>()
|
||||
private val vouchCoordinator by lazy {
|
||||
VouchCoordinator(
|
||||
scope = scope,
|
||||
identity = identityState,
|
||||
connectedPeerIDs = peerManager::getActivePeerIDs,
|
||||
fingerprintForPeer = peerManager::getFingerprintForPeer,
|
||||
peerInfo = peerManager::getPeerInfo,
|
||||
signingKeyForFingerprint = ::signingKeyForFingerprint,
|
||||
hasEstablishedSession = encryptionService::hasEstablishedSession,
|
||||
sign = encryptionService::signData,
|
||||
verify = encryptionService::verifyEd25519Signature,
|
||||
send = ::sendVouchPayload
|
||||
)
|
||||
}
|
||||
private val meshPingManager = MeshPingManager(myPeerID, scope, ::dispatchUnsignedDiagnostic)
|
||||
|
||||
val gossipSyncManager: GossipSyncManager =
|
||||
sharedGossipManager ?: GossipSyncManager(myPeerID = myPeerID, scope = scope, configProvider = gossipConfigProvider)
|
||||
@ -129,6 +151,7 @@ class MeshCore(
|
||||
for (request in voiceFrameQueue) dispatchVoiceFrame(request)
|
||||
}
|
||||
messageHandler.packetProcessor = packetProcessor
|
||||
gossipSyncManager.boardPacketsProvider = boardStore::syncCandidates
|
||||
peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) }
|
||||
setupDelegates()
|
||||
|
||||
@ -217,6 +240,7 @@ class MeshCore(
|
||||
peerManager.delegate = object : PeerManagerDelegate {
|
||||
override fun onPeerListUpdated(peerIDs: List<String>) {
|
||||
try { com.bitchat.android.services.AppStateStore.setTransportPeers(transport.id, peerIDs) } catch (_: Exception) { }
|
||||
vouchCoordinator.peersUpdated(peerIDs)
|
||||
delegate?.didUpdatePeerList(peerIDs)
|
||||
}
|
||||
|
||||
@ -242,6 +266,10 @@ class MeshCore(
|
||||
authenticatedRemoteStaticKey,
|
||||
authenticatedSessionToken
|
||||
)
|
||||
vouchCoordinator.peerAuthenticated(
|
||||
peerID,
|
||||
identityState.generateFingerprint(authenticatedRemoteStaticKey)
|
||||
)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
sendAnnouncementToPeer(peerID)
|
||||
@ -411,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,
|
||||
@ -421,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,
|
||||
@ -437,6 +467,30 @@ class MeshCore(
|
||||
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun onGroupInviteReceived(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
GroupMessagePort.receiver?.invite(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun onGroupKeyUpdateReceived(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
GroupMessagePort.receiver?.keyUpdate(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {
|
||||
GroupMessagePort.receiver?.message(payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun onVouchPayloadReceived(peerID: String, payload: ByteArray) {
|
||||
vouchCoordinator.handlePayload(peerID, payload)
|
||||
}
|
||||
}
|
||||
|
||||
packetProcessor.delegate = object : PacketProcessorDelegate {
|
||||
@ -460,6 +514,9 @@ class MeshCore(
|
||||
return SpecialRecipients.BROADCAST
|
||||
}
|
||||
|
||||
override fun isPeerDirectlyConnected(peerID: String): Boolean =
|
||||
peerManager.getPeerInfo(peerID)?.isDirectConnection == true
|
||||
|
||||
override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
|
||||
return runBlocking { securityManager.handleNoiseHandshake(routed) }
|
||||
}
|
||||
@ -468,6 +525,8 @@ class MeshCore(
|
||||
return runBlocking { messageHandler.handleNoiseEncrypted(routed) }
|
||||
}
|
||||
|
||||
override fun handleCourierEnvelope(routed: RoutedPacket): Boolean = false
|
||||
|
||||
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
|
||||
val result = messageHandler.handleAnnounceWithResult(routed)
|
||||
if (result !is AnnounceHandlingResult.Accepted) return false
|
||||
@ -481,7 +540,7 @@ class MeshCore(
|
||||
try {
|
||||
val pkt = routed.packet
|
||||
val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST))
|
||||
if (isBroadcast && pkt.type == MessageType.MESSAGE.value) {
|
||||
if (isBroadcast && pkt.type in setOf(MessageType.MESSAGE.value, MessageType.FILE_TRANSFER.value)) {
|
||||
gossipSyncManager.onPublicPacketSeen(pkt)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
@ -489,6 +548,10 @@ class MeshCore(
|
||||
|
||||
override fun handleVoiceFrame(routed: RoutedPacket): Boolean =
|
||||
messageHandler.handlePublicVoiceFrame(routed)
|
||||
override fun handleGroupMessage(routed: RoutedPacket) {
|
||||
messageHandler.handleGroupMessage(routed)
|
||||
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun handleLeave(routed: RoutedPacket) {
|
||||
scope.launch { messageHandler.handleLeave(routed) }
|
||||
@ -527,11 +590,29 @@ class MeshCore(
|
||||
val req = RequestSyncPacket.decode(routed.packet.payload) ?: return
|
||||
gossipSyncManager.handleRequestSync(fromPeer, req)
|
||||
}
|
||||
|
||||
override fun handleBoardPost(routed: RoutedPacket): Boolean {
|
||||
val wire = com.bitchat.android.board.BoardWireCodec.decode(routed.packet.payload)
|
||||
?: return false
|
||||
return boardStore.ingestRemoteForRelay(wire, routed.packet)
|
||||
}
|
||||
override fun handlePing(routed: RoutedPacket) = meshPingManager.handlePing(routed)
|
||||
|
||||
override fun handlePong(routed: RoutedPacket) = meshPingManager.handlePong(routed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchUnsignedDiagnostic(packet: BitchatPacket) {
|
||||
dispatchGlobal(RoutedPacket(packet))
|
||||
}
|
||||
|
||||
fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit) {
|
||||
meshPingManager.ping(peerID, callback)
|
||||
}
|
||||
|
||||
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
|
||||
if (content.isEmpty()) return
|
||||
val bridgePolicyAtSend = BridgeMeshPort.outboundPolicy()
|
||||
scope.launch {
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
@ -546,6 +627,97 @@ class MeshCore(
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
if (channel == null) {
|
||||
val nickname = hooks.announcementNicknameProvider?.invoke()
|
||||
?: delegate?.getNickname()
|
||||
BridgeMeshPort.bridgeOutgoing(
|
||||
content,
|
||||
myPeerID,
|
||||
packet.timestamp.toLong(),
|
||||
nickname,
|
||||
bridgePolicyAtSend
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String? = null) {
|
||||
sendRawProtocolPacket(
|
||||
type = MessageType.NOSTR_CARRIER,
|
||||
payload = payload,
|
||||
recipientPeerID = recipientPeerID,
|
||||
sign = true
|
||||
)
|
||||
}
|
||||
|
||||
fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) {
|
||||
sendRawProtocolPacket(
|
||||
type = MessageType.COURIER_ENVELOPE,
|
||||
payload = payload,
|
||||
recipientPeerID = recipientPeerID,
|
||||
sign = true
|
||||
)
|
||||
}
|
||||
|
||||
fun sendPrekeyBundle(payload: ByteArray) {
|
||||
sendRawProtocolPacket(
|
||||
type = MessageType.PREKEY_BUNDLE,
|
||||
payload = payload,
|
||||
recipientPeerID = null,
|
||||
sign = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendRawProtocolPacket(
|
||||
type: MessageType,
|
||||
payload: ByteArray,
|
||||
recipientPeerID: String?,
|
||||
sign: Boolean
|
||||
) {
|
||||
if (payload.isEmpty()) return
|
||||
scope.launch {
|
||||
val packet = BridgeProtocolPacketFactory.protocolPacket(
|
||||
type = type,
|
||||
payload = payload,
|
||||
senderPeerId = myPeerID,
|
||||
recipientPeerId = recipientPeerID,
|
||||
ttl = maxTtl
|
||||
) ?: return@launch
|
||||
val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet
|
||||
if (sign &&
|
||||
type != MessageType.COURIER_ENVELOPE &&
|
||||
outgoing.signature?.size != 64
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
dispatchGlobal(RoutedPacket(outgoing))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun sendBoardPayload(payload: ByteArray) {
|
||||
val wire = com.bitchat.android.board.BoardWireCodec.decode(payload) ?: return
|
||||
if (!wire.verifySignature()) return
|
||||
scope.launch {
|
||||
// The inner board signature is authoritative. A stable outer
|
||||
// sender/signature would re-link otherwise isolated location scopes.
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.BOARD_POST.value,
|
||||
senderID = wire.transportSenderID(),
|
||||
recipientID = null,
|
||||
timestamp = System.currentTimeMillis().coerceAtLeast(0).toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
boardStore.ingest(
|
||||
wire,
|
||||
packet,
|
||||
com.bitchat.android.board.BoardIngestSource.LOCAL
|
||||
)
|
||||
dispatchGlobal(RoutedPacket(packet))
|
||||
}
|
||||
}
|
||||
|
||||
@ -575,6 +747,31 @@ class MeshCore(
|
||||
return true
|
||||
}
|
||||
|
||||
private fun signingKeyForFingerprint(fingerprint: String): ByteArray? {
|
||||
identityState.getAuthenticatedSigningKey(fingerprint)?.let { return it }
|
||||
return peerManager.getActivePeerIDs().firstNotNullOfOrNull { peerID ->
|
||||
val peerFingerprint = peerManager.getFingerprintForPeer(peerID)
|
||||
peerManager.getPeerInfo(peerID)?.signingPublicKey
|
||||
?.takeIf { peerFingerprint.equals(fingerprint, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendVouchPayload(peerID: String, payload: ByteArray): Boolean {
|
||||
val plaintext = NoisePayload(NoisePayloadType.VOUCH, payload).encode()
|
||||
val encrypted = securityManager.encryptForPeer(plaintext, peerID) ?: return false
|
||||
val packet = BitchatPacket(
|
||||
version = VouchCoordinator.NOISE_PACKET_VERSION,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = encrypted,
|
||||
ttl = maxTtl
|
||||
)
|
||||
dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
|
||||
return true
|
||||
}
|
||||
|
||||
fun sendFileBroadcast(file: BitchatFilePacket) {
|
||||
try {
|
||||
val payload = file.encode() ?: return
|
||||
@ -658,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,
|
||||
@ -798,6 +1001,38 @@ class MeshCore(
|
||||
sendNoisePayloadToPeer(payload, peerID)
|
||||
}
|
||||
|
||||
fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) {
|
||||
sendNoisePayloadToPeer(
|
||||
NoisePayload(NoisePayloadType.GROUP_INVITE, payload),
|
||||
recipientPeerID
|
||||
)
|
||||
}
|
||||
|
||||
fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) {
|
||||
sendNoisePayloadToPeer(
|
||||
NoisePayload(NoisePayloadType.GROUP_KEY_UPDATE, payload),
|
||||
recipientPeerID
|
||||
)
|
||||
}
|
||||
|
||||
fun broadcastGroupMessage(payload: ByteArray) {
|
||||
if (payload.isEmpty()) return
|
||||
scope.launch {
|
||||
val packet = BitchatPacket(
|
||||
version = if (payload.size > 0xffff) 2u else 1u,
|
||||
type = MessageType.GROUP_MESSAGE.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
dispatchGlobal(RoutedPacket(packet))
|
||||
try { gossipSyncManager.onPublicPacketSeen(packet) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String) {
|
||||
scope.launch {
|
||||
try {
|
||||
@ -832,7 +1067,11 @@ class MeshCore(
|
||||
Log.e("MeshCore", "No signing public key available for announcement")
|
||||
return@launch
|
||||
}
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
|
||||
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
|
||||
nickname,
|
||||
staticKey,
|
||||
signingKey
|
||||
)
|
||||
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch
|
||||
val announcePacket = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
@ -853,7 +1092,11 @@ class MeshCore(
|
||||
?: myPeerID
|
||||
val staticKey = encryptionService.getStaticPublicKey() ?: return
|
||||
val signingKey = encryptionService.getSigningPublicKey() ?: return
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
|
||||
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
|
||||
nickname,
|
||||
staticKey,
|
||||
signingKey
|
||||
)
|
||||
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
@ -991,6 +1234,12 @@ class MeshCore(
|
||||
|
||||
fun getStaticNoisePublicKey(): ByteArray? = encryptionService.getStaticPublicKey()
|
||||
|
||||
fun getSigningPublicKey(): ByteArray? =
|
||||
encryptionService.getSigningPublicKey()?.copyOf()
|
||||
|
||||
fun signData(data: ByteArray): ByteArray? =
|
||||
encryptionService.signData(data)
|
||||
|
||||
fun shouldShowEncryptionIcon(peerID: String): Boolean = encryptionService.hasEstablishedSession(peerID)
|
||||
|
||||
fun getEncryptedPeers(): List<String> = emptyList()
|
||||
@ -1035,6 +1284,7 @@ class MeshCore(
|
||||
peerManager.clearAllPeers()
|
||||
peerManager.clearAllFingerprints()
|
||||
try { gossipSyncManager.clear() } catch (_: Exception) { }
|
||||
boardStore.wipe()
|
||||
}
|
||||
|
||||
fun clearAllEncryptionData() {
|
||||
|
||||
@ -13,6 +13,17 @@ interface MeshDelegate {
|
||||
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
|
||||
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {}
|
||||
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {}
|
||||
fun didReceiveGroupInvite(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {}
|
||||
fun didReceiveGroupKeyUpdate(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {}
|
||||
fun didReceiveGroupMessage(payload: ByteArray, timestampMs: Long) {}
|
||||
/** Current Noise generation either proved peer state or exhausted its 5-second watchdog. */
|
||||
fun didResolvePrivateMediaPolicy(peerID: String) {}
|
||||
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
|
||||
|
||||
111
app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt
Normal file
111
app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt
Normal file
@ -0,0 +1,111 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MeshDiagnosticsConstants
|
||||
import com.bitchat.android.protocol.MeshPingPayload
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
data class MeshPingResult(val rttMillis: Long, val hopCount: Int)
|
||||
|
||||
internal class MeshPingManager(
|
||||
private val myPeerID: String,
|
||||
private val scope: CoroutineScope,
|
||||
private val send: (BitchatPacket) -> Unit,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* BLE and Wi-Fi Aware have separate manager instances, but a reply can return over either
|
||||
* transport. Pending probes therefore live at process scope and are namespaced by the
|
||||
* local identity.
|
||||
*/
|
||||
private val pending = ConcurrentHashMap<String, Pending>()
|
||||
}
|
||||
|
||||
private data class Pending(
|
||||
val peerID: String,
|
||||
val startedNanos: Long,
|
||||
val callback: (MeshPingResult?) -> Unit,
|
||||
val timeout: Job,
|
||||
)
|
||||
|
||||
private val inboundByLink = ConcurrentHashMap<String, ArrayDeque<Long>>()
|
||||
|
||||
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 {
|
||||
delay(MeshDiagnosticsConstants.TIMEOUT_MILLIS)
|
||||
pending.remove(key)?.callback?.invoke(null)
|
||||
}
|
||||
pending[key] = Pending(peerID, System.nanoTime(), callback, timeout)
|
||||
send(packet(MessageType.PING, peerID, payload))
|
||||
}
|
||||
|
||||
fun handlePing(routed: RoutedPacket) {
|
||||
val packet = routed.packet
|
||||
val payload = MeshPingPayload.decode(packet.payload) ?: return
|
||||
val sender = packet.senderID.toHexString()
|
||||
val ingress = routed.ingressLinkID ?: routed.relayAddress ?: routed.peerID ?: return
|
||||
if (!consumeInboundBudget(ingress)) return
|
||||
send(packet(MessageType.PONG, sender, payload))
|
||||
}
|
||||
|
||||
fun handlePong(routed: RoutedPacket) {
|
||||
val payload = MeshPingPayload.decode(routed.packet.payload) ?: return
|
||||
val key = pendingKey(payload)
|
||||
val candidate = pending[key] ?: return
|
||||
if (routed.packet.senderID.toHexString() != candidate.peerID) return
|
||||
if (!pending.remove(key, candidate)) return
|
||||
candidate.timeout.cancel()
|
||||
val elapsed = (System.nanoTime() - candidate.startedNanos) / 1_000_000
|
||||
candidate.callback(MeshPingResult(elapsed, payload.hopCount(routed.packet.ttl)))
|
||||
}
|
||||
|
||||
private fun pendingKey(payload: MeshPingPayload): String =
|
||||
"$myPeerID:${payload.nonce.toHexString()}"
|
||||
|
||||
private fun consumeInboundBudget(link: String): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
synchronized(inboundByLink) {
|
||||
inboundByLink.entries.removeAll { (_, times) ->
|
||||
synchronized(times) {
|
||||
times.lastOrNull()?.let { now - it >= MeshDiagnosticsConstants.INBOUND_RATE_WINDOW_MILLIS } != false
|
||||
}
|
||||
}
|
||||
if (inboundByLink.size >= 256 && !inboundByLink.containsKey(link)) 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
|
||||
} == true
|
||||
) {
|
||||
timestamps.removeFirst()
|
||||
}
|
||||
if (timestamps.size >= MeshDiagnosticsConstants.INBOUND_RATE_LIMIT) return false
|
||||
timestamps.addLast(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun packet(type: MessageType, recipientPeerID: String, payload: MeshPingPayload) =
|
||||
BitchatPacket(
|
||||
version = 1u,
|
||||
type = type.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload.encode(),
|
||||
signature = null,
|
||||
ttl = MeshDiagnosticsConstants.TTL,
|
||||
)
|
||||
}
|
||||
@ -13,15 +13,24 @@ interface MeshService {
|
||||
fun stopServices()
|
||||
|
||||
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null)
|
||||
fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String? = null)
|
||||
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) {}
|
||||
fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
|
||||
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
|
||||
fun sendGroupInvite(payload: ByteArray, recipientPeerID: String)
|
||||
fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String)
|
||||
fun broadcastGroupMessage(payload: ByteArray)
|
||||
fun sendFileBroadcast(file: BitchatFilePacket)
|
||||
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
|
||||
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray)
|
||||
fun sendBoardPayload(payload: ByteArray) {}
|
||||
fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
@ -32,6 +41,7 @@ interface MeshService {
|
||||
|
||||
fun sendBroadcastAnnounce()
|
||||
fun sendAnnouncementToPeer(peerID: String)
|
||||
fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit)
|
||||
|
||||
fun getPeerNicknames(): Map<String, String>
|
||||
fun getPeerRSSI(): Map<String, Int>
|
||||
@ -41,6 +51,19 @@ interface MeshService {
|
||||
fun initiateNoiseHandshake(peerID: String)
|
||||
fun getPeerFingerprint(peerID: String): String?
|
||||
fun getPeerInfo(peerID: String): PeerInfo?
|
||||
fun getPeerInfos(): List<PeerInfo> = getPeerNicknames().keys.mapNotNull(::getPeerInfo)
|
||||
fun sendCourierMessage(
|
||||
content: String,
|
||||
messageID: String,
|
||||
recipientNoiseKey: ByteArray,
|
||||
courierPeerIDs: List<String>
|
||||
): List<String> = emptyList()
|
||||
fun sendBridgeCourierMessage(
|
||||
content: String,
|
||||
messageID: String,
|
||||
recipientNoiseKey: ByteArray,
|
||||
onAccepted: () -> Unit = {}
|
||||
): Boolean = false
|
||||
fun updatePeerInfo(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
@ -50,11 +73,14 @@ interface MeshService {
|
||||
): Boolean
|
||||
fun getIdentityFingerprint(): String
|
||||
fun getStaticNoisePublicKey(): ByteArray?
|
||||
fun getSigningPublicKey(): ByteArray?
|
||||
fun signData(data: ByteArray): ByteArray?
|
||||
fun shouldShowEncryptionIcon(peerID: String): Boolean
|
||||
fun getEncryptedPeers(): List<String>
|
||||
|
||||
fun getDeviceAddressForPeer(peerID: String): String?
|
||||
fun getDeviceAddressToPeerMapping(): Map<String, String>
|
||||
fun getDirectBlePeerIDs(): Set<String> = emptySet()
|
||||
fun printDeviceAddressesForPeers(): String
|
||||
fun getDebugStatus(): String
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.sync.PacketIdUtil
|
||||
import com.bitchat.android.nostr.MeshMessageIdentity
|
||||
import com.bitchat.android.util.toHexString
|
||||
import com.bitchat.android.features.voice.LiveVoiceManager
|
||||
import com.bitchat.android.features.voice.LiveVoiceScope
|
||||
@ -118,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)
|
||||
}
|
||||
}
|
||||
@ -128,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,
|
||||
@ -146,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")
|
||||
}
|
||||
@ -199,6 +223,23 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> {
|
||||
delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong())
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.GROUP_INVITE -> {
|
||||
delegate?.onGroupInviteReceived(
|
||||
peerID,
|
||||
decryption.authenticatedSession.remoteStaticKey.copyOf(),
|
||||
noisePayload.data
|
||||
)
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.GROUP_KEY_UPDATE -> {
|
||||
delegate?.onGroupKeyUpdateReceived(
|
||||
peerID,
|
||||
decryption.authenticatedSession.remoteStaticKey.copyOf(),
|
||||
noisePayload.data
|
||||
)
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.VOUCH -> {
|
||||
delegate?.onVouchPayloadReceived(peerID, noisePayload.data)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
@ -207,6 +248,32 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
return true
|
||||
}
|
||||
|
||||
/** Admit an already authenticated Noise X payload through the normal private-message path. */
|
||||
suspend fun handleOpenedCourierPayload(routed: RoutedPacket): Boolean {
|
||||
val packet = routed.packet
|
||||
val peerID = routed.peerID ?: return false
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return false
|
||||
if (noisePayload.type == com.bitchat.android.model.NoisePayloadType.DELIVERED) {
|
||||
val messageID = noisePayload.data.toString(Charsets.UTF_8)
|
||||
if (messageID.isBlank()) return false
|
||||
delegate?.onDeliveryAckReceived(messageID, peerID)
|
||||
return true
|
||||
}
|
||||
if (noisePayload.type != com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) return false
|
||||
val privateMessage = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) ?: return false
|
||||
val message = BitchatMessage(
|
||||
id = privateMessage.messageID,
|
||||
sender = delegate?.getPeerNickname(peerID) ?: "Unknown",
|
||||
content = privateMessage.content,
|
||||
timestamp = Date(packet.timestamp.toLong()),
|
||||
isPrivate = true,
|
||||
recipientNickname = delegate?.getMyNickname(),
|
||||
senderPeerID = peerID
|
||||
)
|
||||
delegate?.onMessageReceived(message)
|
||||
return com.bitchat.android.services.AppStateStore.hasPrivateTextReceipt(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Count consecutive decrypt failures from a signature-verified peer that we still hold an
|
||||
* established session for. After repeated failures the session is stale (the peer completed
|
||||
@ -230,6 +297,13 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
consecutiveDecryptFailures[peerID] = failures
|
||||
}
|
||||
}
|
||||
|
||||
fun handleGroupMessage(routed: RoutedPacket) {
|
||||
delegate?.onGroupMessageReceived(
|
||||
routed.packet.payload,
|
||||
routed.packet.timestamp.toLong()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send delivery ACK for a received private message - exactly like iOS
|
||||
@ -348,6 +422,11 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
capabilities = announcement.capabilities
|
||||
) ?: false
|
||||
|
||||
BridgeMeshPort.handleVerifiedAnnouncement(
|
||||
peerID,
|
||||
announcement
|
||||
)
|
||||
|
||||
// Update mesh graph from gossip neighbors (only if TLV present)
|
||||
try {
|
||||
val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload)
|
||||
@ -453,6 +532,12 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
private suspend fun handleBroadcastMessage(routed: RoutedPacket) {
|
||||
val packet = routed.packet
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
if (packet.timestamp > Long.MAX_VALUE.toULong()) return
|
||||
val ageMs = System.currentTimeMillis() - packet.timestamp.toLong()
|
||||
if (ageMs !in
|
||||
-com.bitchat.android.sync.GossipSyncManager.PUBLIC_PACKET_FUTURE_SKEW_MS..
|
||||
com.bitchat.android.sync.GossipSyncManager.PUBLIC_MESSAGE_MAX_AGE_MS
|
||||
) return
|
||||
|
||||
// Enforce: only accept public messages from verified peers we know
|
||||
val peerInfo = delegate?.getPeerInfo(peerID)
|
||||
@ -486,13 +571,18 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
|
||||
// Fallback: plain text
|
||||
val message = BitchatMessage(
|
||||
id = PacketIdUtil.computeIdHex(packet).uppercase(),
|
||||
id = MeshMessageIdentity.stableId(
|
||||
peerID,
|
||||
packet.timestamp.toLong(),
|
||||
String(packet.payload, Charsets.UTF_8)
|
||||
),
|
||||
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
|
||||
content = String(packet.payload, Charsets.UTF_8),
|
||||
senderPeerID = peerID,
|
||||
timestamp = Date(packet.timestamp.toLong())
|
||||
)
|
||||
delegate?.onMessageReceived(message)
|
||||
BridgeMeshPort.handleAuthenticatedRadioMessage(message.id)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to process broadcast message: ${e.message}")
|
||||
}
|
||||
@ -738,4 +828,16 @@ interface MessageHandlerDelegate {
|
||||
fun onReadReceiptReceived(messageID: String, peerID: String)
|
||||
fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
fun onGroupInviteReceived(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {}
|
||||
fun onGroupKeyUpdateReceived(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {}
|
||||
fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {}
|
||||
fun onVouchPayloadReceived(peerID: String, payload: ByteArray) {}
|
||||
}
|
||||
|
||||
@ -122,6 +122,22 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
|
||||
var validPacket = true
|
||||
val messageType = MessageType.fromValue(packet.type)
|
||||
val isBroadcast = packet.recipientID == null ||
|
||||
packet.recipientID.contentEquals(com.bitchat.android.protocol.SpecialRecipients.BROADCAST)
|
||||
if (isBroadcast && packet.timestamp <= Long.MAX_VALUE.toULong()) {
|
||||
val ageMs = System.currentTimeMillis() - packet.timestamp.toLong()
|
||||
val maxAgeMs = when (messageType) {
|
||||
MessageType.MESSAGE -> com.bitchat.android.sync.GossipSyncManager.PUBLIC_MESSAGE_MAX_AGE_MS
|
||||
MessageType.FRAGMENT, MessageType.FILE_TRANSFER ->
|
||||
com.bitchat.android.sync.GossipSyncManager.FRAGMENT_MAX_AGE_MS
|
||||
else -> null
|
||||
}
|
||||
if (maxAgeMs != null && ageMs !in
|
||||
-com.bitchat.android.sync.GossipSyncManager.PUBLIC_PACKET_FUTURE_SKEW_MS..maxAgeMs
|
||||
) return
|
||||
} else if (isBroadcast && packet.timestamp > Long.MAX_VALUE.toULong()) {
|
||||
return
|
||||
}
|
||||
// Verbose logging to debug manager (and chat via ChatViewModel observer)
|
||||
try {
|
||||
val mt = messageType?.name ?: packet.type.toString()
|
||||
@ -137,15 +153,35 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
MessageType.MESSAGE -> handleMessage(routed)
|
||||
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
|
||||
MessageType.VOICE_FRAME -> validPacket = delegate?.handleVoiceFrame(routed) ?: false
|
||||
MessageType.GROUP_MESSAGE -> handleGroupMessage(routed)
|
||||
MessageType.BOARD_POST -> validPacket = handleBoardPost(routed)
|
||||
MessageType.LEAVE -> handleLeave(routed)
|
||||
MessageType.FRAGMENT -> handleFragment(routed)
|
||||
MessageType.REQUEST_SYNC -> handleRequestSync(routed)
|
||||
MessageType.PREKEY_BUNDLE -> {
|
||||
BridgeMeshPort.handlePrekeyPacket(packet)
|
||||
}
|
||||
MessageType.NOSTR_CARRIER -> {
|
||||
val directedToUs = packetRelayManager.isPacketAddressedToMe(packet)
|
||||
val isBroadcast = packet.recipientID == null ||
|
||||
packet.recipientID.contentEquals(delegate?.getBroadcastRecipient())
|
||||
if (directedToUs || isBroadcast) {
|
||||
BridgeMeshPort.handleCarrier(
|
||||
packet.payload,
|
||||
peerID,
|
||||
directedToUs
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Handle private packet types (address check required)
|
||||
if (packetRelayManager.isPacketAddressedToMe(packet)) {
|
||||
when (messageType) {
|
||||
MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed)
|
||||
MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed)
|
||||
MessageType.COURIER_ENVELOPE -> validPacket = delegate?.handleCourierEnvelope(routed) ?: false
|
||||
MessageType.PING -> delegate?.handlePing(routed)
|
||||
MessageType.PONG -> delegate?.handlePong(routed)
|
||||
MessageType.FILE_TRANSFER -> handleMessage(routed)
|
||||
else -> {
|
||||
validPacket = false
|
||||
@ -195,6 +231,12 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
private suspend fun handleMessage(routed: RoutedPacket) {
|
||||
delegate?.handleMessage(routed)
|
||||
}
|
||||
|
||||
private fun handleGroupMessage(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing private-group message from ${formatPeerForLog(peerID)}")
|
||||
delegate?.handleGroupMessage(routed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle leave message
|
||||
@ -226,8 +268,24 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
* Handle REQUEST_SYNC packets (public, TTL=1)
|
||||
*/
|
||||
private suspend fun handleRequestSync(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
if (routed.packet.ttl != com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) {
|
||||
Log.w(TAG, "Dropping non-link-local REQUEST_SYNC from ${formatPeerForLog(peerID)}")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "Processing REQUEST_SYNC from ${formatPeerForLog(peerID)}")
|
||||
delegate?.handleRequestSync(routed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Board packets are self-authenticating. The delegate verifies their
|
||||
* embedded Ed25519 signature and returns false for anything that must not relay.
|
||||
*/
|
||||
private fun handleBoardPost(routed: RoutedPacket): Boolean {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing board packet from ${formatPeerForLog(peerID)}")
|
||||
return delegate?.handleBoardPost(routed) ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle delivery acknowledgment
|
||||
@ -291,16 +349,22 @@ interface PacketProcessorDelegate {
|
||||
// Network information
|
||||
fun getNetworkSize(): Int
|
||||
fun getBroadcastRecipient(): ByteArray
|
||||
fun isPeerDirectlyConnected(peerID: String): Boolean = false
|
||||
|
||||
// Message type handlers
|
||||
fun handleNoiseHandshake(routed: RoutedPacket): Boolean
|
||||
fun handleNoiseEncrypted(routed: RoutedPacket): Boolean
|
||||
fun handleCourierEnvelope(routed: RoutedPacket): Boolean = false
|
||||
suspend fun handleAnnounce(routed: RoutedPacket): Boolean
|
||||
fun handleMessage(routed: RoutedPacket)
|
||||
fun handleVoiceFrame(routed: RoutedPacket): Boolean = false
|
||||
fun handleGroupMessage(routed: RoutedPacket) {}
|
||||
fun handleLeave(routed: RoutedPacket)
|
||||
fun handleFragment(packet: BitchatPacket): BitchatPacket?
|
||||
fun handleRequestSync(routed: RoutedPacket)
|
||||
fun handleBoardPost(routed: RoutedPacket): Boolean = false
|
||||
fun handlePing(routed: RoutedPacket) {}
|
||||
fun handlePong(routed: RoutedPacket) {}
|
||||
|
||||
// Communication
|
||||
fun sendAnnouncementToPeer(peerID: String)
|
||||
|
||||
@ -4,8 +4,10 @@ import com.bitchat.android.protocol.MessageType
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MeshDiagnosticsConstants
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
@ -32,6 +34,7 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
|
||||
// Coroutines
|
||||
private val relayScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val diagnosticRelayTimestamps = ConcurrentHashMap<String, ArrayDeque<Long>>()
|
||||
|
||||
/**
|
||||
* Main entry point for relay decisions
|
||||
@ -60,6 +63,13 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
Log.d(TAG, "TTL expired, not relaying packet")
|
||||
return
|
||||
}
|
||||
|
||||
val isDiagnostic = MessageType.fromValue(packet.type) in
|
||||
setOf(MessageType.PING, MessageType.PONG)
|
||||
if (isDiagnostic && !consumeDiagnosticRelayBudget(routed, peerID)) {
|
||||
Log.w(TAG, "Diagnostic relay budget exhausted for ingress link")
|
||||
return
|
||||
}
|
||||
|
||||
// Decrement TTL by 1
|
||||
val networkSize = delegate?.getNetworkSize() ?: 1
|
||||
@ -108,11 +118,36 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
// Apply relay logic based on packet type and debug switch
|
||||
val shouldRelay = isRelayEnabled() && shouldRelayPacket(relayPacket, peerID)
|
||||
if (shouldRelay) {
|
||||
if (isDiagnostic) {
|
||||
delay(
|
||||
Random.nextLong(
|
||||
MeshDiagnosticsConstants.RELAY_JITTER_MIN_MILLIS,
|
||||
MeshDiagnosticsConstants.RELAY_JITTER_MAX_MILLIS + 1,
|
||||
)
|
||||
)
|
||||
}
|
||||
relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress))
|
||||
} else {
|
||||
Log.d(TAG, "Relay decision: NOT relaying packet type ${packet.type}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumeDiagnosticRelayBudget(routed: RoutedPacket, fallbackPeerID: String): Boolean {
|
||||
val ingressKey = routed.ingressLinkID ?: routed.relayAddress ?: fallbackPeerID
|
||||
val now = System.currentTimeMillis()
|
||||
val timestamps = diagnosticRelayTimestamps.computeIfAbsent(ingressKey) { ArrayDeque() }
|
||||
synchronized(timestamps) {
|
||||
while (timestamps.firstOrNull()?.let {
|
||||
now - it >= MeshDiagnosticsConstants.INBOUND_RATE_WINDOW_MILLIS
|
||||
} == true
|
||||
) {
|
||||
timestamps.removeFirst()
|
||||
}
|
||||
if (timestamps.size >= MeshDiagnosticsConstants.INBOUND_RATE_LIMIT) return false
|
||||
timestamps.addLast(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a packet is specifically addressed to us
|
||||
@ -140,6 +175,9 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
* Determine if we should relay this packet based on type and network conditions
|
||||
*/
|
||||
private fun shouldRelayPacket(packet: BitchatPacket, fromPeerID: String): Boolean {
|
||||
if (MessageType.fromValue(packet.type) in setOf(MessageType.PING, MessageType.PONG)) {
|
||||
return true
|
||||
}
|
||||
// Always relay if TTL is high enough (indicates important message)
|
||||
if (packet.ttl >= 4u) {
|
||||
Log.d(TAG, "High TTL (${packet.ttl}), relaying")
|
||||
|
||||
@ -89,8 +89,10 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce mandatory signature verification
|
||||
if (!verifyPacketSignature(packet, peerID)) {
|
||||
// Mesh diagnostics are intentionally unsigned for iOS wire compatibility.
|
||||
val isUnsignedDiagnostic =
|
||||
messageType in setOf(MessageType.PING, MessageType.PONG) && packet.signature == null
|
||||
if (!isUnsignedDiagnostic && !verifyPacketSignature(packet, peerID)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@ -257,8 +259,11 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
MessageType.ANNOUNCE,
|
||||
MessageType.MESSAGE,
|
||||
MessageType.FILE_TRANSFER,
|
||||
MessageType.COURIER_ENVELOPE,
|
||||
MessageType.VOICE_FRAME,
|
||||
MessageType.LEAVE
|
||||
MessageType.LEAVE,
|
||||
MessageType.REQUEST_SYNC,
|
||||
MessageType.NOSTR_CARRIER
|
||||
)) {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -5,7 +5,9 @@ import android.util.Log
|
||||
import com.bitchat.android.favorites.FavoriteControlMessage
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.service.TransportBridgeService
|
||||
import com.bitchat.android.wifiaware.WifiAwareController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -37,6 +39,10 @@ class UnifiedMeshService(
|
||||
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
private val powerManager = PowerManager.getInstance(context.applicationContext)
|
||||
private var announcementJob: Job? = null
|
||||
private val diagnosticsScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val meshPingManager = MeshPingManager(bluetooth.myPeerID, diagnosticsScope) { packet ->
|
||||
TransportBridgeService.broadcastFromLocal(RoutedPacket(packet))
|
||||
}
|
||||
|
||||
override val myPeerID: String
|
||||
get() = bluetooth.myPeerID
|
||||
@ -101,6 +107,29 @@ class UnifiedMeshService(
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String?) {
|
||||
when {
|
||||
isBleEnabled() -> bluetooth.sendNostrCarrier(payload, recipientPeerID)
|
||||
else -> wifiService()?.sendNostrCarrier(payload, recipientPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) {
|
||||
when {
|
||||
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
|
||||
bluetooth.sendCourierEnvelope(payload, recipientPeerID)
|
||||
else -> wifiService()?.sendCourierEnvelope(payload, recipientPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendPrekeyBundle(payload: ByteArray) {
|
||||
if (isBleEnabled()) {
|
||||
bluetooth.sendPrekeyBundle(payload)
|
||||
} else {
|
||||
wifiService()?.sendPrekeyBundle(payload)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendPrivateMessage(
|
||||
content: String,
|
||||
recipientPeerID: String,
|
||||
@ -116,6 +145,25 @@ class UnifiedMeshService(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPeerInfos(): List<PeerInfo> = bluetooth.getPeerInfos()
|
||||
|
||||
override fun sendCourierMessage(
|
||||
content: String,
|
||||
messageID: String,
|
||||
recipientNoiseKey: ByteArray,
|
||||
courierPeerIDs: List<String>
|
||||
): List<String> = bluetooth.sendCourierMessage(content, messageID, recipientNoiseKey, courierPeerIDs)
|
||||
|
||||
override fun sendBridgeCourierMessage(
|
||||
content: String,
|
||||
messageID: String,
|
||||
recipientNoiseKey: ByteArray,
|
||||
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)
|
||||
@ -150,6 +198,30 @@ class UnifiedMeshService(
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) {
|
||||
when {
|
||||
isBleReady(recipientPeerID) -> bluetooth.sendGroupInvite(payload, recipientPeerID)
|
||||
isWifiReady(recipientPeerID) ->
|
||||
wifiService()?.sendGroupInvite(payload, recipientPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) {
|
||||
when {
|
||||
isBleReady(recipientPeerID) ->
|
||||
bluetooth.sendGroupKeyUpdate(payload, recipientPeerID)
|
||||
isWifiReady(recipientPeerID) ->
|
||||
wifiService()?.sendGroupKeyUpdate(payload, recipientPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun broadcastGroupMessage(payload: ByteArray) {
|
||||
when {
|
||||
isBleEnabled() -> bluetooth.broadcastGroupMessage(payload)
|
||||
else -> wifiService()?.broadcastGroupMessage(payload)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendFileBroadcast(file: BitchatFilePacket) {
|
||||
when {
|
||||
isBleEnabled() -> bluetooth.sendFileBroadcast(file)
|
||||
@ -184,6 +256,15 @@ class UnifiedMeshService(
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendBoardPayload(payload: ByteArray) {
|
||||
when {
|
||||
isBleEnabled() -> bluetooth.sendBoardPayload(payload)
|
||||
else -> wifiService()?.sendBoardPayload(payload)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
override fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
@ -239,6 +320,10 @@ class UnifiedMeshService(
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit) {
|
||||
meshPingManager.ping(peerID, callback)
|
||||
}
|
||||
|
||||
override fun getPeerNicknames(): Map<String, String> {
|
||||
val merged = linkedMapOf<String, String>()
|
||||
try { merged.putAll(wifiService()?.getPeerNicknames().orEmpty()) } catch (_: Exception) { }
|
||||
@ -327,6 +412,12 @@ class UnifiedMeshService(
|
||||
return bluetooth.getStaticNoisePublicKey() ?: wifiService()?.getStaticNoisePublicKey()
|
||||
}
|
||||
|
||||
override fun getSigningPublicKey(): ByteArray? =
|
||||
bluetooth.getSigningPublicKey() ?: wifiService()?.getSigningPublicKey()
|
||||
|
||||
override fun signData(data: ByteArray): ByteArray? =
|
||||
bluetooth.signData(data) ?: wifiService()?.signData(data)
|
||||
|
||||
override fun shouldShowEncryptionIcon(peerID: String): Boolean {
|
||||
return hasEstablishedSession(peerID)
|
||||
}
|
||||
@ -351,6 +442,13 @@ class UnifiedMeshService(
|
||||
return merged
|
||||
}
|
||||
|
||||
override fun getDirectBlePeerIDs(): Set<String> =
|
||||
try {
|
||||
bluetooth.getDeviceAddressToPeerMapping().values.toSet()
|
||||
} catch (_: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
override fun printDeviceAddressesForPeers(): String {
|
||||
return buildString {
|
||||
appendLine(bluetooth.printDeviceAddressesForPeers())
|
||||
@ -376,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() {
|
||||
@ -413,6 +513,26 @@ class UnifiedMeshService(
|
||||
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun didReceiveGroupInvite(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun didReceiveGroupKeyUpdate(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun didReceiveGroupMessage(payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveGroupMessage(payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun didResolvePrivateMediaPolicy(peerID: String) {
|
||||
delegate?.didResolvePrivateMediaPolicy(peerID)
|
||||
}
|
||||
|
||||
127
app/src/main/java/com/bitchat/android/mesh/VouchCoordinator.kt
Normal file
127
app/src/main/java/com/bitchat/android/mesh/VouchCoordinator.kt
Normal file
@ -0,0 +1,127 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.util.Log
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.model.VouchAttestation
|
||||
import com.bitchat.android.util.dataFromHexString
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Transport-neutral exchange and acceptance policy for transitive verification.
|
||||
*
|
||||
* All payloads supplied to [handlePayload] have already been authenticated and
|
||||
* decrypted by the Noise session for [peerID].
|
||||
*/
|
||||
class VouchCoordinator(
|
||||
private val scope: CoroutineScope,
|
||||
private val identity: SecureIdentityStateManager,
|
||||
private val connectedPeerIDs: () -> Collection<String>,
|
||||
private val fingerprintForPeer: (String) -> String?,
|
||||
private val peerInfo: (String) -> PeerInfo?,
|
||||
private val signingKeyForFingerprint: (String) -> ByteArray?,
|
||||
private val hasEstablishedSession: (String) -> Boolean,
|
||||
private val sign: (ByteArray) -> ByteArray?,
|
||||
private val verify: (ByteArray, ByteArray, ByteArray) -> Boolean,
|
||||
private val send: (String, ByteArray) -> Boolean
|
||||
) {
|
||||
init {
|
||||
scope.launch {
|
||||
SecureIdentityStateManager.changes.collect {
|
||||
vouchToConnectedVerifiedPeers()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun peerAuthenticated(peerID: String, fingerprint: String) {
|
||||
attemptVouch(peerID, fingerprint)
|
||||
}
|
||||
|
||||
fun peersUpdated(peerIDs: Collection<String>) {
|
||||
peerIDs.forEach { peerID ->
|
||||
fingerprintForPeer(peerID)?.let { attemptVouch(peerID, it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun vouchToConnectedVerifiedPeers(nowMs: Long = System.currentTimeMillis()) {
|
||||
connectedPeerIDs().forEach { peerID ->
|
||||
fingerprintForPeer(peerID)?.let { attemptVouch(peerID, it, nowMs) }
|
||||
}
|
||||
}
|
||||
|
||||
fun attemptVouch(
|
||||
peerID: String,
|
||||
peerFingerprint: String,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): Boolean {
|
||||
val normalizedPeerFingerprint = peerFingerprint.lowercase()
|
||||
if (!hasEstablishedSession(peerID) ||
|
||||
!identity.isVerifiedFingerprint(normalizedPeerFingerprint)
|
||||
) return false
|
||||
|
||||
val capabilities = peerInfo(peerID)?.capabilities
|
||||
if (capabilities != null && capabilities != PeerCapabilities.NONE &&
|
||||
!capabilities.contains(PeerCapabilities.VOUCH)
|
||||
) return false
|
||||
|
||||
val lastSent = identity.lastVouchBatchSent(normalizedPeerFingerprint)
|
||||
if (lastSent != null && nowMs - lastSent < BATCH_INTERVAL_MS) return false
|
||||
|
||||
val attestations = identity.mostRecentlyVerifiedFingerprints(
|
||||
VouchAttestation.MAX_BATCH_COUNT,
|
||||
excluding = normalizedPeerFingerprint
|
||||
).mapNotNull { vouchee ->
|
||||
val fingerprintBytes = vouchee.dataFromHexString() ?: return@mapNotNull null
|
||||
val signingKey = signingKeyForFingerprint(vouchee) ?: return@mapNotNull null
|
||||
VouchAttestation.build(fingerprintBytes, signingKey, nowMs, sign)
|
||||
}
|
||||
val payload = VouchAttestation.encodeList(attestations) ?: return false
|
||||
if (!send(peerID, payload)) return false
|
||||
identity.markVouchBatchSent(normalizedPeerFingerprint, nowMs)
|
||||
Log.d(TAG, "Sent ${attestations.size} vouch(es) to ${peerID.take(LOG_FINGERPRINT_LENGTH)}")
|
||||
return true
|
||||
}
|
||||
|
||||
fun handlePayload(
|
||||
peerID: String,
|
||||
payload: ByteArray,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
) {
|
||||
val senderFingerprint = fingerprintForPeer(peerID)?.lowercase() ?: return
|
||||
if (!identity.isVerifiedFingerprint(senderFingerprint)) return
|
||||
val senderSigningKey = signingKeyForFingerprint(senderFingerprint) ?: return
|
||||
|
||||
var accepted = INITIAL_ACCEPTED_COUNT
|
||||
VouchAttestation.decodeList(payload).forEach { attestation ->
|
||||
if (!attestation.isExpired(nowMs) &&
|
||||
verify(attestation.signature, attestation.signableBytes(), senderSigningKey) &&
|
||||
identity.recordVouch(
|
||||
attestation.voucheeFingerprint.hexEncodedString(),
|
||||
senderFingerprint,
|
||||
attestation.voucheeSigningKey,
|
||||
attestation.timestampMs,
|
||||
nowMs
|
||||
)
|
||||
) accepted++
|
||||
}
|
||||
if (accepted > INITIAL_ACCEPTED_COUNT) {
|
||||
Log.i(TAG, "Accepted $accepted vouch(es) from ${peerID.take(LOG_FINGERPRINT_LENGTH)}")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "VouchCoordinator"
|
||||
private const val INITIAL_ACCEPTED_COUNT = 0
|
||||
private const val LOG_FINGERPRINT_LENGTH = 8
|
||||
private const val HOURS_PER_DAY = 24L
|
||||
private const val MINUTES_PER_HOUR = 60L
|
||||
private const val SECONDS_PER_MINUTE = 60L
|
||||
private const val MILLIS_PER_SECOND = 1000L
|
||||
const val BATCH_INTERVAL_MS =
|
||||
HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLIS_PER_SECOND
|
||||
val NOISE_PACKET_VERSION: UByte = 1u
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,9 @@ sealed class DeliveryStatus : Parcelable {
|
||||
@Parcelize
|
||||
object Sending : DeliveryStatus()
|
||||
|
||||
@Parcelize
|
||||
object Queued : DeliveryStatus()
|
||||
|
||||
@Parcelize
|
||||
object Sent : DeliveryStatus()
|
||||
|
||||
@ -40,6 +43,7 @@ sealed class DeliveryStatus : Parcelable {
|
||||
fun getDisplayText(): String {
|
||||
return when (this) {
|
||||
is Sending -> "Sending..."
|
||||
is Queued -> "Queued"
|
||||
is Sent -> "Sent"
|
||||
is Delivered -> "Delivered to ${this.to}"
|
||||
is Read -> "Read by ${this.by}"
|
||||
@ -77,7 +81,15 @@ data class BitchatMessage(
|
||||
* surfaces color the sender by the same stable key while [senderPeerID] remains available for
|
||||
* mesh IDs and private-chat routing aliases.
|
||||
*/
|
||||
val senderNostrPubkey: String? = null
|
||||
val senderNostrPubkey: String? = null,
|
||||
/** Rendered from a signed bridge rendezvous event rather than local radio. */
|
||||
val isBridged: Boolean = false,
|
||||
/**
|
||||
* Untrusted radio-coordinate hint from the bridge event. It may merge a
|
||||
* duplicate when the authenticated radio copy arrives, but never owns the
|
||||
* bridge row's primary ID.
|
||||
*/
|
||||
val bridgeRadioMessageIdHint: String? = null
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
@ -362,4 +374,3 @@ data class BitchatMessage(
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
115
app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt
Normal file
115
app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt
Normal file
@ -0,0 +1,115 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/** Opaque store-and-forward envelope, wire-compatible with iOS CourierEnvelope. */
|
||||
data class CourierEnvelope(
|
||||
val recipientTag: ByteArray,
|
||||
val expiry: ULong,
|
||||
val ciphertext: ByteArray,
|
||||
val copies: UByte = 1u,
|
||||
val prekeyID: UInt? = null
|
||||
) {
|
||||
companion object {
|
||||
const val TAG_LENGTH = 16
|
||||
const val MAX_CIPHERTEXT_BYTES = 16 * 1024
|
||||
const val MAX_LIFETIME_MS = 24 * 60 * 60 * 1000L
|
||||
const val MAX_COPIES = 8
|
||||
private val TAG_DOMAIN = "bitchat-courier-tag-v1".toByteArray(Charsets.UTF_8)
|
||||
|
||||
fun epochDay(nowMs: Long): UInt = Math.floorDiv(nowMs, 86_400_000L).toUInt()
|
||||
|
||||
fun recipientTag(noiseStaticKey: ByteArray, epochDay: UInt): ByteArray {
|
||||
require(noiseStaticKey.size == 32)
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(SecretKeySpec(noiseStaticKey, "HmacSHA256"))
|
||||
mac.update(TAG_DOMAIN)
|
||||
mac.update(ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(epochDay.toInt()).array())
|
||||
return mac.doFinal().copyOf(TAG_LENGTH)
|
||||
}
|
||||
|
||||
fun decode(data: ByteArray): CourierEnvelope? {
|
||||
var offset = 0
|
||||
var tag: ByteArray? = null
|
||||
var expiry: ULong? = null
|
||||
var ciphertext: ByteArray? = null
|
||||
var copies: UByte = 1u
|
||||
var prekeyID: UInt? = null
|
||||
while (offset + 3 <= data.size) {
|
||||
val type = data[offset].toUByte()
|
||||
val length = ((data[offset + 1].toInt() and 0xff) shl 8) or
|
||||
(data[offset + 2].toInt() and 0xff)
|
||||
offset += 3
|
||||
if (offset + length > data.size) return null
|
||||
val value = data.copyOfRange(offset, offset + length)
|
||||
offset += length
|
||||
when (type.toInt()) {
|
||||
1 -> if (length == TAG_LENGTH && tag == null) tag = value else return null
|
||||
2 -> if (length == 8 && expiry == null) expiry = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).long.toULong() else return null
|
||||
3 -> if (ciphertext == null) ciphertext = value else return null
|
||||
4 -> {
|
||||
if (length != 1 || copies != 1u.toUByte()) return null
|
||||
copies = value[0].toUByte()
|
||||
if (copies !in 2u.toUByte()..MAX_COPIES.toUByte()) return null
|
||||
}
|
||||
5 -> if (length == 4 && prekeyID == null) {
|
||||
prekeyID = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).int.toUInt()
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (offset != data.size) return null
|
||||
val requiredTag = tag ?: return null
|
||||
val requiredExpiry = expiry ?: return null
|
||||
val requiredCiphertext = ciphertext ?: return null
|
||||
if (requiredCiphertext.isEmpty() || requiredCiphertext.size > MAX_CIPHERTEXT_BYTES) return null
|
||||
return CourierEnvelope(requiredTag, requiredExpiry, requiredCiphertext, copies, prekeyID)
|
||||
}
|
||||
}
|
||||
|
||||
fun encode(): ByteArray? {
|
||||
if (recipientTag.size != TAG_LENGTH || ciphertext.isEmpty() || ciphertext.size > MAX_CIPHERTEXT_BYTES ||
|
||||
copies !in 1u.toUByte()..MAX_COPIES.toUByte()
|
||||
) return null
|
||||
val fields = mutableListOf<Pair<Int, ByteArray>>()
|
||||
fields += 1 to recipientTag
|
||||
fields += 2 to ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN).putLong(expiry.toLong()).array()
|
||||
fields += 3 to ciphertext
|
||||
if (copies > 1u) fields += 4 to byteArrayOf(copies.toByte())
|
||||
prekeyID?.let {
|
||||
fields += 5 to ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(it.toInt()).array()
|
||||
}
|
||||
val size = fields.sumOf { 3 + it.second.size }
|
||||
val buffer = ByteBuffer.allocate(size).order(ByteOrder.BIG_ENDIAN)
|
||||
fields.forEach { (type, value) ->
|
||||
buffer.put(type.toByte())
|
||||
buffer.putShort(value.size.toShort())
|
||||
buffer.put(value)
|
||||
}
|
||||
return buffer.array()
|
||||
}
|
||||
|
||||
fun matchesRecipient(noiseStaticKey: ByteArray, nowMs: Long): Boolean {
|
||||
val day = epochDay(nowMs)
|
||||
return listOf(day - 1u, day, day + 1u).any {
|
||||
recipientTag.contentEquals(recipientTag(noiseStaticKey, it))
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is CourierEnvelope &&
|
||||
recipientTag.contentEquals(other.recipientTag) && expiry == other.expiry &&
|
||||
ciphertext.contentEquals(other.ciphertext) && copies == other.copies && prekeyID == other.prekeyID
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = recipientTag.contentHashCode()
|
||||
result = 31 * result + expiry.hashCode()
|
||||
result = 31 * result + ciphertext.contentHashCode()
|
||||
result = 31 * result + copies.hashCode()
|
||||
result = 31 * result + (prekeyID?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@ -13,7 +13,8 @@ data class IdentityAnnouncement(
|
||||
val noisePublicKey: ByteArray, // Noise static public key (Curve25519.KeyAgreement)
|
||||
val signingPublicKey: ByteArray, // Ed25519 public key for signing
|
||||
val capabilities: PeerCapabilities? = null,
|
||||
val unknownTLVs: List<UnknownAnnouncementTLV> = emptyList()
|
||||
val unknownTLVs: List<UnknownAnnouncementTLV> = emptyList(),
|
||||
val bridgeGeohash: String? = null
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
@ -23,7 +24,8 @@ data class IdentityAnnouncement(
|
||||
NICKNAME(0x01u),
|
||||
NOISE_PUBLIC_KEY(0x02u),
|
||||
SIGNING_PUBLIC_KEY(0x03u), // NEW: Ed25519 signing public key
|
||||
CAPABILITIES(0x05u);
|
||||
CAPABILITIES(0x05u),
|
||||
BRIDGE_GEOHASH(0x06u);
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: UByte): TLVType? {
|
||||
@ -39,6 +41,9 @@ data class IdentityAnnouncement(
|
||||
val nicknameData = nickname.toByteArray(Charsets.UTF_8)
|
||||
|
||||
// Check size limits
|
||||
val bridgeGeohashData = bridgeGeohash
|
||||
?.toByteArray(Charsets.UTF_8)
|
||||
?.takeIf { it.size in 1..12 }
|
||||
if (nicknameData.size > 255 || noisePublicKey.size > 255 || signingPublicKey.size > 255 ||
|
||||
unknownTLVs.any { it.value.size > 255 }) {
|
||||
return null
|
||||
@ -68,6 +73,12 @@ data class IdentityAnnouncement(
|
||||
result.addAll(capabilityBytes.toList())
|
||||
}
|
||||
|
||||
bridgeGeohashData?.let { geohash ->
|
||||
result.add(TLVType.BRIDGE_GEOHASH.value.toByte())
|
||||
result.add(geohash.size.toByte())
|
||||
result.addAll(geohash.toList())
|
||||
}
|
||||
|
||||
// Preserve extensions this build does not understand. This includes
|
||||
// gossip TLV 0x04 when an announcement is decoded through this model.
|
||||
unknownTLVs.forEach { tlv ->
|
||||
@ -92,6 +103,7 @@ data class IdentityAnnouncement(
|
||||
var noisePublicKey: ByteArray? = null
|
||||
var signingPublicKey: ByteArray? = null
|
||||
var capabilities: PeerCapabilities? = null
|
||||
var bridgeGeohash: String? = null
|
||||
val unknownTLVs = mutableListOf<UnknownAnnouncementTLV>()
|
||||
|
||||
while (offset + 2 <= dataCopy.size) {
|
||||
@ -125,6 +137,10 @@ data class IdentityAnnouncement(
|
||||
TLVType.CAPABILITIES -> {
|
||||
capabilities = PeerCapabilities.decode(value)
|
||||
}
|
||||
TLVType.BRIDGE_GEOHASH -> {
|
||||
if (value.size !in 1..12) return null
|
||||
bridgeGeohash = String(value, Charsets.UTF_8)
|
||||
}
|
||||
null -> {
|
||||
// Retain unknown extensions so callers can forward or
|
||||
// re-encode the announcement without erasing them.
|
||||
@ -135,7 +151,14 @@ data class IdentityAnnouncement(
|
||||
|
||||
// All three fields are required
|
||||
return if (nickname != null && noisePublicKey != null && signingPublicKey != null) {
|
||||
IdentityAnnouncement(nickname, noisePublicKey, signingPublicKey, capabilities, unknownTLVs)
|
||||
IdentityAnnouncement(
|
||||
nickname,
|
||||
noisePublicKey,
|
||||
signingPublicKey,
|
||||
capabilities,
|
||||
unknownTLVs,
|
||||
bridgeGeohash
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@ -145,12 +168,14 @@ data class IdentityAnnouncement(
|
||||
fun forLocalPeer(
|
||||
nickname: String,
|
||||
noisePublicKey: ByteArray,
|
||||
signingPublicKey: ByteArray
|
||||
signingPublicKey: ByteArray,
|
||||
bridgeGeohash: String? = null
|
||||
): IdentityAnnouncement = IdentityAnnouncement(
|
||||
nickname = nickname,
|
||||
noisePublicKey = noisePublicKey,
|
||||
signingPublicKey = signingPublicKey,
|
||||
capabilities = PeerCapabilities.LOCAL_SUPPORTED
|
||||
capabilities = PeerCapabilities.localSupported(),
|
||||
bridgeGeohash = bridgeGeohash
|
||||
)
|
||||
}
|
||||
|
||||
@ -166,6 +191,7 @@ data class IdentityAnnouncement(
|
||||
if (!signingPublicKey.contentEquals(other.signingPublicKey)) return false
|
||||
if (capabilities != other.capabilities) return false
|
||||
if (unknownTLVs != other.unknownTLVs) return false
|
||||
if (bridgeGeohash != other.bridgeGeohash) return false
|
||||
|
||||
return true
|
||||
}
|
||||
@ -176,10 +202,11 @@ data class IdentityAnnouncement(
|
||||
result = 31 * result + signingPublicKey.contentHashCode()
|
||||
result = 31 * result + (capabilities?.hashCode() ?: 0)
|
||||
result = 31 * result + unknownTLVs.hashCode()
|
||||
result = 31 * result + (bridgeGeohash?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., capabilities=${capabilities?.rawValue})"
|
||||
return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., capabilities=${capabilities?.rawValue}, bridgeGeohash=$bridgeGeohash)"
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,8 +22,11 @@ enum class NoisePayloadType(val value: UByte) {
|
||||
READ_RECEIPT(0x02u), // Message was read
|
||||
DELIVERED(0x03u), // Message was delivered
|
||||
VOICE_FRAME(0x08u), // Ephemeral live push-to-talk frame
|
||||
GROUP_INVITE(0x06u), // Creator-signed private-group state
|
||||
GROUP_KEY_UPDATE(0x07u), // Creator-signed roster/key rotation
|
||||
VERIFY_CHALLENGE(0x10u), // Verification challenge
|
||||
VERIFY_RESPONSE(0x11u), // Verification response
|
||||
VOUCH(0x12u), // Transitive verification attestations
|
||||
FILE_TRANSFER(0x20u),
|
||||
/** Authenticated capabilities + Ed25519 binding for the current Noise generation. */
|
||||
PEER_STATE(0x21u);
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import com.bitchat.android.nostr.NostrEvent
|
||||
|
||||
/**
|
||||
* Wire payload for MessageType.NOSTR_CARRIER (0x28).
|
||||
*
|
||||
* The TLV layout and limits intentionally match iOS. Lengths are unsigned
|
||||
* 16-bit big-endian values and unknown TLVs are skipped.
|
||||
*/
|
||||
data class NostrCarrierPacket(
|
||||
val direction: Direction,
|
||||
val geohash: String,
|
||||
val eventJson: ByteArray
|
||||
) {
|
||||
enum class Direction(val value: Int) {
|
||||
TO_GATEWAY(0x01),
|
||||
FROM_GATEWAY(0x02),
|
||||
TO_BRIDGE(0x03),
|
||||
FROM_BRIDGE(0x04);
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: Int): Direction? = entries.firstOrNull { it.value == value }
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
require(geohash.toByteArray(Charsets.UTF_8).size in 1..MAX_GEOHASH_LENGTH)
|
||||
require(eventJson.size in 1..MAX_EVENT_JSON_BYTES)
|
||||
}
|
||||
|
||||
fun event(): NostrEvent? =
|
||||
NostrEvent.fromJsonString(String(eventJson, Charsets.UTF_8))
|
||||
|
||||
fun encode(): ByteArray {
|
||||
return checkNotNull(
|
||||
Tlv16Codec.encode(
|
||||
Tlv16Codec.Field(TLV_DIRECTION, byteArrayOf(direction.value.toByte())),
|
||||
Tlv16Codec.Field(TLV_GEOHASH, geohash.toByteArray(Charsets.UTF_8)),
|
||||
Tlv16Codec.Field(TLV_EVENT_JSON, eventJson)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_EVENT_JSON_BYTES = 16 * 1024
|
||||
const val MAX_GEOHASH_LENGTH = 12
|
||||
|
||||
private const val TLV_DIRECTION = 0x01
|
||||
private const val TLV_GEOHASH = 0x02
|
||||
private const val TLV_EVENT_JSON = 0x03
|
||||
|
||||
fun fromEvent(direction: Direction, geohash: String, event: NostrEvent): NostrCarrierPacket? =
|
||||
runCatching {
|
||||
NostrCarrierPacket(
|
||||
direction = direction,
|
||||
geohash = geohash,
|
||||
eventJson = event.toJsonString().toByteArray(Charsets.UTF_8)
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
fun decode(data: ByteArray): NostrCarrierPacket? {
|
||||
var direction: Direction? = null
|
||||
var geohash: String? = null
|
||||
var eventJson: ByteArray? = null
|
||||
|
||||
Tlv16Codec.decode(data)?.forEach { field ->
|
||||
when (field.type) {
|
||||
TLV_DIRECTION -> {
|
||||
if (field.value.size != 1) return null
|
||||
direction =
|
||||
Direction.fromValue(field.value[0].toInt() and 0xFF) ?: return null
|
||||
}
|
||||
TLV_GEOHASH -> {
|
||||
geohash = field.value.toString(Charsets.UTF_8)
|
||||
}
|
||||
TLV_EVENT_JSON -> eventJson = field.value
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
return runCatching {
|
||||
NostrCarrierPacket(
|
||||
direction = direction ?: return null,
|
||||
geohash = geohash ?: return null,
|
||||
eventJson = eventJson ?: return null
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
this === other ||
|
||||
(other is NostrCarrierPacket &&
|
||||
direction == other.direction &&
|
||||
geohash == other.geohash &&
|
||||
eventJson.contentEquals(other.eventJson))
|
||||
|
||||
override fun hashCode(): Int =
|
||||
31 * (31 * direction.hashCode() + geohash.hashCode()) + eventJson.contentHashCode()
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bitchat.android.protocol.MeshDiagnosticsConstants
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
@ -27,27 +28,57 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val VOUCH_BIT_INDEX = 5
|
||||
private const val PRIVATE_MEDIA_BIT_INDEX = 8
|
||||
val NONE = PeerCapabilities(0)
|
||||
|
||||
val PREKEYS = PeerCapabilities(1L shl 0)
|
||||
val WIFI_BULK = PeerCapabilities(1L shl 1)
|
||||
val GATEWAY = PeerCapabilities(1L shl 2)
|
||||
val GROUPS = PeerCapabilities(1L shl 3)
|
||||
val BOARD = PeerCapabilities(1L shl 4)
|
||||
val VOUCH = PeerCapabilities(1L shl 5)
|
||||
val MESH_DIAGNOSTICS = PeerCapabilities(1L shl 6)
|
||||
val BRIDGE = PeerCapabilities(1L shl 7)
|
||||
|
||||
/** Noise-encrypted private BitchatFilePacket using payload type 0x20. */
|
||||
val PRIVATE_MEDIA = PeerCapabilities(1L shl 8)
|
||||
val PRIVATE_MEDIA = PeerCapabilities(1L shl PRIVATE_MEDIA_BIT_INDEX)
|
||||
|
||||
/** Transitive verification attestations over authenticated Noise. */
|
||||
val VOUCH = PeerCapabilities(1L shl VOUCH_BIT_INDEX)
|
||||
|
||||
val PRIVATE_MEDIA_RECEIPTS = PeerCapabilities(1L shl 9)
|
||||
|
||||
/** Reserved by iOS; decode it but do not advertise or act on it. */
|
||||
val NON_DESTRUCTIVE_NOISE_REPLACEMENT = PeerCapabilities(1L shl 10)
|
||||
|
||||
/** Can bridge public mesh traffic through geohash rendezvous relays. */
|
||||
val BRIDGE = PeerCapabilities(1L shl 7)
|
||||
|
||||
/** Publishes signed one-time prekeys for forward-secret courier mail. */
|
||||
val PREKEYS = PeerCapabilities(1L shl 0)
|
||||
|
||||
|
||||
/** Capabilities implemented by this Android build. */
|
||||
val LOCAL_SUPPORTED = PRIVATE_MEDIA
|
||||
@Deprecated("Use localSupported() so runtime bridge state is included")
|
||||
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
|
||||
}
|
||||
|
||||
fun localSupported(): PeerCapabilities = PeerCapabilities(
|
||||
LOCAL_SUPPORTED.rawValue or
|
||||
(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)
|
||||
)
|
||||
|
||||
/**
|
||||
* Decode the low 64 bits and ignore any future extension bytes, which
|
||||
|
||||
171
app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt
Normal file
171
app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt
Normal file
@ -0,0 +1,171 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Signed batch of one-time Curve25519 keys carried by MessageType.PREKEY_BUNDLE (0x24).
|
||||
*
|
||||
* The canonical signing bytes and TLV representation intentionally match the
|
||||
* iOS BitFoundation implementation byte-for-byte.
|
||||
*/
|
||||
data class PrekeyBundle(
|
||||
val noiseStaticPublicKey: ByteArray,
|
||||
val prekeys: List<Prekey>,
|
||||
val generatedAt: Long,
|
||||
val signature: ByteArray
|
||||
) {
|
||||
data class Prekey(val id: Long, val publicKey: ByteArray) {
|
||||
init {
|
||||
require(id in 0..0xFFFF_FFFFL)
|
||||
require(publicKey.size == KEY_LENGTH)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
this === other ||
|
||||
(other is Prekey && id == other.id && publicKey.contentEquals(other.publicKey))
|
||||
|
||||
override fun hashCode(): Int = 31 * id.hashCode() + publicKey.contentHashCode()
|
||||
}
|
||||
|
||||
fun signableBytes(): ByteArray {
|
||||
val output = ByteArrayOutputStream(
|
||||
1 + SIGNING_CONTEXT.size + KEY_LENGTH + 1 + prekeys.size * PREKEY_ENTRY_LENGTH + Long.SIZE_BYTES
|
||||
)
|
||||
output.write(SIGNING_CONTEXT.size)
|
||||
output.write(SIGNING_CONTEXT)
|
||||
output.write(fixedKey(noiseStaticPublicKey))
|
||||
output.write(prekeys.size.coerceAtMost(0xFF))
|
||||
prekeys.take(0xFF).forEach { prekey ->
|
||||
output.write(uint32Bytes(prekey.id))
|
||||
output.write(fixedKey(prekey.publicKey))
|
||||
}
|
||||
output.write(uint64Bytes(generatedAt))
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
fun encode(): ByteArray? {
|
||||
if (noiseStaticPublicKey.size != KEY_LENGTH ||
|
||||
signature.size != SIGNATURE_LENGTH ||
|
||||
prekeys.isEmpty() ||
|
||||
prekeys.size > MAX_PREKEYS ||
|
||||
prekeys.map { it.id }.distinct().size != prekeys.size
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
val entries = ByteArrayOutputStream(prekeys.size * PREKEY_ENTRY_LENGTH)
|
||||
prekeys.forEach { prekey ->
|
||||
if (prekey.publicKey.size != KEY_LENGTH || prekey.id !in 0..0xFFFF_FFFFL) return null
|
||||
entries.write(uint32Bytes(prekey.id))
|
||||
entries.write(prekey.publicKey)
|
||||
}
|
||||
|
||||
return Tlv16Codec.encode(
|
||||
Tlv16Codec.Field(TLV_NOISE_STATIC_KEY, noiseStaticPublicKey),
|
||||
Tlv16Codec.Field(TLV_PREKEYS, entries.toByteArray()),
|
||||
Tlv16Codec.Field(TLV_GENERATED_AT, uint64Bytes(generatedAt)),
|
||||
Tlv16Codec.Field(TLV_SIGNATURE, signature)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_LENGTH = 32
|
||||
const val SIGNATURE_LENGTH = 64
|
||||
const val MAX_PREKEYS = 8
|
||||
private const val PREKEY_ENTRY_LENGTH = 4 + KEY_LENGTH
|
||||
|
||||
private val SIGNING_CONTEXT = "bitchat-prekey-bundle-v1".toByteArray(Charsets.UTF_8)
|
||||
private const val TLV_NOISE_STATIC_KEY = 0x01
|
||||
private const val TLV_PREKEYS = 0x02
|
||||
private const val TLV_GENERATED_AT = 0x03
|
||||
private const val TLV_SIGNATURE = 0x04
|
||||
|
||||
fun decode(data: ByteArray): PrekeyBundle? {
|
||||
var noiseStaticKey: ByteArray? = null
|
||||
var prekeys: List<Prekey>? = null
|
||||
var generatedAt: Long? = null
|
||||
var signature: ByteArray? = null
|
||||
|
||||
Tlv16Codec.decode(data)?.forEach { field ->
|
||||
when (field.type) {
|
||||
TLV_NOISE_STATIC_KEY -> {
|
||||
if (field.value.size != KEY_LENGTH) return null
|
||||
noiseStaticKey = field.value
|
||||
}
|
||||
TLV_PREKEYS -> {
|
||||
if (field.value.isEmpty() ||
|
||||
field.value.size % PREKEY_ENTRY_LENGTH != 0 ||
|
||||
field.value.size / PREKEY_ENTRY_LENGTH > MAX_PREKEYS
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val parsed = mutableListOf<Prekey>()
|
||||
var entryOffset = 0
|
||||
while (entryOffset < field.value.size) {
|
||||
val id = ByteBuffer.wrap(field.value, entryOffset, Int.SIZE_BYTES)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
.int.toLong() and 0xFFFF_FFFFL
|
||||
entryOffset += Int.SIZE_BYTES
|
||||
val publicKey =
|
||||
field.value.copyOfRange(entryOffset, entryOffset + KEY_LENGTH)
|
||||
entryOffset += KEY_LENGTH
|
||||
parsed += Prekey(id, publicKey)
|
||||
}
|
||||
if (parsed.map { it.id }.distinct().size != parsed.size) return null
|
||||
prekeys = parsed
|
||||
}
|
||||
TLV_GENERATED_AT -> {
|
||||
if (field.value.size != Long.SIZE_BYTES) return null
|
||||
generatedAt = ByteBuffer.wrap(field.value).order(ByteOrder.BIG_ENDIAN).long
|
||||
}
|
||||
TLV_SIGNATURE -> {
|
||||
if (field.value.size != SIGNATURE_LENGTH) return null
|
||||
signature = field.value
|
||||
}
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
return runCatching {
|
||||
PrekeyBundle(
|
||||
noiseStaticPublicKey = noiseStaticKey ?: return null,
|
||||
prekeys = prekeys?.takeIf { it.isNotEmpty() } ?: return null,
|
||||
generatedAt = generatedAt ?: return null,
|
||||
signature = signature ?: return null
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun uint32Bytes(value: Long): ByteArray =
|
||||
ByteBuffer.allocate(Int.SIZE_BYTES)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
.putInt(value.toInt())
|
||||
.array()
|
||||
|
||||
private fun uint64Bytes(value: Long): ByteArray =
|
||||
ByteBuffer.allocate(Long.SIZE_BYTES)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
.putLong(value)
|
||||
.array()
|
||||
|
||||
private fun fixedKey(key: ByteArray): ByteArray =
|
||||
key.copyOf(KEY_LENGTH)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
this === other ||
|
||||
(other is PrekeyBundle &&
|
||||
noiseStaticPublicKey.contentEquals(other.noiseStaticPublicKey) &&
|
||||
prekeys == other.prekeys &&
|
||||
generatedAt == other.generatedAt &&
|
||||
signature.contentEquals(other.signature))
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = noiseStaticPublicKey.contentHashCode()
|
||||
result = 31 * result + prekeys.hashCode()
|
||||
result = 31 * result + generatedAt.hashCode()
|
||||
result = 31 * result + signature.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import com.bitchat.android.sync.SyncDefaults
|
||||
import com.bitchat.android.sync.GCSFilter
|
||||
import com.bitchat.android.sync.SyncTypeFlags
|
||||
|
||||
/**
|
||||
* REQUEST_SYNC payload using GCS (Golomb-Coded Set) parameters.
|
||||
@ -8,11 +10,15 @@ import com.bitchat.android.sync.SyncDefaults
|
||||
* - 0x01: P (uint8) — Golomb-Rice parameter
|
||||
* - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
||||
* - 0x03: data (opaque) — GR bitstream bytes
|
||||
* - 0x04: types (compact little-endian SyncTypeFlags) — packet types covered by the filter
|
||||
* - 0x05: sinceTimestamp (uint64, big-endian) — oldest timestamp covered by the filter
|
||||
*/
|
||||
data class RequestSyncPacket(
|
||||
val p: Int,
|
||||
val m: Long,
|
||||
val data: ByteArray
|
||||
val data: ByteArray,
|
||||
val types: SyncTypeFlags? = null,
|
||||
val sinceTimestamp: ULong? = null
|
||||
) {
|
||||
fun encode(): ByteArray {
|
||||
val out = ArrayList<Byte>()
|
||||
@ -38,6 +44,15 @@ data class RequestSyncPacket(
|
||||
)
|
||||
// data
|
||||
putTLV(0x03, data)
|
||||
types?.encode()?.let { putTLV(0x04, it) }
|
||||
sinceTimestamp?.let { timestamp ->
|
||||
putTLV(
|
||||
0x05,
|
||||
ByteArray(8) { index ->
|
||||
(timestamp shr ((7 - index) * 8) and 0xffu).toByte()
|
||||
}
|
||||
)
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
@ -50,6 +65,8 @@ data class RequestSyncPacket(
|
||||
var p: Int? = null
|
||||
var m: Long? = null
|
||||
var payload: ByteArray? = null
|
||||
var types: SyncTypeFlags? = null
|
||||
var sinceTimestamp: ULong? = null
|
||||
|
||||
while (off + 3 <= data.size) {
|
||||
val t = (data[off].toInt() and 0xFF); off += 1
|
||||
@ -69,14 +86,20 @@ data class RequestSyncPacket(
|
||||
if (v.size > MAX_ACCEPT_FILTER_BYTES) return null
|
||||
payload = v
|
||||
}
|
||||
0x04 -> SyncTypeFlags.decode(v)?.let { types = it }
|
||||
0x05 -> if (v.size == 8) {
|
||||
var timestamp = 0uL
|
||||
v.forEach { byte -> timestamp = (timestamp shl 8) or byte.toUByte().toULong() }
|
||||
sinceTimestamp = timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val pp = p ?: return null
|
||||
val mm = m ?: return null
|
||||
val dd = payload ?: return null
|
||||
if (pp < 1 || mm <= 0L) return null
|
||||
return RequestSyncPacket(pp, mm, dd)
|
||||
if (pp !in 1..GCSFilter.MAX_P || mm <= 0L) return null
|
||||
return RequestSyncPacket(pp, mm, dd, types, sinceTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
47
app/src/main/java/com/bitchat/android/model/Tlv16Codec.kt
Normal file
47
app/src/main/java/com/bitchat/android/model/Tlv16Codec.kt
Normal file
@ -0,0 +1,47 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Minimal unsigned 16-bit big-endian TLV codec used by bridge wire models.
|
||||
*
|
||||
* Semantic validation intentionally remains in each model. This helper only
|
||||
* owns framing so every decoder rejects truncated and trailing data the same
|
||||
* way.
|
||||
*/
|
||||
internal object Tlv16Codec {
|
||||
data class Field(val type: Int, val value: ByteArray)
|
||||
|
||||
fun encode(vararg fields: Field): ByteArray? {
|
||||
val output = ByteArrayOutputStream(
|
||||
fields.sumOf { HEADER_SIZE + it.value.size }
|
||||
)
|
||||
fields.forEach { field ->
|
||||
if (field.type !in 0..0xFF || field.value.size > 0xFFFF) return null
|
||||
output.write(field.type)
|
||||
output.write((field.value.size ushr 8) and 0xFF)
|
||||
output.write(field.value.size and 0xFF)
|
||||
output.write(field.value)
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
fun decode(data: ByteArray): List<Field>? {
|
||||
val fields = mutableListOf<Field>()
|
||||
var offset = 0
|
||||
while (offset < data.size) {
|
||||
if (data.size - offset < HEADER_SIZE) return null
|
||||
val type = data[offset].toInt() and 0xFF
|
||||
val length =
|
||||
((data[offset + 1].toInt() and 0xFF) shl 8) or
|
||||
(data[offset + 2].toInt() and 0xFF)
|
||||
offset += HEADER_SIZE
|
||||
if (length > data.size - offset) return null
|
||||
fields += Field(type, data.copyOfRange(offset, offset + length))
|
||||
offset += length
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
private const val HEADER_SIZE = 3
|
||||
}
|
||||
193
app/src/main/java/com/bitchat/android/model/VouchAttestation.kt
Normal file
193
app/src/main/java/com/bitchat/android/model/VouchAttestation.kt
Normal file
@ -0,0 +1,193 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* An iOS-compatible, Ed25519-signed statement that the sender of the enclosing
|
||||
* authenticated Noise payload verified [voucheeFingerprint].
|
||||
*/
|
||||
data class VouchAttestation(
|
||||
val voucheeFingerprint: ByteArray,
|
||||
val voucheeSigningKey: ByteArray,
|
||||
val timestampMs: Long,
|
||||
val signature: ByteArray
|
||||
) {
|
||||
init {
|
||||
require(voucheeFingerprint.size == FINGERPRINT_SIZE)
|
||||
require(voucheeSigningKey.size == SIGNING_KEY_SIZE)
|
||||
require(signature.size == SIGNATURE_SIZE)
|
||||
}
|
||||
|
||||
fun signableBytes(): ByteArray = signableBytes(
|
||||
voucheeFingerprint,
|
||||
voucheeSigningKey,
|
||||
timestampMs
|
||||
)
|
||||
|
||||
fun isExpired(nowMs: Long = System.currentTimeMillis()): Boolean {
|
||||
val age = nowMs - timestampMs
|
||||
return age > MAX_AGE_MS || age < -MAX_CLOCK_SKEW_MS
|
||||
}
|
||||
|
||||
fun encode(): ByteArray {
|
||||
val output = ByteArrayOutputStream()
|
||||
output.writeTlv(TYPE_FINGERPRINT, voucheeFingerprint)
|
||||
output.writeTlv(TYPE_SIGNING_KEY, voucheeSigningKey)
|
||||
output.writeTlv(TYPE_TIMESTAMP, timestampBytes(timestampMs))
|
||||
output.writeTlv(TYPE_SIGNATURE, signature)
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is VouchAttestation &&
|
||||
voucheeFingerprint.contentEquals(other.voucheeFingerprint) &&
|
||||
voucheeSigningKey.contentEquals(other.voucheeSigningKey) &&
|
||||
timestampMs == other.timestampMs &&
|
||||
signature.contentEquals(other.signature)
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = voucheeFingerprint.contentHashCode()
|
||||
result = HASH_MULTIPLIER * result + voucheeSigningKey.contentHashCode()
|
||||
result = HASH_MULTIPLIER * result + timestampMs.hashCode()
|
||||
return HASH_MULTIPLIER * result + signature.contentHashCode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_BATCH_COUNT = 16
|
||||
const val FINGERPRINT_SIZE = 32
|
||||
const val SIGNING_KEY_SIZE = 32
|
||||
const val SIGNATURE_SIZE = 64
|
||||
private const val DAYS_VALID = 30L
|
||||
private const val HOURS_PER_DAY = 24L
|
||||
private const val MINUTES_PER_HOUR = 60L
|
||||
private const val SECONDS_PER_MINUTE = 60L
|
||||
private const val MILLIS_PER_SECOND = 1000L
|
||||
const val MAX_AGE_MS =
|
||||
DAYS_VALID * HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLIS_PER_SECOND
|
||||
const val MAX_CLOCK_SKEW_MS =
|
||||
MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLIS_PER_SECOND
|
||||
|
||||
private const val SIGNING_CONTEXT = "bitchat-vouch-v1"
|
||||
private const val TYPE_FINGERPRINT = 0x01
|
||||
private const val TYPE_SIGNING_KEY = 0x02
|
||||
private const val TYPE_TIMESTAMP = 0x03
|
||||
private const val TYPE_SIGNATURE = 0x04
|
||||
private const val TLV_HEADER_SIZE = 2
|
||||
private const val TLV_LENGTH_SIZE = 1
|
||||
private const val BATCH_COUNT_SIZE = 1
|
||||
private const val BATCH_ENTRY_LENGTH_SIZE = 2
|
||||
private const val BITS_PER_BYTE = 8
|
||||
private const val BYTE_MASK = 0xFF
|
||||
private const val UINT16_MAX = 0xFFFF
|
||||
private const val INITIAL_OFFSET = 0
|
||||
private const val INITIAL_TIMESTAMP = 0L
|
||||
private const val MINIMUM_TIMESTAMP_MS = 0L
|
||||
private const val INITIAL_ENTRY_COUNT = 0
|
||||
private const val HASH_MULTIPLIER = 31
|
||||
private const val TIMESTAMP_LENGTH = Long.SIZE_BYTES
|
||||
|
||||
fun build(
|
||||
voucheeFingerprint: ByteArray,
|
||||
voucheeSigningKey: ByteArray,
|
||||
timestampMs: Long = System.currentTimeMillis(),
|
||||
sign: (ByteArray) -> ByteArray?
|
||||
): VouchAttestation? {
|
||||
if (voucheeFingerprint.size != FINGERPRINT_SIZE ||
|
||||
voucheeSigningKey.size != SIGNING_KEY_SIZE
|
||||
) return null
|
||||
val signature = sign(signableBytes(voucheeFingerprint, voucheeSigningKey, timestampMs))
|
||||
?: return null
|
||||
if (signature.size != SIGNATURE_SIZE) return null
|
||||
return VouchAttestation(voucheeFingerprint, voucheeSigningKey, timestampMs, signature)
|
||||
}
|
||||
|
||||
fun signableBytes(
|
||||
voucheeFingerprint: ByteArray,
|
||||
voucheeSigningKey: ByteArray,
|
||||
timestampMs: Long
|
||||
): ByteArray = SIGNING_CONTEXT.toByteArray(Charsets.UTF_8) +
|
||||
voucheeFingerprint + voucheeSigningKey + timestampBytes(timestampMs)
|
||||
|
||||
fun decode(data: ByteArray): VouchAttestation? {
|
||||
var offset = INITIAL_OFFSET
|
||||
var fingerprint: ByteArray? = null
|
||||
var signingKey: ByteArray? = null
|
||||
var timestamp: Long? = null
|
||||
var signature: ByteArray? = null
|
||||
while (offset < data.size) {
|
||||
if (offset + TLV_HEADER_SIZE > data.size) return null
|
||||
val type = data[offset++].toInt() and BYTE_MASK
|
||||
val length = data[offset++].toInt() and BYTE_MASK
|
||||
if (offset + length > data.size) return null
|
||||
val value = data.copyOfRange(offset, offset + length)
|
||||
offset += length
|
||||
when (type) {
|
||||
TYPE_FINGERPRINT -> if (length == FINGERPRINT_SIZE) fingerprint = value else return null
|
||||
TYPE_SIGNING_KEY -> if (length == SIGNING_KEY_SIZE) signingKey = value else return null
|
||||
TYPE_TIMESTAMP -> if (length == TIMESTAMP_LENGTH) {
|
||||
val decodedTimestamp = value.fold(INITIAL_TIMESTAMP) { result, byte ->
|
||||
(result shl BITS_PER_BYTE) or (byte.toLong() and BYTE_MASK.toLong())
|
||||
}
|
||||
if (decodedTimestamp < MINIMUM_TIMESTAMP_MS) return null
|
||||
timestamp = decodedTimestamp
|
||||
} else return null
|
||||
TYPE_SIGNATURE -> if (length == SIGNATURE_SIZE) signature = value else return null
|
||||
}
|
||||
}
|
||||
return VouchAttestation(
|
||||
fingerprint ?: return null,
|
||||
signingKey ?: return null,
|
||||
timestamp ?: return null,
|
||||
signature ?: return null
|
||||
)
|
||||
}
|
||||
|
||||
fun encodeList(attestations: List<VouchAttestation>): ByteArray? {
|
||||
if (attestations.isEmpty() || attestations.size > MAX_BATCH_COUNT) return null
|
||||
val output = ByteArrayOutputStream()
|
||||
output.write(attestations.size)
|
||||
attestations.forEach { attestation ->
|
||||
val encoded = attestation.encode()
|
||||
if (encoded.size > UINT16_MAX) return null
|
||||
output.write(encoded.size ushr BITS_PER_BYTE)
|
||||
output.write(encoded.size and BYTE_MASK)
|
||||
output.write(encoded)
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
fun decodeList(data: ByteArray): List<VouchAttestation> {
|
||||
if (data.size <= BATCH_COUNT_SIZE) return emptyList()
|
||||
val limit = minOf(data[0].toInt() and BYTE_MASK, MAX_BATCH_COUNT)
|
||||
val decoded = mutableListOf<VouchAttestation>()
|
||||
var offset = BATCH_COUNT_SIZE
|
||||
var entriesRead = INITIAL_ENTRY_COUNT
|
||||
while (entriesRead < limit && offset < data.size) {
|
||||
if (offset + BATCH_ENTRY_LENGTH_SIZE > data.size) break
|
||||
val length = ((data[offset].toInt() and BYTE_MASK) shl BITS_PER_BYTE) or
|
||||
(data[offset + TLV_LENGTH_SIZE].toInt() and BYTE_MASK)
|
||||
offset += BATCH_ENTRY_LENGTH_SIZE
|
||||
if (offset + length > data.size) break
|
||||
decode(data.copyOfRange(offset, offset + length))?.let(decoded::add)
|
||||
offset += length
|
||||
entriesRead++
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private const val LAST_BYTE_INDEX_OFFSET = 1
|
||||
private const val TIMESTAMP_HIGH_BIT_OFFSET =
|
||||
(TIMESTAMP_LENGTH - LAST_BYTE_INDEX_OFFSET) * BITS_PER_BYTE
|
||||
|
||||
private fun timestampBytes(timestampMs: Long): ByteArray =
|
||||
ByteArray(TIMESTAMP_LENGTH) { index ->
|
||||
(timestampMs ushr (TIMESTAMP_HIGH_BIT_OFFSET - index * BITS_PER_BYTE)).toByte()
|
||||
}
|
||||
|
||||
private fun ByteArrayOutputStream.writeTlv(type: Int, value: ByteArray) {
|
||||
write(type)
|
||||
write(value.size)
|
||||
write(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
package com.bitchat.android.noise
|
||||
|
||||
import com.bitchat.android.noise.southernstorm.protocol.HandshakeState
|
||||
import com.bitchat.android.noise.southernstorm.protocol.Noise
|
||||
|
||||
/**
|
||||
* One-message Noise X helper used by iOS-compatible courier envelopes.
|
||||
*
|
||||
* Protocol: Noise_X_25519_ChaChaPoly_SHA256
|
||||
* Prologue: "bitchat-courier-v1"
|
||||
*/
|
||||
object CourierNoiseCrypto {
|
||||
private const val PROTOCOL_NAME = "Noise_X_25519_ChaChaPoly_SHA256"
|
||||
private val COURIER_PROLOGUE = "bitchat-courier-v1".toByteArray(Charsets.UTF_8)
|
||||
private val PREKEY_PROLOGUE_PREFIX = "bitchat-prekey-v1".toByteArray(Charsets.UTF_8)
|
||||
private const val X_OVERHEAD_BYTES = 32 + 48 + 16
|
||||
|
||||
data class Opened(val payload: ByteArray, val senderStaticKey: ByteArray)
|
||||
|
||||
fun seal(
|
||||
payload: ByteArray,
|
||||
senderStaticPrivateKey: ByteArray,
|
||||
recipientStaticPublicKey: ByteArray
|
||||
): ByteArray = sealWithPrologue(
|
||||
payload,
|
||||
senderStaticPrivateKey,
|
||||
recipientStaticPublicKey,
|
||||
COURIER_PROLOGUE
|
||||
)
|
||||
|
||||
fun sealToPrekey(
|
||||
payload: ByteArray,
|
||||
senderStaticPrivateKey: ByteArray,
|
||||
recipientPrekey: com.bitchat.android.model.PrekeyBundle.Prekey
|
||||
): ByteArray = sealWithPrologue(
|
||||
payload,
|
||||
senderStaticPrivateKey,
|
||||
recipientPrekey.publicKey,
|
||||
prekeyPrologue(recipientPrekey.id)
|
||||
)
|
||||
|
||||
private fun sealWithPrologue(
|
||||
payload: ByteArray,
|
||||
senderStaticPrivateKey: ByteArray,
|
||||
recipientStaticPublicKey: ByteArray,
|
||||
prologue: ByteArray
|
||||
): ByteArray {
|
||||
require(senderStaticPrivateKey.size == 32)
|
||||
require(recipientStaticPublicKey.size == 32)
|
||||
val handshake = HandshakeState(PROTOCOL_NAME, HandshakeState.INITIATOR)
|
||||
return try {
|
||||
handshake.setPrologue(prologue, 0, prologue.size)
|
||||
handshake.getLocalKeyPair().setPrivateKey(senderStaticPrivateKey, 0)
|
||||
handshake.getRemotePublicKey().setPublicKey(recipientStaticPublicKey, 0)
|
||||
handshake.start()
|
||||
val message = ByteArray(payload.size + X_OVERHEAD_BYTES)
|
||||
val length = handshake.writeMessage(message, 0, payload, 0, payload.size)
|
||||
message.copyOf(length)
|
||||
} finally {
|
||||
handshake.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
fun open(
|
||||
ciphertext: ByteArray,
|
||||
recipientStaticPrivateKey: ByteArray
|
||||
): Opened = openWithPrologue(ciphertext, recipientStaticPrivateKey, COURIER_PROLOGUE)
|
||||
|
||||
fun openWithPrekey(
|
||||
ciphertext: ByteArray,
|
||||
recipientPrekeyPrivateKey: ByteArray,
|
||||
prekeyId: Long
|
||||
): Opened = openWithPrologue(
|
||||
ciphertext,
|
||||
recipientPrekeyPrivateKey,
|
||||
prekeyPrologue(prekeyId)
|
||||
)
|
||||
|
||||
private fun openWithPrologue(
|
||||
ciphertext: ByteArray,
|
||||
recipientStaticPrivateKey: ByteArray,
|
||||
prologue: ByteArray
|
||||
): Opened {
|
||||
require(recipientStaticPrivateKey.size == 32)
|
||||
val handshake = HandshakeState(PROTOCOL_NAME, HandshakeState.RESPONDER)
|
||||
return try {
|
||||
handshake.setPrologue(prologue, 0, prologue.size)
|
||||
handshake.getLocalKeyPair().setPrivateKey(recipientStaticPrivateKey, 0)
|
||||
handshake.start()
|
||||
val payload = ByteArray(ciphertext.size)
|
||||
val length = handshake.readMessage(ciphertext, 0, ciphertext.size, payload, 0)
|
||||
val senderKey = ByteArray(handshake.getRemotePublicKey().publicKeyLength)
|
||||
handshake.getRemotePublicKey().getPublicKey(senderKey, 0)
|
||||
Opened(payload.copyOf(length), senderKey)
|
||||
} finally {
|
||||
handshake.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
/** Test/support helper that derives the X25519 public key used on the wire. */
|
||||
fun publicKey(privateKey: ByteArray): ByteArray {
|
||||
require(privateKey.size == 32)
|
||||
val key = Noise.createDH("25519")
|
||||
return try {
|
||||
key.setPrivateKey(privateKey, 0)
|
||||
ByteArray(key.publicKeyLength).also { key.getPublicKey(it, 0) }
|
||||
} finally {
|
||||
key.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
private fun prekeyPrologue(prekeyId: Long): ByteArray {
|
||||
require(prekeyId in 0..0xFFFF_FFFFL)
|
||||
return PREKEY_PROLOGUE_PREFIX + byteArrayOf(
|
||||
(prekeyId ushr 24).toByte(),
|
||||
(prekeyId ushr 16).toByte(),
|
||||
(prekeyId ushr 8).toByte(),
|
||||
prekeyId.toByte()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import android.util.Log
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.mesh.PeerFingerprintManager
|
||||
import com.bitchat.android.noise.southernstorm.protocol.Noise
|
||||
import com.bitchat.android.noise.southernstorm.protocol.HandshakeState
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@ -18,7 +19,10 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* - Channel encryption using password-derived keys
|
||||
* - Peer fingerprint mapping and identity persistence
|
||||
*/
|
||||
class NoiseEncryptionService(private val context: Context) {
|
||||
class NoiseEncryptionService(
|
||||
private val context: Context,
|
||||
private val identityStateManager: SecureIdentityStateManager = SecureIdentityStateManager(context)
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NoiseEncryptionService"
|
||||
@ -42,9 +46,6 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
// Channel encryption for password-protected channels
|
||||
private val channelEncryption = NoiseChannelEncryption()
|
||||
|
||||
// Identity management for peer ID rotation support
|
||||
private val identityStateManager: SecureIdentityStateManager
|
||||
|
||||
// Centralized fingerprint management - NO LOCAL STORAGE
|
||||
private val fingerprintManager = PeerFingerprintManager.getInstance()
|
||||
|
||||
@ -53,9 +54,6 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
var onHandshakeRequired: ((String) -> Unit)? = null // peerID needs handshake
|
||||
|
||||
init {
|
||||
// Initialize identity state manager for persistent storage
|
||||
identityStateManager = SecureIdentityStateManager(context)
|
||||
|
||||
// Load or create keys - temporary placeholders
|
||||
staticIdentityPrivateKey = ByteArray(32)
|
||||
staticIdentityPublicKey = ByteArray(32)
|
||||
@ -147,6 +145,40 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
return sessionManager.getRemoteStaticKey(peerID)
|
||||
}
|
||||
|
||||
fun sealCourierPayload(payload: ByteArray, recipientStaticKey: ByteArray): ByteArray {
|
||||
require(recipientStaticKey.size == 32 && recipientStaticKey.any { it != 0.toByte() })
|
||||
val state = HandshakeState("Noise_X_25519_ChaChaPoly_SHA256", HandshakeState.INITIATOR)
|
||||
return try {
|
||||
state.getLocalKeyPair().setPrivateKey(staticIdentityPrivateKey, 0)
|
||||
state.getRemotePublicKey().setPublicKey(recipientStaticKey, 0)
|
||||
val prologue = "bitchat-courier-v1".toByteArray(Charsets.UTF_8)
|
||||
state.setPrologue(prologue, 0, prologue.size)
|
||||
state.start()
|
||||
val output = ByteArray(payload.size + 128)
|
||||
output.copyOf(state.writeMessage(output, 0, payload, 0, payload.size))
|
||||
} finally {
|
||||
state.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
fun openCourierPayload(ciphertext: ByteArray): Pair<ByteArray, ByteArray> {
|
||||
require(ciphertext.size >= 96)
|
||||
val state = HandshakeState("Noise_X_25519_ChaChaPoly_SHA256", HandshakeState.RESPONDER)
|
||||
return try {
|
||||
state.getLocalKeyPair().setPrivateKey(staticIdentityPrivateKey, 0)
|
||||
val prologue = "bitchat-courier-v1".toByteArray(Charsets.UTF_8)
|
||||
state.setPrologue(prologue, 0, prologue.size)
|
||||
state.start()
|
||||
val payload = ByteArray(ciphertext.size)
|
||||
val length = state.readMessage(ciphertext, 0, ciphertext.size, payload, 0)
|
||||
val senderKey = ByteArray(32)
|
||||
state.getRemotePublicKey().getPublicKey(senderKey, 0)
|
||||
senderKey to payload.copyOf(length)
|
||||
} finally {
|
||||
state.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? =
|
||||
sessionManager.getAuthenticatedSession(peerID)
|
||||
|
||||
|
||||
@ -0,0 +1,253 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import com.bitchat.android.crypto.EncryptionService
|
||||
import com.bitchat.android.model.CourierEnvelope
|
||||
import java.util.Collections
|
||||
import java.util.LinkedHashMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Minimal relay surface used by the bridge courier.
|
||||
*
|
||||
* Keeping this adapter boundary small lets the kind-1401 contract be exercised with a
|
||||
* deterministic in-memory relay in unit tests, without opening a network connection.
|
||||
*/
|
||||
internal interface BridgeCourierRelay {
|
||||
fun subscribe(
|
||||
filter: NostrFilter,
|
||||
id: String,
|
||||
targetRelayUrls: List<String>,
|
||||
handler: (NostrEvent) -> Unit
|
||||
)
|
||||
|
||||
fun unsubscribe(id: String)
|
||||
|
||||
fun hasConnectedRelay(relayUrls: Collection<String>): Boolean
|
||||
|
||||
fun sendEvent(
|
||||
event: NostrEvent,
|
||||
relayUrls: List<String>,
|
||||
onAccepted: () -> Unit
|
||||
): Boolean
|
||||
}
|
||||
|
||||
internal interface BridgeCourierCipher {
|
||||
fun staticPublicKey(): ByteArray?
|
||||
|
||||
fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray
|
||||
|
||||
fun sealWithPrekey(payload: ByteArray, recipientNoiseKey: ByteArray): Pair<ByteArray, UInt?> =
|
||||
seal(payload, recipientNoiseKey) to null
|
||||
}
|
||||
|
||||
private class NostrBridgeCourierRelay(
|
||||
private val relayManager: NostrRelayManager
|
||||
) : BridgeCourierRelay {
|
||||
override fun subscribe(
|
||||
filter: NostrFilter,
|
||||
id: String,
|
||||
targetRelayUrls: List<String>,
|
||||
handler: (NostrEvent) -> Unit
|
||||
) {
|
||||
relayManager.subscribe(
|
||||
filter = filter,
|
||||
id = id,
|
||||
targetRelayUrls = targetRelayUrls,
|
||||
handler = handler
|
||||
)
|
||||
}
|
||||
|
||||
override fun unsubscribe(id: String) {
|
||||
relayManager.unsubscribe(id)
|
||||
}
|
||||
|
||||
override fun hasConnectedRelay(relayUrls: Collection<String>): Boolean =
|
||||
relayManager.hasConnectedRelay(relayUrls)
|
||||
|
||||
override fun sendEvent(
|
||||
event: NostrEvent,
|
||||
relayUrls: List<String>,
|
||||
onAccepted: () -> Unit
|
||||
): Boolean = relayManager.sendEvent(event, relayUrls, onAccepted = onAccepted,
|
||||
publicationAllowed = com.bitchat.android.services.bridge.MeshBridgeService.publicationPermit())
|
||||
}
|
||||
|
||||
private class EncryptionBridgeCourierCipher(
|
||||
private val encryptionService: EncryptionService,
|
||||
private val prekeys: com.bitchat.android.services.bridge.PrekeyManager
|
||||
) : BridgeCourierCipher {
|
||||
override fun staticPublicKey(): ByteArray? = encryptionService.getStaticPublicKey()
|
||||
|
||||
override fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray =
|
||||
encryptionService.sealCourierPayload(payload, recipientNoiseKey)
|
||||
|
||||
override fun sealWithPrekey(payload: ByteArray, recipientNoiseKey: ByteArray): Pair<ByteArray, UInt?> {
|
||||
val id = java.security.MessageDigest.getInstance("SHA-256").digest(payload)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
val sealed = prekeys.seal(payload, id, recipientNoiseKey, recipientAdvertisesPrekeys = true)
|
||||
return sealed.ciphertext to sealed.prekeyId?.toUInt()
|
||||
}
|
||||
}
|
||||
|
||||
/** Parks opaque courier envelopes on default Nostr relays using the iOS kind-1401 contract. */
|
||||
class BridgeCourierService internal constructor(
|
||||
private val cipher: BridgeCourierCipher,
|
||||
private val onEnvelope: (CourierEnvelope) -> Unit,
|
||||
private val relayManager: BridgeCourierRelay,
|
||||
private val relayUrls: List<String> = NostrRelayManager.defaultRelays(),
|
||||
private val clock: () -> Long = System::currentTimeMillis,
|
||||
private val identityFactory: () -> NostrIdentity = NostrIdentity::generate,
|
||||
private val enabled: () -> Boolean = { true }
|
||||
) {
|
||||
constructor(
|
||||
context: Context,
|
||||
encryptionService: EncryptionService,
|
||||
onEnvelope: (CourierEnvelope) -> Unit
|
||||
) : this(
|
||||
cipher = EncryptionBridgeCourierCipher(encryptionService, com.bitchat.android.services.bridge.PrekeyManager.getInstance(context)),
|
||||
onEnvelope = onEnvelope,
|
||||
relayManager = NostrBridgeCourierRelay(
|
||||
NostrRelayManager.getInstance(context.applicationContext)
|
||||
),
|
||||
enabled = { com.bitchat.android.services.bridge.MeshBridgeService.isEnabled.value }
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val KIND = 1401
|
||||
private const val MAX_ENCODED_BYTES = 20 * 1024
|
||||
private const val TAG_REFRESH_INTERVAL_MS = 60 * 60 * 1000L
|
||||
}
|
||||
|
||||
private val seenEvents = Collections.synchronizedMap(
|
||||
object : LinkedHashMap<String, Unit>(512, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Unit>?) = size > 512
|
||||
}
|
||||
)
|
||||
private val subscriptionID = "bridge-courier-drops-${System.identityHashCode(this)}"
|
||||
@Volatile private var subscribedDay: UInt? = null
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var refreshJob: Job? = null
|
||||
val isStarted: Boolean get() = subscribedDay != null
|
||||
|
||||
@Synchronized
|
||||
fun start() {
|
||||
if (!enabled()) return
|
||||
val localKey = cipher.staticPublicKey() ?: ByteArray(0)
|
||||
if (localKey.size == 32) {
|
||||
val now = clock()
|
||||
val day = CourierEnvelope.epochDay(now)
|
||||
if (subscribedDay == day) return
|
||||
if (subscribedDay != null) relayManager.unsubscribe(subscriptionID)
|
||||
val tags = listOf(day - 1u, day, day + 1u).map {
|
||||
CourierEnvelope.recipientTag(localKey, it).toHex()
|
||||
}
|
||||
val filter = NostrFilter.Builder()
|
||||
.kinds(KIND)
|
||||
.since(now - CourierEnvelope.MAX_LIFETIME_MS)
|
||||
.limit(100)
|
||||
.tag("x", *tags.toTypedArray())
|
||||
.build()
|
||||
relayManager.subscribe(
|
||||
filter = filter,
|
||||
id = subscriptionID,
|
||||
targetRelayUrls = relayUrls,
|
||||
handler = ::handleEvent
|
||||
)
|
||||
subscribedDay = day
|
||||
if (refreshJob?.isActive != true) {
|
||||
refreshJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(TAG_REFRESH_INTERVAL_MS)
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deposit(
|
||||
content: String,
|
||||
messageID: String,
|
||||
recipientNoiseKey: ByteArray,
|
||||
onAccepted: () -> Unit = {}
|
||||
): Boolean {
|
||||
val privateMessage = com.bitchat.android.model.PrivateMessagePacket(messageID, content).encode() ?: return false
|
||||
val typed = com.bitchat.android.model.NoisePayload(
|
||||
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE,
|
||||
privateMessage
|
||||
).encode()
|
||||
return depositPayload(typed, recipientNoiseKey, onAccepted)
|
||||
}
|
||||
|
||||
fun depositPayload(
|
||||
typedPayload: ByteArray,
|
||||
recipientNoiseKey: ByteArray,
|
||||
onAccepted: () -> Unit = {}
|
||||
): Boolean {
|
||||
if (!enabled()) return false
|
||||
start()
|
||||
if (!relayManager.hasConnectedRelay(relayUrls)) return false
|
||||
val sealed = try { cipher.sealWithPrekey(typedPayload, recipientNoiseKey) } catch (_: Exception) { return false }
|
||||
val now = clock()
|
||||
val envelope = CourierEnvelope(
|
||||
recipientTag = CourierEnvelope.recipientTag(recipientNoiseKey, CourierEnvelope.epochDay(now)),
|
||||
expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(),
|
||||
ciphertext = sealed.first,
|
||||
prekeyID = sealed.second,
|
||||
copies = 1u
|
||||
)
|
||||
val encoded = envelope.encode() ?: return false
|
||||
if (encoded.size > MAX_ENCODED_BYTES) return false
|
||||
val identity = try { identityFactory() } catch (_: Exception) { return false }
|
||||
val event = identity.signEvent(
|
||||
NostrEvent(
|
||||
pubkey = identity.publicKeyHex,
|
||||
createdAt = (now / 1000).toInt(),
|
||||
kind = KIND,
|
||||
tags = listOf(
|
||||
listOf("x", envelope.recipientTag.toHex()),
|
||||
listOf("expiration", (envelope.expiry / 1000u).toString())
|
||||
),
|
||||
content = Base64.encodeToString(encoded, Base64.NO_WRAP)
|
||||
)
|
||||
)
|
||||
if (!enabled()) return false
|
||||
return relayManager.sendEvent(event, relayUrls, onAccepted)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
relayManager.unsubscribe(subscriptionID)
|
||||
subscribedDay = null
|
||||
refreshJob?.cancel()
|
||||
refreshJob = null
|
||||
}
|
||||
|
||||
private fun handleEvent(event: NostrEvent) {
|
||||
if (!enabled()) return
|
||||
if (event.kind != KIND || !event.isValidSignature()) return
|
||||
synchronized(seenEvents) { if (seenEvents.put(event.id, Unit) != null) return }
|
||||
if (event.content.length > ((MAX_ENCODED_BYTES + 2) / 3) * 4) return
|
||||
val encoded = try { Base64.decode(event.content, Base64.DEFAULT) } catch (_: Exception) { return }
|
||||
if (encoded.size > MAX_ENCODED_BYTES) return
|
||||
val envelope = CourierEnvelope.decode(encoded) ?: return
|
||||
val now = clock()
|
||||
if (envelope.expiry.toLong() <= now || envelope.expiry.toLong() > now + CourierEnvelope.MAX_LIFETIME_MS + 60 * 60 * 1000L) return
|
||||
val eventTag = event.tags.firstOrNull { it.size > 1 && it[0] == "x" }?.get(1) ?: return
|
||||
val expiration = event.tags.firstOrNull { it.size > 1 && it[0] == "expiration" }?.get(1)?.toULongOrNull() ?: return
|
||||
if (eventTag != envelope.recipientTag.toHex() || expiration != envelope.expiry / 1000u) return
|
||||
val localKey = cipher.staticPublicKey() ?: return
|
||||
if (!envelope.matchesRecipient(localKey, now)) return
|
||||
onEnvelope(envelope)
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -38,7 +38,10 @@ class LocationNotesManager private constructor() {
|
||||
val pubkey: String,
|
||||
val content: String,
|
||||
val createdAt: Int,
|
||||
val nickname: String?
|
||||
val nickname: String?,
|
||||
val geohash: String,
|
||||
val expiresAt: Int? = null,
|
||||
val isUrgent: Boolean = false
|
||||
) {
|
||||
/**
|
||||
* Display name for the note - matches iOS exactly
|
||||
@ -97,6 +100,15 @@ class LocationNotesManager private constructor() {
|
||||
// Coroutine scope for background operations
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var liveLocationToken: Long? = null
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(60_000)
|
||||
pruneExpiredNotes()
|
||||
}
|
||||
}
|
||||
}
|
||||
private var subscribeRetryJob: Job? = null
|
||||
private var initialLoadJob: Job? = null
|
||||
|
||||
@ -139,7 +151,7 @@ class LocationNotesManager private constructor() {
|
||||
}
|
||||
|
||||
// Validate geohash (building-level precision: 8 chars) - matches iOS
|
||||
if (!isValidBuildingGeohash(normalized)) {
|
||||
if (normalized.length != 8 || !isValidGeohash(normalized)) {
|
||||
Log.w(TAG, "LocationNotesManager rejected an invalid building geohash")
|
||||
return
|
||||
}
|
||||
@ -172,8 +184,8 @@ class LocationNotesManager private constructor() {
|
||||
/**
|
||||
* Validate building-level geohash (precision 8) - matches iOS Geohash.isValidBuildingGeohash
|
||||
*/
|
||||
private fun isValidBuildingGeohash(geohash: String): Boolean {
|
||||
if (geohash.length != 8) return false
|
||||
private fun isValidGeohash(geohash: String): Boolean {
|
||||
if (geohash.length !in 1..12) return false
|
||||
val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz"
|
||||
return geohash.all { it in base32Chars }
|
||||
}
|
||||
@ -210,7 +222,7 @@ class LocationNotesManager private constructor() {
|
||||
/**
|
||||
* Send a new location note
|
||||
*/
|
||||
fun send(content: String, nickname: String?) {
|
||||
fun send(content: String, nickname: String?, expiresAt: Int? = null, urgent: Boolean = false) {
|
||||
val token = LiveLocationPrivacyGate.captureToken() ?: run {
|
||||
stop()
|
||||
return
|
||||
@ -275,7 +287,9 @@ class LocationNotesManager private constructor() {
|
||||
content = trimmed,
|
||||
geohash = currentGeohash,
|
||||
senderIdentity = preparedIdentity,
|
||||
nickname = nickname
|
||||
nickname = nickname,
|
||||
expiresAt = expiresAt,
|
||||
urgent = urgent
|
||||
)
|
||||
}
|
||||
if (!LiveLocationPrivacyGate.accepts(token)) return@launch
|
||||
@ -286,7 +300,10 @@ class LocationNotesManager private constructor() {
|
||||
pubkey = preparedEvent.pubkey,
|
||||
content = trimmed,
|
||||
createdAt = preparedEvent.createdAt,
|
||||
nickname = nickname
|
||||
nickname = nickname,
|
||||
geohash = currentGeohash,
|
||||
expiresAt = expiresAt,
|
||||
isUrgent = urgent
|
||||
)
|
||||
|
||||
if (!noteIDs.contains(preparedEvent.id)) {
|
||||
@ -301,7 +318,7 @@ class LocationNotesManager private constructor() {
|
||||
}
|
||||
|
||||
// CRITICAL FIX: Send to geo-specific relays (matching iOS pattern)
|
||||
// iOS: dependencies.sendEvent(event, relays)
|
||||
// iOS: dependencies.sendEvent(event, relays, token)
|
||||
val sent = withContext(Dispatchers.IO) {
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
sendEventFunc?.invoke(preparedEvent, relays, token)
|
||||
@ -443,6 +460,16 @@ class LocationNotesManager private constructor() {
|
||||
// Extract nickname from tags
|
||||
val nicknameTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "n" }
|
||||
val nickname = nicknameTag?.get(1)
|
||||
val expiresAt = event.tags
|
||||
.firstOrNull { it.size >= 2 && it[0].equals("expiration", ignoreCase = true) }
|
||||
?.get(1)
|
||||
?.toIntOrNull()
|
||||
if (expiresAt != null && expiresAt <= currentEpochSeconds()) return
|
||||
val urgent = event.tags.any {
|
||||
it.size >= 2 &&
|
||||
it[0].equals("t", ignoreCase = true) &&
|
||||
it[1].equals("urgent", ignoreCase = true)
|
||||
}
|
||||
|
||||
// Create note
|
||||
val note = Note(
|
||||
@ -450,7 +477,10 @@ class LocationNotesManager private constructor() {
|
||||
pubkey = event.pubkey,
|
||||
content = event.content,
|
||||
createdAt = event.createdAt,
|
||||
nickname = nickname
|
||||
nickname = nickname,
|
||||
geohash = eventGeohash.lowercase(),
|
||||
expiresAt = expiresAt,
|
||||
isUrgent = urgent
|
||||
)
|
||||
|
||||
// Add to collection
|
||||
@ -493,6 +523,89 @@ class LocationNotesManager private constructor() {
|
||||
fun clearError() {
|
||||
_errorMessage.value = null
|
||||
}
|
||||
|
||||
fun isOwnNote(note: Note): Boolean {
|
||||
val current = _geohash.value ?: return false
|
||||
val deriveIdentity = deriveIdentityFunc ?: return false
|
||||
return runCatching { deriveIdentity(current).publicKeyHex == note.pubkey }.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun delete(note: Note): Boolean {
|
||||
if (!isOwnNote(note)) return false
|
||||
return deleteEvent(note.id, note.geohash) {
|
||||
_notes.value = _notes.value.filterNot { it.id == note.id }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the relay copy of a geohash board post without changing the
|
||||
* active notes subscription. The callback lets BoardManager retract the
|
||||
* copy with NIP-09 while the app remains alive.
|
||||
*/
|
||||
fun publishBoardBridge(
|
||||
content: String,
|
||||
geohash: String,
|
||||
nickname: String,
|
||||
expiresAtSeconds: Int,
|
||||
urgent: Boolean,
|
||||
onPublished: (String) -> Unit
|
||||
) {
|
||||
val token = LiveLocationPrivacyGate.captureToken() ?: return
|
||||
val deriveIdentity = deriveIdentityFunc ?: return
|
||||
val sendEvent = sendEventFunc ?: return
|
||||
val relays = runCatching {
|
||||
RelayDirectory.closestRelaysForGeohash(geohash, 5)
|
||||
}.getOrDefault(emptyList())
|
||||
if (relays.isEmpty()) return
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val identity = withContext(Dispatchers.IO) { deriveIdentity(geohash) }
|
||||
val event = NostrProtocol.createGeohashTextNote(
|
||||
content = content,
|
||||
geohash = geohash,
|
||||
senderIdentity = identity,
|
||||
nickname = nickname,
|
||||
expiresAt = expiresAtSeconds,
|
||||
urgent = urgent
|
||||
)
|
||||
withContext(Dispatchers.IO) { sendEvent(event, relays, token) }
|
||||
onPublished(event.id)
|
||||
}.onFailure {
|
||||
Log.e(TAG, "Failed to bridge board post to Nostr: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteEvent(eventID: String, geohash: String, onDeleted: () -> Unit = {}): Boolean {
|
||||
val token = LiveLocationPrivacyGate.captureToken() ?: return false
|
||||
val deriveIdentity = deriveIdentityFunc ?: return false
|
||||
val sendEvent = sendEventFunc ?: return false
|
||||
val relays = runCatching {
|
||||
RelayDirectory.closestRelaysForGeohash(geohash, 5)
|
||||
}.getOrDefault(emptyList())
|
||||
if (relays.isEmpty()) return false
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val identity = withContext(Dispatchers.IO) { deriveIdentity(geohash) }
|
||||
val deletion = NostrProtocol.createDeleteEvent(eventID, identity)
|
||||
withContext(Dispatchers.IO) { sendEvent(deletion, relays, token) }
|
||||
onDeleted()
|
||||
}.onFailure {
|
||||
Log.e(TAG, "Failed to delete Nostr notice: ${it.message}")
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun pruneExpiredNotes() {
|
||||
val now = currentEpochSeconds()
|
||||
_notes.value = _notes.value.filter { note ->
|
||||
note.expiresAt?.let { it > now } ?: true
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentEpochSeconds(): Int =
|
||||
(System.currentTimeMillis() / 1_000L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
|
||||
|
||||
/**
|
||||
* Cancel subscription and clear state
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** Cross-platform stable identity for a public mesh radio/bridge copy. */
|
||||
object MeshMessageIdentity {
|
||||
fun stableId(senderIdHex: String, timestampMs: Long, content: String): String {
|
||||
val input = "${senderIdHex.lowercase()}|$timestampMs|${content.trim()}"
|
||||
return MessageDigest.getInstance("SHA-256")
|
||||
.digest(input.toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
.take(32)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -241,10 +241,8 @@ class NostrClient private constructor(private val context: Context) {
|
||||
giftWrap: NostrEvent,
|
||||
handler: (content: String, senderNpub: String, timestamp: Int) -> Unit
|
||||
) {
|
||||
// Age filtering (24h + 15min buffer for randomized timestamps)
|
||||
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
|
||||
if (messageAge > 173700) { // 48 hours + 15 minutes
|
||||
Log.v(TAG, "Ignoring old private message")
|
||||
if (!NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(giftWrap.createdAt)) {
|
||||
Log.v(TAG, "Ignoring private message with implausible gift-wrap created_at")
|
||||
return
|
||||
}
|
||||
|
||||
@ -254,6 +252,10 @@ class NostrClient private constructor(private val context: Context) {
|
||||
val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity)
|
||||
if (decryptResult != null) {
|
||||
val (content, senderPubkey, timestamp) = decryptResult
|
||||
if (!NostrTimestampPolicy.isPlausibleRumorTimestamp(timestamp)) {
|
||||
Log.w(TAG, "Dropping private message with implausible rumor timestamp")
|
||||
return
|
||||
}
|
||||
|
||||
// Convert sender pubkey to npub
|
||||
val senderNpub = try {
|
||||
|
||||
@ -60,8 +60,10 @@ class NostrDirectMessageHandler(
|
||||
try {
|
||||
if (dedupe(giftWrap.id)) return@launch
|
||||
|
||||
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
|
||||
if (messageAge > 173700) return@launch // 48 hours + 15 mins
|
||||
if (!NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(giftWrap.createdAt)) {
|
||||
Log.v(TAG, "Ignoring gift wrap with implausible created_at")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity)
|
||||
if (decryptResult == null) {
|
||||
@ -70,6 +72,10 @@ class NostrDirectMessageHandler(
|
||||
}
|
||||
|
||||
val (content, rawSenderPubkey, rumorTimestamp) = decryptResult
|
||||
if (!NostrTimestampPolicy.isPlausibleRumorTimestamp(rumorTimestamp)) {
|
||||
Log.w(TAG, "Dropping Nostr DM with implausible rumor timestamp")
|
||||
return@launch
|
||||
}
|
||||
val senderPubkey = rawSenderPubkey.lowercase()
|
||||
|
||||
// If sender is blocked for geohash contexts, drop any events from this pubkey
|
||||
@ -188,6 +194,7 @@ class NostrDirectMessageHandler(
|
||||
}
|
||||
NoisePayloadType.DELIVERED -> {
|
||||
val messageId = String(payload.data, Charsets.UTF_8)
|
||||
com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageId, conversationID)
|
||||
withContext(Dispatchers.Main) {
|
||||
updateDeliveryStatus(
|
||||
messageId,
|
||||
@ -197,6 +204,7 @@ class NostrDirectMessageHandler(
|
||||
}
|
||||
NoisePayloadType.READ_RECEIPT -> {
|
||||
val messageId = String(payload.data, Charsets.UTF_8)
|
||||
com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageId, conversationID)
|
||||
withContext(Dispatchers.Main) {
|
||||
updateDeliveryStatus(
|
||||
messageId,
|
||||
@ -243,6 +251,9 @@ class NostrDirectMessageHandler(
|
||||
NoisePayloadType.VERIFY_CHALLENGE,
|
||||
NoisePayloadType.VERIFY_RESPONSE,
|
||||
NoisePayloadType.VOICE_FRAME,
|
||||
NoisePayloadType.GROUP_INVITE,
|
||||
NoisePayloadType.GROUP_KEY_UPDATE,
|
||||
NoisePayloadType.VOUCH,
|
||||
NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation.
|
||||
}
|
||||
}
|
||||
|
||||
@ -210,10 +210,12 @@ data class NostrEvent(
|
||||
object NostrKind {
|
||||
const val METADATA = 0
|
||||
const val TEXT_NOTE = 1
|
||||
const val DELETION = 5
|
||||
const val DIRECT_MESSAGE = 14 // NIP-17 direct message (unsigned)
|
||||
const val FILE_MESSAGE = 15 // NIP-17 file message (unsigned)
|
||||
const val SEAL = 13 // NIP-17 sealed event
|
||||
const val GIFT_WRAP = 1059 // NIP-17 gift wrap
|
||||
const val COURIER_DROP = 1401 // Opaque bridge courier envelope
|
||||
const val EPHEMERAL_EVENT = 20000 // For geohash channels
|
||||
const val GEOHASH_PRESENCE = 20001 // For geohash presence heartbeat
|
||||
}
|
||||
|
||||
@ -69,6 +69,28 @@ data class NostrFilter(
|
||||
limit = limit
|
||||
)
|
||||
}
|
||||
|
||||
fun bridgeRendezvous(
|
||||
cells: Collection<String>,
|
||||
since: Long? = null,
|
||||
limit: Int = 200
|
||||
): NostrFilter = NostrFilter(
|
||||
kinds = listOf(NostrKind.EPHEMERAL_EVENT, NostrKind.GEOHASH_PRESENCE),
|
||||
since = since?.let { (it / 1000).toInt() },
|
||||
tagFilters = mapOf("r" to cells.toList()),
|
||||
limit = limit
|
||||
)
|
||||
|
||||
fun courierDrops(
|
||||
recipientTagsHex: Collection<String>,
|
||||
since: Long? = null,
|
||||
limit: Int = 100
|
||||
): NostrFilter = NostrFilter(
|
||||
kinds = listOf(NostrKind.COURIER_DROP),
|
||||
since = since?.let { (it / 1000).toInt() },
|
||||
tagFilters = mapOf("x" to recipientTagsHex.toList()),
|
||||
limit = limit
|
||||
)
|
||||
|
||||
/**
|
||||
* Create filter for text notes from specific authors
|
||||
|
||||
@ -176,6 +176,39 @@ object NostrIdentityBridge {
|
||||
Log.d(TAG, "Used fallback geohash identity derivation")
|
||||
return fallbackIdentity
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the iOS-compatible bridge rendezvous identity. The domain label
|
||||
* prevents linking it to geohash-chat identity and the iteration is
|
||||
* encoded big-endian, matching CryptoKit's UInt32.bigEndian bytes.
|
||||
*/
|
||||
fun deriveBridgeIdentity(cell: String, context: Context): NostrIdentity {
|
||||
val label = "bridge|$cell"
|
||||
geohashIdentityCache[label]?.let { return it }
|
||||
val stateManager = SecureIdentityStateManager(context)
|
||||
val seed = getOrCreateDeviceSeed(stateManager)
|
||||
val message = label.toByteArray(Charsets.UTF_8)
|
||||
|
||||
for (iteration in 0 until 10) {
|
||||
val input = message + byteArrayOf(
|
||||
(iteration ushr 24).toByte(),
|
||||
(iteration ushr 16).toByte(),
|
||||
(iteration ushr 8).toByte(),
|
||||
iteration.toByte()
|
||||
)
|
||||
val candidate = hmacSha256(seed, input).toHexStringLocal()
|
||||
if (NostrCrypto.isValidPrivateKey(candidate)) {
|
||||
return NostrIdentity.fromPrivateKey(candidate).also {
|
||||
geohashIdentityCache[label] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fallback = MessageDigest.getInstance("SHA-256").digest(seed + message)
|
||||
return NostrIdentity.fromPrivateKey(fallback.toHexStringLocal()).also {
|
||||
geohashIdentityCache[label] = it
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate candidate key for a specific iteration (matches iOS implementation)
|
||||
|
||||
@ -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<String>,
|
||||
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<String>,
|
||||
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<Delivery> = 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()
|
||||
}
|
||||
|
||||
@ -74,6 +79,11 @@ internal class NostrPendingEventQueue(
|
||||
}
|
||||
}
|
||||
|
||||
fun removeRelay(relayUrl: String) = synchronized(lock) {
|
||||
entries.forEach { it.pendingRelayUrls.remove(relayUrl) }
|
||||
entries.removeAll { it.pendingRelayUrls.isEmpty() }
|
||||
}
|
||||
|
||||
fun removeLiveLocationEvents() {
|
||||
synchronized(lock) {
|
||||
entries.removeAll { it.liveLocationToken != null }
|
||||
|
||||
@ -65,6 +65,12 @@ object NostrProtocol {
|
||||
Log.v(TAG, "Starting decryption of gift wrap: ${giftWrap.id.take(16)}...")
|
||||
|
||||
return try {
|
||||
val recipientTags = listOf(listOf("p", recipientIdentity.publicKeyHex))
|
||||
if (giftWrap.content.toByteArray(Charsets.UTF_8).size > 64 * 1024 ||
|
||||
giftWrap.kind != NostrKind.GIFT_WRAP || giftWrap.tags != recipientTags ||
|
||||
!NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(giftWrap.createdAt) ||
|
||||
!giftWrap.isValidSignature()
|
||||
) return null
|
||||
// 1. Unwrap the gift wrap
|
||||
val seal = unwrapGiftWrap(giftWrap, recipientIdentity.privateKeyHex)
|
||||
?: run {
|
||||
@ -74,7 +80,7 @@ object NostrProtocol {
|
||||
|
||||
Log.v(TAG, "Successfully unwrapped gift wrap from: ${seal.pubkey.take(16)}...")
|
||||
|
||||
if (seal.kind != NostrKind.SEAL || !seal.isValidSignature()) {
|
||||
if (seal.kind != NostrKind.SEAL || seal.tags.isNotEmpty() || !seal.isValidSignature()) {
|
||||
Log.w(TAG, "❌ Invalid NIP-17 seal signature")
|
||||
return null
|
||||
}
|
||||
@ -86,7 +92,10 @@ object NostrProtocol {
|
||||
return null
|
||||
}
|
||||
|
||||
if (seal.pubkey != rumor.pubkey) {
|
||||
if (seal.pubkey != rumor.pubkey || rumor.kind != NostrKind.DIRECT_MESSAGE ||
|
||||
(rumor.tags.isNotEmpty() && rumor.tags != recipientTags) || rumor.sig != null ||
|
||||
!NostrTimestampPolicy.isPlausibleRumorTimestamp(rumor.createdAt)
|
||||
) {
|
||||
Log.w(TAG, "❌ NIP-17 seal pubkey does not match rumor pubkey")
|
||||
return null
|
||||
}
|
||||
@ -108,7 +117,9 @@ object NostrProtocol {
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = null
|
||||
nickname: String? = null,
|
||||
expiresAt: Int? = null,
|
||||
urgent: Boolean = false
|
||||
): NostrEvent = withContext(Dispatchers.Default) {
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("g", geohash))
|
||||
@ -116,6 +127,12 @@ object NostrProtocol {
|
||||
if (!nickname.isNullOrEmpty()) {
|
||||
tags.add(listOf("n", nickname))
|
||||
}
|
||||
expiresAt?.let {
|
||||
tags.add(listOf("expiration", it.toString()))
|
||||
}
|
||||
if (urgent) {
|
||||
tags.add(listOf("t", "urgent"))
|
||||
}
|
||||
|
||||
val event = NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
@ -128,6 +145,21 @@ object NostrProtocol {
|
||||
return@withContext senderIdentity.signEvent(event)
|
||||
}
|
||||
|
||||
/** Create a NIP-09 deletion request signed by the original event identity. */
|
||||
suspend fun createDeleteEvent(
|
||||
eventID: String,
|
||||
senderIdentity: NostrIdentity
|
||||
): NostrEvent = withContext(Dispatchers.Default) {
|
||||
val event = NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.DELETION,
|
||||
tags = listOf(listOf("e", eventID)),
|
||||
content = ""
|
||||
)
|
||||
senderIdentity.signEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a geohash-scoped presence event (kind 20001)
|
||||
* Has no content and no nickname, used for participant counting
|
||||
@ -212,6 +244,73 @@ object NostrProtocol {
|
||||
|
||||
return@withContext senderIdentity.signEvent(event)
|
||||
}
|
||||
|
||||
/** iOS-compatible public mesh event on a bridge rendezvous cell. */
|
||||
fun createBridgeMeshEvent(
|
||||
content: String,
|
||||
cell: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = null,
|
||||
meshSenderId: String? = null,
|
||||
meshTimestampMs: Long? = null
|
||||
): NostrEvent {
|
||||
val tags = mutableListOf<List<String>>(listOf("r", cell))
|
||||
nickname?.trim()?.takeIf { it.isNotEmpty() }?.let { tags += listOf("n", it) }
|
||||
val sender = meshSenderId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (sender != null && meshTimestampMs != null) {
|
||||
tags += listOf(
|
||||
"m",
|
||||
MeshMessageIdentity.stableId(sender, meshTimestampMs, content),
|
||||
sender,
|
||||
meshTimestampMs.toString()
|
||||
)
|
||||
}
|
||||
return senderIdentity.signEvent(
|
||||
NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.EPHEMERAL_EVENT,
|
||||
tags = tags,
|
||||
content = content
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Empty bridge-presence heartbeat, deliberately separate from `#g` chat. */
|
||||
fun createBridgePresenceEvent(
|
||||
cell: String,
|
||||
senderIdentity: NostrIdentity
|
||||
): NostrEvent = senderIdentity.signEvent(
|
||||
NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.GEOHASH_PRESENCE,
|
||||
tags = listOf(listOf("r", cell)),
|
||||
content = ""
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Opaque relay drop. Callers use a throwaway identity so deposits cannot
|
||||
* be linked by their Nostr publisher key.
|
||||
*/
|
||||
fun createCourierDropEvent(
|
||||
envelope: ByteArray,
|
||||
recipientTagHex: String,
|
||||
expiresAtMs: Long,
|
||||
senderIdentity: NostrIdentity
|
||||
): NostrEvent = senderIdentity.signEvent(
|
||||
NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.COURIER_DROP,
|
||||
tags = listOf(
|
||||
listOf("x", recipientTagHex),
|
||||
listOf("expiration", (expiresAtMs / 1000).toString())
|
||||
),
|
||||
content = android.util.Base64.encodeToString(envelope, android.util.Base64.NO_WRAP)
|
||||
)
|
||||
)
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
|
||||
@ -35,6 +35,7 @@ class NostrRelayManager private constructor() {
|
||||
*/
|
||||
fun getInstance(context: android.content.Context): NostrRelayManager {
|
||||
shared.appContext = context.applicationContext
|
||||
shared.loadCustomRelays(context.applicationContext)
|
||||
return shared
|
||||
}
|
||||
|
||||
@ -55,7 +56,7 @@ class NostrRelayManager private constructor() {
|
||||
pendingGiftWrapIDs.add(id)
|
||||
}
|
||||
|
||||
fun defaultRelays(): List<String> = DEFAULT_RELAYS
|
||||
fun defaultRelays(): List<String> = (DEFAULT_RELAYS + shared.customRelays.value).distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -73,12 +74,82 @@ class NostrRelayManager private constructor() {
|
||||
var nextReconnectTime: Long? = null
|
||||
)
|
||||
|
||||
private val _customRelays = MutableStateFlow<List<String>>(emptyList())
|
||||
val customRelays: StateFlow<List<String>> = _customRelays.asStateFlow()
|
||||
private var customRelaysLoaded = false
|
||||
private val removedCustomRelayUrls = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
@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()
|
||||
removedCustomRelayUrls.remove(url)
|
||||
_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
|
||||
removedCustomRelayUrls.add(url)
|
||||
messageQueue.removeRelay(url)
|
||||
_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<List<Relay>>(emptyList())
|
||||
val relays: StateFlow<List<Relay>> = _relays.asStateFlow()
|
||||
|
||||
private val _isConnected = MutableStateFlow<Boolean>(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
private data class PendingAcceptance(
|
||||
val callback: () -> Unit,
|
||||
val pendingRelays: MutableSet<String>
|
||||
)
|
||||
private val eventAcceptanceHandlers = java.util.concurrent.ConcurrentHashMap<String, PendingAcceptance>()
|
||||
|
||||
// Internal state
|
||||
private val relaysList = mutableListOf<Relay>()
|
||||
@ -226,23 +297,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 ---
|
||||
@ -446,32 +518,49 @@ class NostrRelayManager private constructor() {
|
||||
fun sendEvent(
|
||||
event: NostrEvent,
|
||||
relayUrls: List<String>? = null,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
liveLocationToken: Long? = null,
|
||||
onAccepted: (() -> Unit)? = null,
|
||||
publicationAllowed: () -> Boolean = { true }
|
||||
): Boolean {
|
||||
if (!publicationAllowed()) return false
|
||||
val targetRelays = (relayUrls ?: relaysList.map { it.url })
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
if (targetRelays.isEmpty()) return
|
||||
if (targetRelays.isEmpty()) return false
|
||||
var enqueued = false
|
||||
|
||||
val queued = runNetworkAction(liveLocationToken) {
|
||||
val queueId = messageQueue.enqueue(
|
||||
event = event,
|
||||
relayUrls = targetRelays,
|
||||
liveLocationToken = liveLocationToken
|
||||
liveLocationToken = liveLocationToken,
|
||||
publicationAllowed = publicationAllowed
|
||||
) ?: return@runNetworkAction
|
||||
enqueued = true
|
||||
if (onAccepted != null) {
|
||||
eventAcceptanceHandlers[event.id] = PendingAcceptance(
|
||||
onAccepted,
|
||||
targetRelays.map { it.trim().trimEnd('/') }.toMutableSet()
|
||||
)
|
||||
}
|
||||
scope.launch {
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) return@launch
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!queued) return
|
||||
return queued && enqueued
|
||||
}
|
||||
|
||||
fun hasConnectedRelay(relayUrls: Collection<String>): Boolean {
|
||||
val targets = relayUrls.map { it.trim().trimEnd('/') }.toSet()
|
||||
return relaysList.any { it.isConnected && it.url.trim().trimEnd('/') in targets }
|
||||
}
|
||||
|
||||
/**
|
||||
@ -667,6 +756,7 @@ class NostrRelayManager private constructor() {
|
||||
|
||||
// Clear any queued messages waiting to be sent
|
||||
messageQueue.clear()
|
||||
eventAcceptanceHandlers.clear()
|
||||
|
||||
Log.i(TAG, "Cleared all Nostr subscriptions and routing caches")
|
||||
} catch (e: Exception) {
|
||||
@ -838,7 +928,7 @@ class NostrRelayManager private constructor() {
|
||||
urlString: String,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
if (!desiredConnected.get()) return
|
||||
if (!desiredConnected.get() || urlString in removedCustomRelayUrls) return
|
||||
val connectionToken = liveLocationToken
|
||||
?.takeIf { urlString !in nonLiveRelayUrls }
|
||||
if (!isNetworkActionAllowed(connectionToken)) return
|
||||
@ -853,6 +943,7 @@ class NostrRelayManager private constructor() {
|
||||
.build()
|
||||
|
||||
val started = runNetworkAction(connectionToken) {
|
||||
if (urlString in removedCustomRelayUrls) return@runNetworkAction
|
||||
val webSocket = httpClient.newWebSocket(
|
||||
request,
|
||||
RelayWebSocketListener(urlString, connectionToken)
|
||||
@ -860,7 +951,7 @@ class NostrRelayManager private constructor() {
|
||||
val existing = connections.putIfAbsent(urlString, webSocket)
|
||||
when {
|
||||
existing != null -> webSocket.close(1000, "Duplicate connection")
|
||||
!desiredConnected.get() -> {
|
||||
!desiredConnected.get() || urlString in removedCustomRelayUrls -> {
|
||||
connections.remove(urlString, webSocket)
|
||||
webSocket.close(1000, "Connection no longer desired")
|
||||
}
|
||||
@ -878,16 +969,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() || relayUrl in removedCustomRelayUrls || !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() && relayUrl !in removedCustomRelayUrls) success = webSocket.send(message)
|
||||
}
|
||||
if (success) {
|
||||
// Update relay stats
|
||||
@ -960,8 +1052,14 @@ class NostrRelayManager private constructor() {
|
||||
is NostrResponse.Ok -> {
|
||||
val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId)
|
||||
if (!response.accepted) {
|
||||
eventAcceptanceHandlers[response.eventId]?.let { pending ->
|
||||
pending.pendingRelays.remove(relayUrl.trim().trimEnd('/'))
|
||||
if (pending.pendingRelays.isEmpty()) eventAcceptanceHandlers.remove(response.eventId, pending)
|
||||
}
|
||||
val level = if (wasGiftWrap) Log.WARN else Log.ERROR
|
||||
Log.println(level, TAG, "Event rejected by relay: ${response.message ?: "no reason"}")
|
||||
} else {
|
||||
eventAcceptanceHandlers.remove(response.eventId)?.callback?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1118,7 +1216,7 @@ class NostrRelayManager private constructor() {
|
||||
) : WebSocketListener() {
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
if (!desiredConnected.get() ||
|
||||
if (!desiredConnected.get() || relayUrl in removedCustomRelayUrls ||
|
||||
connections[relayUrl] !== webSocket ||
|
||||
!isNetworkActionAllowed(liveLocationToken)
|
||||
) {
|
||||
@ -1140,7 +1238,8 @@ class NostrRelayManager private constructor() {
|
||||
delivery.event,
|
||||
webSocket,
|
||||
relayUrl,
|
||||
delivery.liveLocationToken
|
||||
delivery.liveLocationToken,
|
||||
delivery.publicationAllowed
|
||||
)
|
||||
) {
|
||||
messageQueue.markDelivered(delivery.queueId, relayUrl)
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import com.bitchat.android.util.AppConstants
|
||||
|
||||
/**
|
||||
* Client-side timestamp windows for inbound Nostr DMs.
|
||||
*
|
||||
* Mirrors iOS `NostrInboundPipeline.isPlausibleRumorTimestamp`: a relay that
|
||||
* ignores the subscription `since` filter — or replays archived events — must
|
||||
* not inject stale or future-dated DMs. The inner rumor timestamp is the
|
||||
* sender's true send time; only the outer gift wrap is NIP-17-randomized.
|
||||
*/
|
||||
object NostrTimestampPolicy {
|
||||
|
||||
/**
|
||||
* Accept an inner rumor `created_at` inside
|
||||
* `[now − lookback − skew, now + skew]`.
|
||||
*/
|
||||
fun isPlausibleRumorTimestamp(
|
||||
tsSeconds: Int,
|
||||
nowSeconds: Long = System.currentTimeMillis() / 1000L
|
||||
): Boolean {
|
||||
val age = nowSeconds - tsSeconds.toLong()
|
||||
val skew = AppConstants.Nostr.DM_MAX_CLOCK_SKEW_SECONDS
|
||||
val lookback = AppConstants.Nostr.DM_SUBSCRIBE_LOOKBACK_SECONDS
|
||||
return age >= -skew && age <= lookback + skew
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an outer gift-wrap `created_at` that is not in the far future and
|
||||
* not older than the NIP-17 randomization ceiling plus skew.
|
||||
*/
|
||||
fun isAcceptableGiftWrapTimestamp(
|
||||
createdAtSeconds: Int,
|
||||
nowSeconds: Long = System.currentTimeMillis() / 1000L
|
||||
): Boolean {
|
||||
val age = nowSeconds - createdAtSeconds.toLong()
|
||||
val skew = AppConstants.Nostr.DM_MAX_CLOCK_SKEW_SECONDS
|
||||
val maxAge = AppConstants.Nostr.DM_GIFT_WRAP_MAX_AGE_SECONDS
|
||||
return age >= -skew && age <= maxAge
|
||||
}
|
||||
}
|
||||
@ -46,6 +46,11 @@ class NostrTransport(
|
||||
// MARK: - Transport Interface Methods
|
||||
|
||||
val myPeerID: String get() = senderPeerID
|
||||
|
||||
fun canDeliverPromptly(): Boolean = try {
|
||||
NostrRelayManager.getInstance(context)
|
||||
.hasConnectedRelay(NostrRelayManager.defaultRelays())
|
||||
} catch (_: Exception) { false }
|
||||
|
||||
fun sendPrivateMessage(
|
||||
content: String,
|
||||
|
||||
@ -14,12 +14,19 @@ enum class MessageType(val value: UByte) {
|
||||
ANNOUNCE(0x01u),
|
||||
MESSAGE(0x02u), // All user messages (private and broadcast)
|
||||
LEAVE(0x03u),
|
||||
COURIER_ENVELOPE(0x04u), // Opaque Noise X store-and-forward envelope
|
||||
NOISE_HANDSHAKE(0x10u), // Noise handshake
|
||||
NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message
|
||||
FRAGMENT(0x20u), // Fragmentation for large packets
|
||||
REQUEST_SYNC(0x21u), // GCS-based sync request
|
||||
FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.)
|
||||
VOICE_FRAME(0x29u); // Ephemeral live push-to-talk frame; never added to gossip sync
|
||||
VOICE_FRAME(0x29u), // Ephemeral live push-to-talk frame; never added to gossip sync
|
||||
BOARD_POST(0x23u),
|
||||
GROUP_MESSAGE(0x25u), // Opaque private-group ciphertext broadcast
|
||||
PREKEY_BUNDLE(0x24u), // Signed batch of one-time courier prekeys
|
||||
NOSTR_CARRIER(0x28u), // Signed bridge/gateway event carrier
|
||||
PING(MeshDiagnosticsConstants.PING_TYPE),
|
||||
PONG(MeshDiagnosticsConstants.PONG_TYPE);
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: UByte): MessageType? {
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
package com.bitchat.android.protocol
|
||||
|
||||
object MeshDiagnosticsConstants {
|
||||
val PING_TYPE: UByte = 0x26u
|
||||
val PONG_TYPE: UByte = 0x27u
|
||||
val TTL: UByte = 7u
|
||||
const val NONCE_SIZE = 8
|
||||
const val PAYLOAD_SIZE = NONCE_SIZE + 1
|
||||
const val TIMEOUT_MILLIS = 10_000L
|
||||
const val INBOUND_RATE_LIMIT = 5
|
||||
const val INBOUND_RATE_WINDOW_MILLIS = 10_000L
|
||||
const val RELAY_JITTER_MIN_MILLIS = 20L
|
||||
const val RELAY_JITTER_MAX_MILLIS = 60L
|
||||
const val CAPABILITY_BIT = 6
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package com.bitchat.android.protocol
|
||||
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* iOS-compatible mesh diagnostics payload.
|
||||
*
|
||||
* Wire format: 8-byte nonce followed by the TTL used to launch the ping.
|
||||
* Decoding deliberately accepts trailing bytes for forward compatibility.
|
||||
*/
|
||||
data class MeshPingPayload(
|
||||
val nonce: ByteArray,
|
||||
val originTtl: UByte,
|
||||
) {
|
||||
init {
|
||||
require(nonce.size == MeshDiagnosticsConstants.NONCE_SIZE) {
|
||||
"Mesh ping nonce must be ${MeshDiagnosticsConstants.NONCE_SIZE} bytes"
|
||||
}
|
||||
}
|
||||
|
||||
fun encode(): ByteArray = nonce + byteArrayOf(originTtl.toByte())
|
||||
|
||||
fun hopCount(receivedTtl: UByte): Int =
|
||||
(originTtl.toInt() - receivedTtl.toInt() + 1).coerceAtLeast(1)
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is MeshPingPayload && nonce.contentEquals(other.nonce) && originTtl == other.originTtl
|
||||
|
||||
override fun hashCode(): Int = 31 * nonce.contentHashCode() + originTtl.hashCode()
|
||||
|
||||
companion object {
|
||||
fun create(originTtl: UByte): MeshPingPayload =
|
||||
MeshPingPayload(
|
||||
ByteArray(MeshDiagnosticsConstants.NONCE_SIZE).also(SecureRandom()::nextBytes),
|
||||
originTtl,
|
||||
)
|
||||
|
||||
fun decode(data: ByteArray): MeshPingPayload? {
|
||||
if (data.size < MeshDiagnosticsConstants.PAYLOAD_SIZE) return null
|
||||
return MeshPingPayload(
|
||||
data.copyOfRange(0, MeshDiagnosticsConstants.NONCE_SIZE),
|
||||
data[MeshDiagnosticsConstants.NONCE_SIZE].toUByte(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -44,6 +44,9 @@ object TransportBridgeService {
|
||||
* Send a packet to a specific peer via this transport (optional).
|
||||
*/
|
||||
fun sendToPeer(peerID: String, packet: BitchatPacket) { }
|
||||
|
||||
/** Send directly and report whether the transport accepted the write. */
|
||||
fun sendToPeerAndReport(peerID: String, packet: BitchatPacket): Boolean = false
|
||||
}
|
||||
|
||||
private val transports = ConcurrentHashMap<String, TransportLayer>()
|
||||
@ -200,6 +203,19 @@ object TransportBridgeService {
|
||||
}
|
||||
}
|
||||
|
||||
fun sendToPeerFromLocalAndReport(peerID: String, packet: BitchatPacket): Boolean {
|
||||
val targets = transports.toMap()
|
||||
if (targets.isEmpty()) return false
|
||||
return targets.values.fold(false) { accepted, layer ->
|
||||
try {
|
||||
layer.sendToPeerAndReport(peerID, packet) || accepted
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send local peer packet: ${e.message}")
|
||||
accepted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): PreparedForward? {
|
||||
if (packet.ttl == 0u.toUByte()) {
|
||||
Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired")
|
||||
|
||||
@ -188,6 +188,11 @@ object AppStateStore {
|
||||
|
||||
fun addPublicMessage(msg: BitchatMessage) {
|
||||
synchronized(this) {
|
||||
if (!msg.isBridged) {
|
||||
_publicMessages.value = _publicMessages.value.filterNot {
|
||||
it.isBridged && it.bridgeRadioMessageIdHint == msg.id
|
||||
}
|
||||
}
|
||||
val publicKey = publicMessageKey(msg)
|
||||
if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return
|
||||
seenMessageIds.add(msg.id)
|
||||
@ -196,6 +201,10 @@ object AppStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
fun hasRadioPublicMessage(messageId: String): Boolean = synchronized(this) {
|
||||
_publicMessages.value.any { !it.isBridged && it.id == messageId }
|
||||
}
|
||||
|
||||
/** Replace a live media row by ID, or append it if the row was not admitted yet. */
|
||||
fun upsertPublicMessage(msg: BitchatMessage) {
|
||||
synchronized(this) {
|
||||
@ -263,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,
|
||||
@ -408,13 +433,27 @@ object AppStateStore {
|
||||
messageID in seenMessageIds
|
||||
}
|
||||
|
||||
fun removePrivateConversation(peerID: String) {
|
||||
synchronized(this) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val map = _privateMessages.value.toMutableMap()
|
||||
val removedPeer = map.remove(peerID) != null
|
||||
val removedConversation = map.remove(conversationID) != null
|
||||
val changed = removedPeer || removedConversation
|
||||
if (changed) {
|
||||
_privateMessages.value = map
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
|
||||
null -> 0
|
||||
is DeliveryStatus.Sending -> 1
|
||||
is DeliveryStatus.Sent -> 2
|
||||
is DeliveryStatus.PartiallyDelivered -> 3
|
||||
is DeliveryStatus.Delivered -> 4
|
||||
is DeliveryStatus.Read -> 5
|
||||
is DeliveryStatus.Queued -> 2
|
||||
is DeliveryStatus.Sent -> 3
|
||||
is DeliveryStatus.PartiallyDelivered -> 4
|
||||
is DeliveryStatus.Delivered -> 5
|
||||
is DeliveryStatus.Read -> 6
|
||||
is DeliveryStatus.Failed -> 0
|
||||
}
|
||||
|
||||
|
||||
@ -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" }
|
||||
}
|
||||
@ -72,6 +72,9 @@ object ContactDirectory {
|
||||
favorite != null -> favorite.peerNoisePublicKey
|
||||
ContactIdentityResolver.isNoiseKeyHex(peerOrConversationID) ->
|
||||
ContactIdentityResolver.bytesFromHex(peerOrConversationID)
|
||||
contactFingerprint != null -> cachedNoiseKeyForFingerprint(contactFingerprint)
|
||||
ContactIdentityResolver.isMeshPeerId(peerOrConversationID) ->
|
||||
noiseKeyForAlias(peerOrConversationID)
|
||||
else -> null
|
||||
}
|
||||
val liveMeshPeerID = contactFingerprint?.let { findLiveMeshPeerForFingerprint(it) }
|
||||
@ -156,6 +159,18 @@ object ContactDirectory {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A contact conversation ID contains the SHA-256 fingerprint of the Noise key, whose
|
||||
* first 16 hex characters are the stable mesh peer ID. Recovering that cached key keeps
|
||||
* offline routing viable after the outbox has canonicalized a peer ID to `contact_…`.
|
||||
* Recompute the fingerprint before returning it so stale or mismatched cache entries cannot
|
||||
* redirect a private message.
|
||||
*/
|
||||
private fun cachedNoiseKeyForFingerprint(fingerprint: String): ByteArray? =
|
||||
cachedNoiseKey(fingerprint.take(16))?.takeIf {
|
||||
ContactIdentityResolver.fingerprintHex(it).equals(fingerprint, ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun cachedFingerprintNickname(fingerprint: String): String? {
|
||||
val context = appContext ?: return null
|
||||
return try {
|
||||
|
||||
@ -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<String>,
|
||||
@ -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<String> {
|
||||
readableDatabase.query(
|
||||
"deleted_private_messages",
|
||||
@ -1684,6 +1723,7 @@ internal class ConversationDatabase(
|
||||
null -> put("delivery_type", 0)
|
||||
DeliveryStatus.Sending -> put("delivery_type", 1)
|
||||
DeliveryStatus.Sent -> put("delivery_type", 2)
|
||||
DeliveryStatus.Queued -> put("delivery_type", 7)
|
||||
is DeliveryStatus.Delivered -> {
|
||||
put("delivery_type", 3)
|
||||
if (includeSensitiveText) put("delivery_text", status.to) else putNull("delivery_text")
|
||||
@ -1710,10 +1750,11 @@ internal class ConversationDatabase(
|
||||
null -> 0
|
||||
is DeliveryStatus.Failed -> 0
|
||||
DeliveryStatus.Sending -> 1
|
||||
DeliveryStatus.Sent -> 2
|
||||
is DeliveryStatus.PartiallyDelivered -> 3
|
||||
is DeliveryStatus.Delivered -> 4
|
||||
is DeliveryStatus.Read -> 5
|
||||
DeliveryStatus.Queued -> 2
|
||||
DeliveryStatus.Sent -> 3
|
||||
is DeliveryStatus.PartiallyDelivered -> 4
|
||||
is DeliveryStatus.Delivered -> 5
|
||||
is DeliveryStatus.Read -> 6
|
||||
}
|
||||
|
||||
private fun Cursor.toMessage(): BitchatMessage {
|
||||
@ -1781,6 +1822,7 @@ internal class ConversationDatabase(
|
||||
reached = nullableInt("delivery_reached") ?: 0,
|
||||
total = nullableInt("delivery_total") ?: 0
|
||||
)
|
||||
7 -> DeliveryStatus.Queued
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.io.File
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
/** Keystore-sealed persistence for private messages awaiting final acknowledgement. */
|
||||
internal class MessageOutboxStore(
|
||||
context: Context,
|
||||
private val cipher: ConversationStorageCipher = AndroidConversationStorageCipher(KEY_ALIAS)
|
||||
) {
|
||||
data class Entry(
|
||||
val content: String,
|
||||
val nickname: String,
|
||||
val messageID: String,
|
||||
val enqueuedAtMs: Long,
|
||||
var sendAttempts: Int = 0,
|
||||
var lastAttemptAtMs: Long = 0,
|
||||
var bridgeDeposited: Boolean = false,
|
||||
var lastBridgeAttemptAtMs: Long = 0,
|
||||
val depositedCourierKeys: MutableSet<String> = mutableSetOf()
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val KEY_ALIAS = "bitchat_message_outbox_v1"
|
||||
private val AAD = "bitchat-message-outbox-v1".toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private val gson = Gson()
|
||||
private val file = File(context.applicationContext.filesDir, "message-outbox.sealed")
|
||||
|
||||
@Synchronized
|
||||
fun load(): MutableMap<String, MutableList<Entry>> = try {
|
||||
if (!file.exists()) return mutableMapOf()
|
||||
val plaintext = cipher.decrypt(file.readBytes(), AAD)
|
||||
val type = object : TypeToken<MutableMap<String, MutableList<Entry>>>() {}.type
|
||||
gson.fromJson<MutableMap<String, MutableList<Entry>>>(plaintext.toString(Charsets.UTF_8), type)
|
||||
?: mutableMapOf()
|
||||
} catch (_: Exception) {
|
||||
mutableMapOf()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun save(outbox: Map<String, List<Entry>>) {
|
||||
if (outbox.values.all { it.isEmpty() }) {
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
val plaintext = gson.toJson(outbox).toByteArray(Charsets.UTF_8)
|
||||
val encrypted = cipher.encrypt(plaintext, AAD)
|
||||
val temporary = File(file.parentFile, "${file.name}.tmp")
|
||||
temporary.writeBytes(encrypted)
|
||||
try {
|
||||
java.nio.file.Files.move(
|
||||
temporary.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
temporary.delete()
|
||||
throw IllegalStateException("Failed to persist message outbox", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun wipe() {
|
||||
file.delete()
|
||||
cipher.destroyKey()
|
||||
}
|
||||
}
|
||||
@ -31,13 +31,6 @@ class MessageRouter private constructor(
|
||||
DROPPED
|
||||
}
|
||||
|
||||
private data class QueuedMessage(
|
||||
val content: String,
|
||||
val nickname: String,
|
||||
val messageID: String,
|
||||
val enqueuedAtMs: Long
|
||||
)
|
||||
|
||||
private data class ConversationRetry(
|
||||
val handshakeAttempts: Int,
|
||||
val nextHandshakeAttemptAtMs: Long
|
||||
@ -48,10 +41,15 @@ class MessageRouter private constructor(
|
||||
private const val OUTBOX_TICK_MS = AppConstants.Router.OUTBOX_TICK_MS
|
||||
private const val OUTBOX_MESSAGE_TTL_MS = AppConstants.Router.OUTBOX_MESSAGE_TTL_MS
|
||||
private const val OUTBOX_MAX_PER_PEER = AppConstants.Router.OUTBOX_MAX_PER_PEER
|
||||
private const val MAX_COURIERS_PER_MESSAGE = AppConstants.Router.MAX_COURIERS_PER_MESSAGE
|
||||
private const val OUTBOX_MAX_TOTAL = 1_000
|
||||
private const val OUTBOX_MAX_SEND_ATTEMPTS = 8
|
||||
private const val BRIDGE_RETRY_COOLDOWN_MS = 30 * 60 * 1000L
|
||||
private val HANDSHAKE_RETRY_BACKOFF_MS = AppConstants.Router.HANDSHAKE_RETRY_BACKOFF_MS
|
||||
|
||||
@Volatile private var INSTANCE: MessageRouter? = null
|
||||
internal var disableSchedulerForTesting = false
|
||||
internal var outboxStoreFactory: (Context) -> MessageOutboxStore = ::MessageOutboxStore
|
||||
fun tryGetInstance(): MessageRouter? = INSTANCE
|
||||
fun getInstance(context: Context, mesh: MeshService): MessageRouter {
|
||||
val instance = INSTANCE ?: synchronized(this) {
|
||||
@ -78,10 +76,19 @@ class MessageRouter private constructor(
|
||||
INSTANCE?.schedulerScope?.cancel()
|
||||
INSTANCE = null
|
||||
}
|
||||
|
||||
fun panicClear(context: Context) {
|
||||
val instance = INSTANCE
|
||||
if (instance != null) instance.clearAll()
|
||||
else outboxStoreFactory(context.applicationContext).wipe()
|
||||
}
|
||||
}
|
||||
|
||||
// Outbox: conversationID -> queued messages, oldest first
|
||||
private val outbox = ConcurrentHashMap<String, MutableList<QueuedMessage>>()
|
||||
private val outboxStore = outboxStoreFactory(context)
|
||||
private val outbox = ConcurrentHashMap<String, MutableList<MessageOutboxStore.Entry>>().apply {
|
||||
putAll(outboxStore.load())
|
||||
}
|
||||
|
||||
// Per-conversation handshake retry state for queued messages
|
||||
private val retryState = ConcurrentHashMap<String, ConversationRetry>()
|
||||
@ -99,9 +106,13 @@ class MessageRouter private constructor(
|
||||
startOutboxScheduler()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clearAll() {
|
||||
schedulerJob?.cancel()
|
||||
schedulerJob = null
|
||||
outbox.clear()
|
||||
retryState.clear()
|
||||
outboxStore.wipe()
|
||||
Log.d(TAG, "Cleared all MessageRouter outbox messages and retry state")
|
||||
}
|
||||
|
||||
@ -134,17 +145,23 @@ class MessageRouter private constructor(
|
||||
}
|
||||
|
||||
val hasMesh = meshTarget?.let { isConnected(mesh, it) } == true
|
||||
val entry = MessageOutboxStore.Entry(content, recipientNickname, messageID, clock())
|
||||
enqueue(conversationID, entry)
|
||||
if (meshTarget != null && isReady(mesh, meshTarget)) {
|
||||
Log.d(TAG, "Routing PM via mesh to ${meshTarget} msg_id=${messageID.take(8)}…")
|
||||
mesh.sendPrivateMessage(content, meshTarget, recipientNickname, messageID)
|
||||
markAttempt(conversationID, messageID)
|
||||
return RouteResult.MESH
|
||||
} else if (canSendViaNostr(nostrTarget)) {
|
||||
Log.d(TAG, "Routing PM via Nostr to ${conversationID.take(32)}… msg_id=${messageID.take(8)}…")
|
||||
nostr.sendPrivateMessage(content, nostrTarget, recipientNickname, messageID)
|
||||
return RouteResult.NOSTR
|
||||
markAttempt(conversationID, messageID)
|
||||
val prompt = canDeliverViaNostrPromptly()
|
||||
if (!prompt) attemptCourierDeposit(conversationID, entry)
|
||||
return if (prompt) RouteResult.NOSTR else RouteResult.QUEUED
|
||||
} else {
|
||||
Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…")
|
||||
enqueue(conversationID, QueuedMessage(content, recipientNickname, messageID, clock()))
|
||||
attemptCourierDeposit(conversationID, entry)
|
||||
Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}…")
|
||||
if (hasMesh) meshTarget?.let { kickHandshake(conversationID, it, immediate = true) }
|
||||
return RouteResult.QUEUED
|
||||
@ -209,12 +226,17 @@ class MessageRouter private constructor(
|
||||
val resolution = ContactDirectory.resolve(conversationID)
|
||||
val meshTarget = resolution.meshPeerID
|
||||
val nostrTarget = resolution.noiseKeyHex ?: conversationID
|
||||
if (clock() - entry.lastAttemptAtMs < retryDelay(entry.sendAttempts)) continue
|
||||
if (entry.sendAttempts >= OUTBOX_MAX_SEND_ATTEMPTS) continue
|
||||
if (meshTarget != null && isReady(mesh, meshTarget)) {
|
||||
mesh.sendPrivateMessage(entry.content, meshTarget, entry.nickname, entry.messageID)
|
||||
iterator.remove()
|
||||
entry.sendAttempts++
|
||||
entry.lastAttemptAtMs = clock()
|
||||
} else if (canSendViaNostr(nostrTarget)) {
|
||||
nostr.sendPrivateMessage(entry.content, nostrTarget, entry.nickname, entry.messageID)
|
||||
iterator.remove()
|
||||
entry.sendAttempts++
|
||||
entry.lastAttemptAtMs = clock()
|
||||
if (!canDeliverViaNostrPromptly()) attemptCourierDeposit(conversationID, entry)
|
||||
}
|
||||
}
|
||||
if (queued.isEmpty()) {
|
||||
@ -223,6 +245,7 @@ class MessageRouter private constructor(
|
||||
retryState.remove(conversationID)
|
||||
retryState.remove(peerID)
|
||||
}
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
// Flush everything (rarely used)
|
||||
@ -231,14 +254,120 @@ class MessageRouter private constructor(
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun enqueue(conversationID: String, entry: QueuedMessage) {
|
||||
private fun enqueue(conversationID: String, entry: MessageOutboxStore.Entry) {
|
||||
val queue = outbox.getOrPut(conversationID) { mutableListOf() }
|
||||
if (queue.any { it.messageID == entry.messageID }) return
|
||||
queue.add(entry)
|
||||
while (queue.size > OUTBOX_MAX_PER_PEER) {
|
||||
val evicted = queue.removeAt(0)
|
||||
Log.w(TAG, "Outbox full for ${conversationID.take(16)}…; evicting oldest msg_id=${evicted.messageID.take(8)}…")
|
||||
notifyExpired(evicted.messageID)
|
||||
}
|
||||
while (outbox.values.sumOf { it.size } > OUTBOX_MAX_TOTAL) {
|
||||
val oldest = outbox.entries
|
||||
.flatMap { (id, entries) -> entries.map { id to it } }
|
||||
.minByOrNull { it.second.enqueuedAtMs } ?: break
|
||||
outbox[oldest.first]?.remove(oldest.second)
|
||||
if (outbox[oldest.first].isNullOrEmpty()) outbox.remove(oldest.first)
|
||||
notifyExpired(oldest.second.messageID)
|
||||
}
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onMessageAcknowledged(messageID: String, peerID: String) {
|
||||
val acknowledgedConversation = ContactDirectory.canonicalConversationId(peerID)
|
||||
var changed = false
|
||||
outbox.entries.toList().forEach { (conversationID, queue) ->
|
||||
if (ContactDirectory.canonicalConversationId(conversationID) != acknowledgedConversation) return@forEach
|
||||
changed = queue.removeAll { it.messageID == messageID } || changed
|
||||
if (queue.isEmpty()) {
|
||||
outbox.remove(conversationID, queue)
|
||||
retryState.remove(conversationID)
|
||||
}
|
||||
}
|
||||
if (changed) persistOutbox()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun markAttempt(conversationID: String, messageID: String) {
|
||||
outbox[conversationID]?.firstOrNull { it.messageID == messageID }?.sendAttempts =
|
||||
(outbox[conversationID]?.firstOrNull { it.messageID == messageID }?.sendAttempts ?: 0) + 1
|
||||
outbox[conversationID]?.firstOrNull { it.messageID == messageID }?.lastAttemptAtMs = clock()
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
private fun retryDelay(attempts: Int): Long = when (attempts) {
|
||||
0 -> 0L
|
||||
1 -> 30_000L
|
||||
2 -> 2 * 60_000L
|
||||
else -> 10 * 60_000L
|
||||
}
|
||||
|
||||
private fun attemptCourierDeposit(conversationID: String, entry: MessageOutboxStore.Entry) {
|
||||
val resolution = ContactDirectory.resolve(conversationID)
|
||||
val recipientKey = resolution.noiseKeyHex?.let(ContactIdentityResolver::bytesFromHex) ?: return
|
||||
if (!entry.bridgeDeposited &&
|
||||
(entry.lastBridgeAttemptAtMs == 0L || clock() - entry.lastBridgeAttemptAtMs >= BRIDGE_RETRY_COOLDOWN_MS)
|
||||
) {
|
||||
val submitted = mesh.sendBridgeCourierMessage(entry.content, entry.messageID, recipientKey) {
|
||||
synchronized(this) {
|
||||
val current = outbox[conversationID]?.firstOrNull { it.messageID == entry.messageID }
|
||||
if (current != null) {
|
||||
current.bridgeDeposited = true
|
||||
persistOutbox()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (submitted) {
|
||||
entry.lastBridgeAttemptAtMs = clock()
|
||||
persistOutbox()
|
||||
}
|
||||
}
|
||||
if (entry.depositedCourierKeys.size >= MAX_COURIERS_PER_MESSAGE) return
|
||||
val candidates = mesh.getPeerInfos()
|
||||
.asSequence()
|
||||
.filter { it.isConnected && it.noisePublicKey != null && !it.noisePublicKey!!.contentEquals(recipientKey) }
|
||||
.filter { peer ->
|
||||
val favorite = try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared
|
||||
.getFavoriteStatus(peer.noisePublicKey!!)?.isMutual == true
|
||||
} catch (_: Exception) { false }
|
||||
favorite || peer.hasVerifiedAnnouncement
|
||||
}
|
||||
.map { peer ->
|
||||
val favorite = try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared
|
||||
.getFavoriteStatus(peer.noisePublicKey!!)?.isMutual == true
|
||||
} catch (_: Exception) { false }
|
||||
peer to favorite
|
||||
}
|
||||
.filter { (peer, _) -> ContactIdentityResolver.noiseKeyHex(peer.noisePublicKey!!) !in entry.depositedCourierKeys }
|
||||
.sortedByDescending { (_, favorite) -> favorite }
|
||||
.take(MAX_COURIERS_PER_MESSAGE - entry.depositedCourierKeys.size)
|
||||
.map { (peer, _) -> peer }
|
||||
.toList()
|
||||
if (candidates.isEmpty()) return
|
||||
val accepted = mesh.sendCourierMessage(
|
||||
entry.content,
|
||||
entry.messageID,
|
||||
recipientKey,
|
||||
candidates.map { it.id }
|
||||
).toSet()
|
||||
candidates.filter { it.id in accepted }.forEach {
|
||||
entry.depositedCourierKeys += ContactIdentityResolver.noiseKeyHex(it.noisePublicKey!!)
|
||||
}
|
||||
if (accepted.isNotEmpty()) persistOutbox()
|
||||
}
|
||||
|
||||
private fun canDeliverViaNostrPromptly(): Boolean = try {
|
||||
nostr.canDeliverPromptly()
|
||||
} catch (_: Exception) { false }
|
||||
|
||||
private fun persistOutbox() {
|
||||
try { outboxStore.save(outbox) } catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to persist sealed outbox: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyExpired(messageID: String) {
|
||||
@ -286,6 +415,7 @@ class MessageRouter private constructor(
|
||||
* follow the MeshForegroundService lifecycle; getInstance restarts the scheduler
|
||||
* and rebinds the mesh reference when the service comes back.
|
||||
*/
|
||||
@Synchronized
|
||||
fun stopOutboxScheduler() {
|
||||
schedulerJob?.cancel()
|
||||
schedulerJob = null
|
||||
@ -304,6 +434,7 @@ class MessageRouter private constructor(
|
||||
expireOldEntries(conversationID, nowMs)
|
||||
val queued = outbox[conversationID] ?: return@forEach
|
||||
if (queued.isEmpty()) return@forEach
|
||||
queued.forEach { attemptCourierDeposit(conversationID, it) }
|
||||
|
||||
val resolution = ContactDirectory.resolve(conversationID)
|
||||
val meshTarget = resolution.meshPeerID
|
||||
@ -338,6 +469,7 @@ class MessageRouter private constructor(
|
||||
outbox.remove(conversationID, queued)
|
||||
retryState.remove(conversationID)
|
||||
}
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
private fun canSendViaNostr(peerID: String): Boolean {
|
||||
@ -385,8 +517,18 @@ class MessageRouter private constructor(
|
||||
} catch (_: Exception) { null }
|
||||
noiseHex?.let {
|
||||
kickHandshakeIfPending(it)
|
||||
flushOutboxFor(it)
|
||||
if (ContactDirectory.canonicalConversationId(it) != ContactDirectory.canonicalConversationId(pid)) {
|
||||
flushOutboxFor(it)
|
||||
}
|
||||
}
|
||||
retryCourierDeposits()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun retryCourierDeposits() {
|
||||
outbox.forEach { (conversationID, entries) ->
|
||||
entries.forEach { attemptCourierDeposit(conversationID, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -399,7 +541,9 @@ class MessageRouter private constructor(
|
||||
} catch (_: Exception) { null }
|
||||
noiseHex?.let {
|
||||
resetRetry(it)
|
||||
flushOutboxFor(it)
|
||||
if (ContactDirectory.canonicalConversationId(it) != ContactDirectory.canonicalConversationId(peerID)) {
|
||||
flushOutboxFor(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<Entry> {
|
||||
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<String, PrivateMediaOutboxStore.Entry>? = null
|
||||
@Volatile private var paused = false
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(15_000)
|
||||
runCatching { retryPending() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loaded(): MutableMap<String, PrivateMediaOutboxStore.Entry> =
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -146,7 +146,10 @@ object VerificationService {
|
||||
val service = encryptionServiceRef?.get() ?: return null
|
||||
val qr = VerificationQR.fromUrlString(urlString) ?: return null
|
||||
val now = System.currentTimeMillis() / 1000L
|
||||
if (now - qr.ts > maxAgeSeconds) return null
|
||||
// Freshness in both directions: a future-dated timestamp must not
|
||||
// buy a QR a longer validity window than a fresh one gets. iOS uses
|
||||
// the same abs() check in VerificationService.verifyScannedQR.
|
||||
if (verificationTimestampSkewSeconds(now, qr.ts) > maxAgeSeconds) return null
|
||||
|
||||
val sig = qr.sigHex.dataFromHexString() ?: return null
|
||||
val signKey = qr.signKeyHex.dataFromHexString() ?: return null
|
||||
@ -292,3 +295,10 @@ object VerificationService {
|
||||
var last: CacheEntry? = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Absolute age of a verification QR timestamp, in seconds. */
|
||||
internal fun verificationTimestampSkewSeconds(nowSeconds: Long, qrTimestampSeconds: Long): Long {
|
||||
if (nowSeconds < 0 || qrTimestampSeconds < 0) return Long.MAX_VALUE
|
||||
return if (nowSeconds >= qrTimestampSeconds) nowSeconds - qrTimestampSeconds
|
||||
else qrTimestampSeconds - nowSeconds
|
||||
}
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
|
||||
internal class BoundedIdSet(private val capacity: Int) {
|
||||
private val values = LinkedHashSet<String>()
|
||||
|
||||
fun add(id: String): Boolean {
|
||||
if (!values.add(id)) return false
|
||||
while (values.size > capacity) values.remove(values.first())
|
||||
return true
|
||||
}
|
||||
|
||||
fun contains(id: String): Boolean = id in values
|
||||
|
||||
fun clear() = values.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns one logical relay subscription while assigning a distinct wire ID to
|
||||
* every replacement. Relay CLOSE/REQ writes may execute out of order, but a
|
||||
* delayed close can only affect the retired generation.
|
||||
*
|
||||
* Callers keep this object confined to their coordinator dispatcher.
|
||||
*/
|
||||
internal class RelaySubscriptionSlot(private val idPrefix: String) {
|
||||
private var generation = 0L
|
||||
private var activeId: String? = null
|
||||
|
||||
fun replace(close: (String) -> Unit, open: (String) -> Unit): String {
|
||||
activeId?.let(close)
|
||||
val replacementId = "$idPrefix-${++generation}"
|
||||
activeId = replacementId
|
||||
try {
|
||||
open(replacementId)
|
||||
} catch (error: Throwable) {
|
||||
activeId = null
|
||||
throw error
|
||||
}
|
||||
return replacementId
|
||||
}
|
||||
|
||||
fun close(close: (String) -> Unit) {
|
||||
val id = activeId ?: return
|
||||
activeId = null
|
||||
close(id)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
|
||||
data class BridgedParticipant(
|
||||
val pubkey: String,
|
||||
val nickname: String?,
|
||||
val lastSeenMs: Long
|
||||
) {
|
||||
val displayName: String
|
||||
get() = "${nickname?.trim()?.takeIf { it.isNotEmpty() } ?: "anon"}#${pubkey.takeLast(4)}"
|
||||
}
|
||||
|
||||
data class BridgeUiState(
|
||||
val enabled: Boolean = false,
|
||||
val nearbyOnly: Boolean = false,
|
||||
val participants: List<BridgedParticipant> = emptyList()
|
||||
)
|
||||
|
||||
internal data class VerifiedBridgePeer(
|
||||
val peerId: String,
|
||||
val nickname: String,
|
||||
val noiseKey: ByteArray,
|
||||
val signingKey: ByteArray,
|
||||
val isVerifiedNickname: Boolean,
|
||||
val capabilities: PeerCapabilities?,
|
||||
val bridgeCell: String?,
|
||||
val lastSeenMs: Long
|
||||
)
|
||||
@ -0,0 +1,40 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
|
||||
/**
|
||||
* Verifies bridge protocol packets against the signing key bound by the
|
||||
* sender's authenticated announcement.
|
||||
*/
|
||||
internal object BridgePacketSignatureVerifier {
|
||||
fun verify(packet: BitchatPacket, publicKey: ByteArray): Boolean {
|
||||
val signature = packet.signature?.takeIf { it.size == 64 } ?: return false
|
||||
val signingData = packet.toBinaryDataForSigning() ?: return false
|
||||
if (publicKey.size != 32) return false
|
||||
return runCatching {
|
||||
Ed25519Signer().apply {
|
||||
init(false, Ed25519PublicKeyParameters(publicKey, 0))
|
||||
update(signingData, 0, signingData.size)
|
||||
}.verifySignature(signature)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
internal object CourierDepositAuthenticator {
|
||||
fun authenticate(
|
||||
packet: BitchatPacket,
|
||||
fromPeerId: String,
|
||||
directIngress: Boolean,
|
||||
peers: List<VerifiedBridgePeer>,
|
||||
isTrusted: (VerifiedBridgePeer) -> Boolean
|
||||
): VerifiedBridgePeer? {
|
||||
if (!directIngress || packet.senderID.toHex() != fromPeerId) return null
|
||||
val peer = peers.firstOrNull { it.peerId == fromPeerId } ?: return null
|
||||
if (!BridgePacketSignatureVerifier.verify(packet, peer.signingKey)) return null
|
||||
return peer.takeIf(isTrusted)
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import com.bitchat.android.mesh.MeshPacketUtils
|
||||
import com.bitchat.android.mesh.BridgeMeshPort
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
|
||||
/**
|
||||
* Shared construction policy for bridge protocol packets emitted by BLE and
|
||||
* Wi-Fi Aware mesh implementations.
|
||||
*/
|
||||
internal object BridgeProtocolPacketFactory {
|
||||
fun protocolPacket(
|
||||
type: MessageType,
|
||||
payload: ByteArray,
|
||||
senderPeerId: String,
|
||||
recipientPeerId: String?,
|
||||
ttl: UByte,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): BitchatPacket? {
|
||||
if (payload.isEmpty()) return null
|
||||
return BitchatPacket(
|
||||
version = if (payload.size > 0xFFFF) 2u else 1u,
|
||||
type = type.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(senderPeerId),
|
||||
recipientID = recipientPeerId?.let(MeshPacketUtils::hexStringToByteArray),
|
||||
timestamp = nowMs.toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = ttl
|
||||
)
|
||||
}
|
||||
|
||||
fun identityAnnouncement(
|
||||
nickname: String,
|
||||
noiseStaticKey: ByteArray,
|
||||
signingKey: ByteArray
|
||||
): IdentityAnnouncement =
|
||||
IdentityAnnouncement.forLocalPeer(
|
||||
nickname,
|
||||
noiseStaticKey,
|
||||
signingKey,
|
||||
BridgeMeshPort.advertisedCell()
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,774 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import com.bitchat.android.geohash.Geohash
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.mesh.BridgeMeshDelegate
|
||||
import com.bitchat.android.mesh.BridgeOutboundPolicy
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.NostrCarrierPacket
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.nostr.MeshMessageIdentity
|
||||
import com.bitchat.android.nostr.NostrEvent
|
||||
import com.bitchat.android.nostr.NostrFilter
|
||||
import com.bitchat.android.nostr.NostrIdentityBridge
|
||||
import com.bitchat.android.nostr.NostrKind
|
||||
import com.bitchat.android.nostr.NostrProtocol
|
||||
import com.bitchat.android.nostr.NostrRelayManager
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.services.AppStateStore
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
import com.bitchat.android.ui.DataManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Opt-in bridge policy shared by foreground transport and Compose UI.
|
||||
*
|
||||
* Outbound public traffic crosses the bridge only when the author opted in
|
||||
* and did not mark the message nearby-only. Passive `fromBridge` reception is
|
||||
* accepted regardless of the switch because it exposes no local traffic.
|
||||
*/
|
||||
object MeshBridgeService : BridgeMeshDelegate {
|
||||
private data class PendingUplink(
|
||||
val depositor: String,
|
||||
val cell: String,
|
||||
val event: NostrEvent
|
||||
)
|
||||
|
||||
private data class PendingDownlink(
|
||||
val cell: String,
|
||||
val event: NostrEvent
|
||||
)
|
||||
|
||||
private const val TAG = "MeshBridgeService"
|
||||
private const val PREFS = "bitchat_bridge"
|
||||
private const val KEY_ENABLED = "bridge_enabled_v1"
|
||||
private const val CELL_PRECISION = 6
|
||||
private const val MAX_EVENT_AGE_MS = 15L * 60 * 1000
|
||||
private const val MAX_CONTENT_BYTES = 16_000
|
||||
private const val MAX_TRACKED_IDS = 512
|
||||
private const val MAX_PARTICIPANTS = 128
|
||||
private const val PARTICIPANT_FRESH_MS = 10L * 60 * 1000
|
||||
private const val PRESENCE_INTERVAL_MS = 4L * 60 * 1000
|
||||
private const val MAX_QUEUED_UPLINKS = 20
|
||||
private const val MAX_UPLINKS_PER_DEPOSITOR = 5
|
||||
private const val UPLINKS_PER_MINUTE_PER_DEPOSITOR = 10
|
||||
private const val MAX_PENDING_DOWNLINKS = 30
|
||||
private const val DOWNLINKS_PER_MINUTE = 20
|
||||
private const val INBOUND_PER_MINUTE = 600
|
||||
private const val INBOUND_PER_SIGNER_PER_MINUTE = 120
|
||||
private const val SIGNATURE_ATTEMPTS_PER_MINUTE = 720
|
||||
|
||||
private val dispatcher = Dispatchers.Default.limitedParallelism(1)
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
private val _isEnabled = MutableStateFlow(false)
|
||||
val isEnabled: StateFlow<Boolean> = _isEnabled.asStateFlow()
|
||||
private val _nearbyOnly = MutableStateFlow(false)
|
||||
val nearbyOnly: StateFlow<Boolean> = _nearbyOnly.asStateFlow()
|
||||
private val _activeCell = MutableStateFlow<String?>(null)
|
||||
val activeCell: StateFlow<String?> = _activeCell.asStateFlow()
|
||||
private val _bridgedParticipants = MutableStateFlow<List<BridgedParticipant>>(emptyList())
|
||||
val bridgedParticipants: StateFlow<List<BridgedParticipant>> = _bridgedParticipants.asStateFlow()
|
||||
private val outboundPolicy = AtomicReference(BridgeOutboundPolicy.Denied)
|
||||
private val rendezvousSubscription = RelaySubscriptionSlot("mesh-bridge-rendezvous")
|
||||
|
||||
@Volatile
|
||||
private var appContext: Context? = null
|
||||
private var relayManager: NostrRelayManager? = null
|
||||
private var prekeyCoordinator: PrekeyCoordinator? = null
|
||||
private var prefs: android.content.SharedPreferences? = null
|
||||
@Volatile
|
||||
private var meshProvider: () -> MeshService? = { null }
|
||||
private var clock: () -> Long = System::currentTimeMillis
|
||||
private var jitter: (Long, Long) -> Long = kotlin.random.Random::nextLong
|
||||
private var localLocationCell: String? = null
|
||||
private var subscribedCells: Set<String> = emptySet()
|
||||
private val verifiedPeers = linkedMapOf<String, VerifiedBridgePeer>()
|
||||
private val verifiedPeerSnapshot = AtomicReference<List<VerifiedBridgePeer>>(emptyList())
|
||||
private val publishedEventIds = BoundedIdSet(MAX_TRACKED_IDS)
|
||||
private val receivedEventIds = BoundedIdSet(MAX_TRACKED_IDS)
|
||||
private val meshBroadcastEventIds = BoundedIdSet(MAX_TRACKED_IDS)
|
||||
private val rebroadcastEventIds = BoundedIdSet(MAX_TRACKED_IDS)
|
||||
private val injectedEventIds = BoundedIdSet(MAX_TRACKED_IDS)
|
||||
private val radioMessageIds = BoundedIdSet(MAX_TRACKED_IDS)
|
||||
private val queuedUplinks = mutableListOf<PendingUplink>()
|
||||
private val pendingDownlinks = mutableListOf<PendingDownlink>()
|
||||
private val participants = linkedMapOf<String, BridgedParticipant>()
|
||||
private val uplinkTimes = mutableMapOf<String, MutableList<Long>>()
|
||||
private val inboundTimes = mutableListOf<Long>()
|
||||
private val inboundTimesBySigner = mutableMapOf<String, MutableList<Long>>()
|
||||
private val downlinkTimes = mutableListOf<Long>()
|
||||
private val signatureAttemptTimes = mutableListOf<Long>()
|
||||
private var downlinkJob: Job? = null
|
||||
private var presenceJob: Job? = null
|
||||
@Volatile
|
||||
private var suppressPrekeyBroadcasts = false
|
||||
@Volatile
|
||||
private var panicQuiesced = false
|
||||
|
||||
fun initialize(
|
||||
context: Context,
|
||||
meshProvider: () -> MeshService? = {
|
||||
com.bitchat.android.service.MeshServiceHolder.unifiedMeshService
|
||||
},
|
||||
clock: () -> Long = System::currentTimeMillis,
|
||||
jitter: (Long, Long) -> Long = kotlin.random.Random::nextLong
|
||||
) {
|
||||
if (appContext != null) return
|
||||
synchronized(this) {
|
||||
if (appContext != null) return
|
||||
val application = context.applicationContext
|
||||
appContext = application
|
||||
this.meshProvider = meshProvider
|
||||
this.clock = clock
|
||||
this.jitter = jitter
|
||||
relayManager = NostrRelayManager.getInstance(application)
|
||||
val prekeyManager = PrekeyManager.getInstance(application)
|
||||
prefs = application.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val storedEnabled = loadEnabledWithMigration(prefs!!)
|
||||
_isEnabled.value = storedEnabled && !panicQuiesced
|
||||
if (panicQuiesced && storedEnabled) {
|
||||
prefs?.edit { putBoolean(KEY_ENABLED, false) }
|
||||
}
|
||||
outboundPolicy.set(
|
||||
BridgeOutboundPolicy.capture(
|
||||
enabled = _isEnabled.value,
|
||||
nearbyOnly = _nearbyOnly.value
|
||||
)
|
||||
)
|
||||
PeerCapabilities.setBridgeEnabled(_isEnabled.value)
|
||||
prekeyCoordinator = PrekeyCoordinator(
|
||||
manager = prekeyManager,
|
||||
meshProvider = ::currentMesh,
|
||||
peersProvider = verifiedPeerSnapshot::get,
|
||||
clock = clock
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
val location = LocationChannelManager.getInstance(context)
|
||||
scope.launch {
|
||||
location.availableChannels.collect { channels ->
|
||||
if (!_isEnabled.value) {
|
||||
localLocationCell = null
|
||||
return@collect
|
||||
}
|
||||
localLocationCell = channels
|
||||
.firstOrNull { it.level == GeohashChannelLevel.NEIGHBORHOOD }
|
||||
?.geohash
|
||||
?.take(CELL_PRECISION)
|
||||
refreshRendezvous()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
relayManager?.isConnected?.collect { connected ->
|
||||
if (connected) {
|
||||
refreshRendezvous(forceSubscriptions = true)
|
||||
flushQueuedUplinks()
|
||||
publishPresence()
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
if (_isEnabled.value) {
|
||||
relayManager?.connect()
|
||||
location.refreshChannels(
|
||||
forceFresh = true,
|
||||
updatePlaceNames = false
|
||||
)
|
||||
refreshRendezvous(forceSubscriptions = true)
|
||||
}
|
||||
if (_isEnabled.value) startPresenceLoop()
|
||||
delay(2_000)
|
||||
broadcastPrekeys(force = true)
|
||||
}
|
||||
}
|
||||
|
||||
private val publicationGeneration = java.util.concurrent.atomic.AtomicLong()
|
||||
fun publicationPermit(): () -> Boolean {
|
||||
val generation = publicationGeneration.get()
|
||||
val enabledAtCapture = _isEnabled.value
|
||||
return { enabledAtCapture && _isEnabled.value && generation == publicationGeneration.get() }
|
||||
}
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
if (outboundPolicy.get().enabled == enabled) return
|
||||
if (enabled) {
|
||||
panicQuiesced = false
|
||||
suppressPrekeyBroadcasts = false
|
||||
}
|
||||
publicationGeneration.incrementAndGet()
|
||||
outboundPolicy.set(BridgeOutboundPolicy.capture(enabled, nearbyOnly = false))
|
||||
_isEnabled.value = enabled
|
||||
prefs?.edit { putBoolean(KEY_ENABLED, enabled) }
|
||||
PeerCapabilities.setBridgeEnabled(enabled)
|
||||
_nearbyOnly.value = false
|
||||
scope.launch {
|
||||
if (!enabled) {
|
||||
stopPresenceLoop()
|
||||
closeSubscriptions()
|
||||
queuedUplinks.clear()
|
||||
pendingDownlinks.clear()
|
||||
participants.clear()
|
||||
publishParticipants()
|
||||
localLocationCell = null
|
||||
_activeCell.value = null
|
||||
} else {
|
||||
startPresenceLoop()
|
||||
relayManager?.connect()
|
||||
LocationChannelManager.getInstance(requireContext())
|
||||
.refreshChannels(
|
||||
forceFresh = true,
|
||||
updatePlaceNames = false
|
||||
)
|
||||
refreshRendezvous(forceSubscriptions = true)
|
||||
broadcastPrekeys(force = true)
|
||||
}
|
||||
currentMesh()?.sendBroadcastAnnounce()
|
||||
}
|
||||
}
|
||||
|
||||
fun setNearbyOnly(enabled: Boolean) {
|
||||
outboundPolicy.updateAndGet { current ->
|
||||
BridgeOutboundPolicy.capture(current.enabled, nearbyOnly = enabled)
|
||||
}
|
||||
_nearbyOnly.value = enabled
|
||||
}
|
||||
|
||||
/** Cell included in announce TLV 0x06 while the bridge switch is on. */
|
||||
override fun advertisedCell(): String? = _activeCell.value.takeIf { _isEnabled.value }
|
||||
|
||||
override fun outboundPolicy(): BridgeOutboundPolicy = outboundPolicy.get()
|
||||
|
||||
override fun bridgeOutgoing(
|
||||
content: String,
|
||||
senderPeerId: String,
|
||||
timestampMs: Long,
|
||||
nickname: String?,
|
||||
policyAtSend: BridgeOutboundPolicy
|
||||
) {
|
||||
scope.launch {
|
||||
if (!policyAtSend.permitsPublication(outboundPolicy.get())) return@launch
|
||||
val cell = _activeCell.value ?: currentCell() ?: return@launch
|
||||
if (content.toByteArray(Charsets.UTF_8).size > MAX_CONTENT_BYTES) return@launch
|
||||
val identity = NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext())
|
||||
val event = NostrProtocol.createBridgeMeshEvent(
|
||||
content = content,
|
||||
cell = cell,
|
||||
senderIdentity = identity,
|
||||
nickname = nickname,
|
||||
meshSenderId = senderPeerId,
|
||||
meshTimestampMs = timestampMs
|
||||
)
|
||||
if (!policyAtSend.permitsPublication(outboundPolicy.get())) return@launch
|
||||
publishedEventIds.add(event.id)
|
||||
injectedEventIds.add(event.id)
|
||||
if (relayManager?.isConnected?.value == true) {
|
||||
relayManager?.sendEventToGeohash(event, cell, publicationAllowed = publicationPermit())
|
||||
} else {
|
||||
val peer = availableBridgePeer() ?: return@launch
|
||||
NostrCarrierPacket.fromEvent(
|
||||
NostrCarrierPacket.Direction.TO_BRIDGE,
|
||||
cell,
|
||||
event
|
||||
)?.encode()?.let { currentMesh()?.sendNostrCarrier(it, peer.peerId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Called only after a public radio packet's Ed25519 signature was accepted. */
|
||||
override fun handleAuthenticatedRadioMessage(messageId: String) {
|
||||
if (panicQuiesced || messageId.isBlank()) return
|
||||
scope.launch {
|
||||
radioMessageIds.add(messageId)
|
||||
pendingDownlinks.removeAll { pending ->
|
||||
classifyMessage(pending.event, pending.cell)?.bridgeRadioMessageIdHint == messageId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement) {
|
||||
if (panicQuiesced) return
|
||||
scope.launch {
|
||||
val peer = VerifiedBridgePeer(
|
||||
peerId = peerId,
|
||||
nickname = announcement.nickname,
|
||||
noiseKey = announcement.noisePublicKey.copyOf(),
|
||||
signingKey = announcement.signingPublicKey.copyOf(),
|
||||
isVerifiedNickname =
|
||||
currentMesh()?.getPeerInfo(peerId)?.isVerifiedNickname == true,
|
||||
capabilities = announcement.capabilities,
|
||||
bridgeCell = announcement.bridgeGeohash?.takeIf(::isValidGeohash),
|
||||
lastSeenMs = clock()
|
||||
)
|
||||
verifiedPeers[peerId] = peer
|
||||
while (verifiedPeers.size > 200) verifiedPeers.remove(verifiedPeers.keys.first())
|
||||
publishVerifiedPeerSnapshot()
|
||||
prekeyCoordinator?.handlePeerVerified(peerId)
|
||||
if (_isEnabled.value) {
|
||||
refreshRendezvous()
|
||||
}
|
||||
broadcastPrekeys()
|
||||
}
|
||||
}
|
||||
|
||||
override fun handlePrekeyPacket(packet: BitchatPacket) {
|
||||
if (panicQuiesced) return
|
||||
scope.launch { prekeyCoordinator?.handlePacket(packet) }
|
||||
}
|
||||
|
||||
override fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) {
|
||||
if (panicQuiesced) return
|
||||
scope.launch {
|
||||
val carrier = NostrCarrierPacket.decode(payload) ?: return@launch
|
||||
when (carrier.direction) {
|
||||
NostrCarrierPacket.Direction.TO_BRIDGE -> {
|
||||
if (directedToUs) handleUplink(carrier, fromPeerId)
|
||||
}
|
||||
NostrCarrierPacket.Direction.FROM_BRIDGE -> {
|
||||
if (!directedToUs) handleDownlink(carrier)
|
||||
}
|
||||
NostrCarrierPacket.Direction.TO_GATEWAY,
|
||||
NostrCarrierPacket.Direction.FROM_GATEWAY -> MeshGatewayService.handleCarrier(carrier, fromPeerId, directedToUs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ApplySharedPref", "UseKtx")
|
||||
suspend fun wipe() {
|
||||
// Revoke policy synchronously so no already-queued bridge work can be
|
||||
// authorized by the pre-panic setting.
|
||||
publicationGeneration.incrementAndGet()
|
||||
outboundPolicy.set(BridgeOutboundPolicy.Denied)
|
||||
panicQuiesced = true
|
||||
suppressPrekeyBroadcasts = true
|
||||
_isEnabled.value = false
|
||||
_nearbyOnly.value = false
|
||||
PeerCapabilities.setBridgeEnabled(false)
|
||||
kotlinx.coroutines.withContext(dispatcher) {
|
||||
prefs?.edit()?.putBoolean(KEY_ENABLED, false)?.commit()
|
||||
stopPresenceLoop()
|
||||
downlinkJob?.cancel()
|
||||
downlinkJob = null
|
||||
closeSubscriptions()
|
||||
queuedUplinks.clear()
|
||||
pendingDownlinks.clear()
|
||||
localLocationCell = null
|
||||
_activeCell.value = null
|
||||
verifiedPeers.clear()
|
||||
publishVerifiedPeerSnapshot()
|
||||
participants.clear()
|
||||
publishedEventIds.clear()
|
||||
receivedEventIds.clear()
|
||||
meshBroadcastEventIds.clear()
|
||||
rebroadcastEventIds.clear()
|
||||
injectedEventIds.clear()
|
||||
radioMessageIds.clear()
|
||||
uplinkTimes.clear()
|
||||
inboundTimes.clear()
|
||||
inboundTimesBySigner.clear()
|
||||
downlinkTimes.clear()
|
||||
signatureAttemptTimes.clear()
|
||||
prekeyCoordinator?.wipe()
|
||||
publishParticipants()
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishVerifiedPeerSnapshot() {
|
||||
verifiedPeerSnapshot.set(
|
||||
verifiedPeers.values.map { peer ->
|
||||
peer.copy(
|
||||
noiseKey = peer.noiseKey.copyOf(),
|
||||
signingKey = peer.signingKey.copyOf()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun refreshRendezvous(forceSubscriptions: Boolean = false) {
|
||||
if (!_isEnabled.value) return
|
||||
val cell = currentCell()
|
||||
val changed = cell != _activeCell.value
|
||||
if (changed) {
|
||||
_activeCell.value = cell
|
||||
currentMesh()?.sendBroadcastAnnounce()
|
||||
}
|
||||
if (cell == null) return
|
||||
val cells = linkedSetOf(cell).apply { addAll(Geohash.neighborsSamePrecision(cell)) }
|
||||
if (changed || forceSubscriptions || cells != subscribedCells) {
|
||||
val targets = linkedSetOf<String>()
|
||||
cells.forEach { subscribedCell ->
|
||||
relayManager?.ensureGeohashRelaysConnected(subscribedCell)
|
||||
targets += relayManager?.getRelaysForGeohash(subscribedCell).orEmpty()
|
||||
}
|
||||
relayManager?.let { manager ->
|
||||
rendezvousSubscription.replace(
|
||||
close = manager::unsubscribe,
|
||||
open = { subscriptionId ->
|
||||
manager.subscribe(
|
||||
filter = NostrFilter.bridgeRendezvous(
|
||||
cells,
|
||||
since = clock() - MAX_EVENT_AGE_MS
|
||||
),
|
||||
id = subscriptionId,
|
||||
handler = { event -> scope.launch { handleRendezvousEvent(event) } },
|
||||
targetRelayUrls = targets.toList()
|
||||
)
|
||||
}
|
||||
)
|
||||
subscribedCells = cells
|
||||
}
|
||||
publishPresence()
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentCell(): String? {
|
||||
localLocationCell?.takeIf(::isValidGeohash)?.let { return it.take(CELL_PRECISION) }
|
||||
return availableBridgePeer()?.bridgeCell?.take(CELL_PRECISION)
|
||||
}
|
||||
|
||||
private fun availableBridgePeer(): VerifiedBridgePeer? =
|
||||
verifiedPeers.values.firstOrNull { peer ->
|
||||
peer.capabilities?.contains(PeerCapabilities.BRIDGE) == true &&
|
||||
peer.bridgeCell != null &&
|
||||
currentMesh()?.getPeerInfo(peer.peerId)?.isConnected == true
|
||||
}
|
||||
|
||||
private fun handleRendezvousEvent(event: NostrEvent) {
|
||||
if (!_isEnabled.value) return
|
||||
val cell = event.tagValue("r") ?: return
|
||||
if (cell !in subscribedCells || publishedEventIds.contains(event.id)) return
|
||||
if (isOwnEvent(event, cell)) {
|
||||
publishedEventIds.add(event.id)
|
||||
return
|
||||
}
|
||||
if (!allowSignatureAttempt() || !event.isValidSignature()) return
|
||||
if (!receivedEventIds.add(event.id) || !allowInbound(event.pubkey)) return
|
||||
if (!isFresh(event) || event.tagValue("r") != cell || !isValidGeohash(cell)) return
|
||||
|
||||
when (event.kind) {
|
||||
NostrKind.GEOHASH_PRESENCE -> recordParticipant(event.pubkey, null)
|
||||
NostrKind.EPHEMERAL_EVENT -> {
|
||||
val message = classifyMessage(event, cell) ?: return
|
||||
val localRadio = message.bridgeRadioMessageIdHint?.let(::radioCopyPresent) == true
|
||||
if (!localRadio && injectBridgeMessage(message)) {
|
||||
recordParticipant(event.pubkey, event.tagValue("n"))
|
||||
}
|
||||
if (!localRadio &&
|
||||
!meshBroadcastEventIds.contains(event.id) &&
|
||||
!rebroadcastEventIds.contains(event.id) &&
|
||||
pendingDownlinks.none { it.event.id == event.id }
|
||||
) {
|
||||
pendingDownlinks += PendingDownlink(cell, event)
|
||||
while (pendingDownlinks.size > MAX_PENDING_DOWNLINKS) pendingDownlinks.removeAt(0)
|
||||
scheduleDownlink(jitter = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUplink(carrier: NostrCarrierPacket, depositor: String) {
|
||||
if (!_isEnabled.value || !allowUplink(depositor)) return
|
||||
val event = structurallyValidEvent(carrier) ?: return
|
||||
if (meshBroadcastEventIds.contains(event.id) ||
|
||||
publishedEventIds.contains(event.id) ||
|
||||
queuedUplinks.any { it.event.id == event.id }
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!allowSignatureAttempt() || !event.isValidSignature()) return
|
||||
if (relayManager?.isConnected?.value == true) {
|
||||
publishCarriedEvent(event, carrier.geohash)
|
||||
} else {
|
||||
if (queuedUplinks.count { it.depositor == depositor } >= MAX_UPLINKS_PER_DEPOSITOR) return
|
||||
queuedUplinks += PendingUplink(depositor, carrier.geohash, event)
|
||||
while (queuedUplinks.size > MAX_QUEUED_UPLINKS) queuedUplinks.removeAt(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDownlink(carrier: NostrCarrierPacket) {
|
||||
val event = structurallyValidEvent(carrier) ?: return
|
||||
if (publishedEventIds.contains(event.id) || isOwnEvent(event, carrier.geohash)) return
|
||||
if (!allowSignatureAttempt() || !event.isValidSignature()) return
|
||||
val firstMesh = meshBroadcastEventIds.add(event.id)
|
||||
if (!firstMesh || !receivedEventIds.add(event.id) || !allowInbound(event.pubkey)) return
|
||||
val message = classifyMessage(event, carrier.geohash) ?: return
|
||||
if (injectBridgeMessage(message)) recordParticipant(event.pubkey, event.tagValue("n"))
|
||||
}
|
||||
|
||||
private fun structurallyValidEvent(carrier: NostrCarrierPacket): NostrEvent? {
|
||||
if (!isValidGeohash(carrier.geohash) ||
|
||||
carrier.eventJson.size > NostrCarrierPacket.MAX_EVENT_JSON_BYTES
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val event = carrier.event() ?: return null
|
||||
if (!isFresh(event) || event.tagValue("r") != carrier.geohash) return null
|
||||
return when (event.kind) {
|
||||
NostrKind.GEOHASH_PRESENCE -> event
|
||||
NostrKind.EPHEMERAL_EVENT ->
|
||||
event.takeIf { classifyMessage(it, carrier.geohash) != null }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun classifyMessage(event: NostrEvent, cell: String): BitchatMessage? {
|
||||
if (event.kind != NostrKind.EPHEMERAL_EVENT ||
|
||||
!isFresh(event) ||
|
||||
event.tagValue("r") != cell ||
|
||||
!isValidGeohash(cell)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val content = event.content
|
||||
if (content.isBlank() || content.toByteArray(Charsets.UTF_8).size > MAX_CONTENT_BYTES) return null
|
||||
val nickname = event.tagValue("n")?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val m = event.tags.firstOrNull { it.size >= 4 && it[0] == "m" }
|
||||
val radioHint = if (m != null &&
|
||||
m[2].matches(Regex("^[0-9a-fA-F]{16}$"))
|
||||
) {
|
||||
m[3].toLongOrNull()?.let { MeshMessageIdentity.stableId(m[2], it, content) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return BitchatMessage(
|
||||
id = event.id,
|
||||
sender = "${nickname ?: "anon"}#${event.pubkey.takeLast(4)}",
|
||||
content = content,
|
||||
timestamp = Date(event.createdAt * 1000L),
|
||||
senderPeerID = "bridge:${event.pubkey.take(16)}",
|
||||
isBridged = true,
|
||||
bridgeRadioMessageIdHint = radioHint
|
||||
)
|
||||
}
|
||||
|
||||
private fun injectBridgeMessage(message: BitchatMessage): Boolean {
|
||||
if (!injectedEventIds.add(message.id)) return false
|
||||
if (message.bridgeRadioMessageIdHint?.let(::radioCopyPresent) == true) return false
|
||||
AppStateStore.addPublicMessage(message)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun radioCopyPresent(messageId: String): Boolean =
|
||||
radioMessageIds.contains(messageId) || AppStateStore.hasRadioPublicMessage(messageId)
|
||||
|
||||
private fun scheduleDownlink(jitter: Boolean) {
|
||||
if (downlinkJob?.isActive == true || pendingDownlinks.isEmpty()) return
|
||||
val now = clock()
|
||||
downlinkTimes.removeAll { now - it >= 60_000 }
|
||||
val waitMs = if (jitter) {
|
||||
jitter(200, 1_501)
|
||||
} else {
|
||||
(downlinkTimes.minOrNull()?.plus(60_000)?.minus(now) ?: 50).coerceAtLeast(50)
|
||||
}
|
||||
downlinkJob = scope.launch {
|
||||
delay(waitMs)
|
||||
drainDownlinks()
|
||||
}
|
||||
}
|
||||
|
||||
private fun drainDownlinks() {
|
||||
val now = clock()
|
||||
downlinkTimes.removeAll { now - it >= 60_000 }
|
||||
while (pendingDownlinks.isNotEmpty() && downlinkTimes.size < DOWNLINKS_PER_MINUTE) {
|
||||
val item = pendingDownlinks.removeAt(0)
|
||||
if (!isFresh(item.event) ||
|
||||
meshBroadcastEventIds.contains(item.event.id) ||
|
||||
rebroadcastEventIds.contains(item.event.id)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
val message = classifyMessage(item.event, item.cell)
|
||||
if (message?.bridgeRadioMessageIdHint?.let(::radioCopyPresent) == true) continue
|
||||
val payload = NostrCarrierPacket.fromEvent(
|
||||
NostrCarrierPacket.Direction.FROM_BRIDGE,
|
||||
item.cell,
|
||||
item.event
|
||||
)?.encode() ?: continue
|
||||
currentMesh()?.sendNostrCarrier(payload)
|
||||
rebroadcastEventIds.add(item.event.id)
|
||||
downlinkTimes += clock()
|
||||
}
|
||||
if (pendingDownlinks.isNotEmpty()) scheduleDownlink(jitter = false)
|
||||
}
|
||||
|
||||
private fun flushQueuedUplinks() {
|
||||
if (!_isEnabled.value || relayManager?.isConnected?.value != true) return
|
||||
val queued = queuedUplinks.toList()
|
||||
queuedUplinks.clear()
|
||||
queued.filterNot { publishedEventIds.contains(it.event.id) }
|
||||
.forEach { publishCarriedEvent(it.event, it.cell) }
|
||||
}
|
||||
|
||||
private fun publishCarriedEvent(event: NostrEvent, cell: String) {
|
||||
publishedEventIds.add(event.id)
|
||||
relayManager?.sendEventToGeohash(event, cell, publicationAllowed = publicationPermit())
|
||||
}
|
||||
|
||||
private fun publishPresence() {
|
||||
if (!_isEnabled.value || relayManager?.isConnected?.value != true) return
|
||||
val cell = _activeCell.value ?: return
|
||||
val identity = NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext())
|
||||
val event = NostrProtocol.createBridgePresenceEvent(cell, identity)
|
||||
publishedEventIds.add(event.id)
|
||||
relayManager?.sendEventToGeohash(event, cell, publicationAllowed = publicationPermit())
|
||||
}
|
||||
|
||||
private fun startPresenceLoop() {
|
||||
if (presenceJob?.isActive == true) return
|
||||
presenceJob = scope.launch {
|
||||
while (true) {
|
||||
delay(PRESENCE_INTERVAL_MS)
|
||||
pruneParticipants()
|
||||
if (_isEnabled.value) {
|
||||
LocationChannelManager.getInstance(requireContext())
|
||||
.refreshChannels(
|
||||
forceFresh = true,
|
||||
updatePlaceNames = false
|
||||
)
|
||||
refreshRendezvous()
|
||||
publishPresence()
|
||||
broadcastPrekeys()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPresenceLoop() {
|
||||
presenceJob?.cancel()
|
||||
presenceJob = null
|
||||
}
|
||||
|
||||
fun resumeAfterPanic() {
|
||||
panicQuiesced = false
|
||||
suppressPrekeyBroadcasts = false
|
||||
refreshPrekeys()
|
||||
}
|
||||
|
||||
fun refreshPrekeys() {
|
||||
scope.launch { broadcastPrekeys(force = true) }
|
||||
}
|
||||
|
||||
private fun broadcastPrekeys(force: Boolean = false) {
|
||||
if (suppressPrekeyBroadcasts) return
|
||||
prekeyCoordinator?.broadcast(force)
|
||||
}
|
||||
|
||||
private fun recordParticipant(pubkey: String, nickname: String?) {
|
||||
val now = clock()
|
||||
participants.entries.removeAll { now - it.value.lastSeenMs > PARTICIPANT_FRESH_MS }
|
||||
if (pubkey !in participants && participants.size >= MAX_PARTICIPANTS) {
|
||||
participants.minByOrNull { it.value.lastSeenMs }?.key?.let(participants::remove)
|
||||
}
|
||||
val previous = participants[pubkey]
|
||||
participants[pubkey] = BridgedParticipant(
|
||||
pubkey,
|
||||
nickname?.trim()?.takeIf { it.isNotEmpty() } ?: previous?.nickname,
|
||||
now
|
||||
)
|
||||
publishParticipants()
|
||||
}
|
||||
|
||||
private fun pruneParticipants() {
|
||||
val now = clock()
|
||||
participants.entries.removeAll { now - it.value.lastSeenMs > PARTICIPANT_FRESH_MS }
|
||||
publishParticipants()
|
||||
}
|
||||
|
||||
private fun publishParticipants() {
|
||||
_bridgedParticipants.value = participants.values.sortedByDescending { it.lastSeenMs }
|
||||
}
|
||||
|
||||
private fun allowUplink(depositor: String): Boolean {
|
||||
val now = clock()
|
||||
val times = uplinkTimes.getOrPut(depositor) { mutableListOf() }
|
||||
times.removeAll { now - it >= 60_000 }
|
||||
if (times.size >= UPLINKS_PER_MINUTE_PER_DEPOSITOR) return false
|
||||
times += now
|
||||
return true
|
||||
}
|
||||
|
||||
private fun allowInbound(signer: String): Boolean {
|
||||
val now = clock()
|
||||
inboundTimes.removeAll { now - it >= 60_000 }
|
||||
if (inboundTimes.size >= INBOUND_PER_MINUTE) return false
|
||||
val signerTimes = inboundTimesBySigner.getOrPut(signer) { mutableListOf() }
|
||||
signerTimes.removeAll { now - it >= 60_000 }
|
||||
if (signerTimes.size >= INBOUND_PER_SIGNER_PER_MINUTE) return false
|
||||
inboundTimes += now
|
||||
signerTimes += now
|
||||
return true
|
||||
}
|
||||
|
||||
private fun allowSignatureAttempt(): Boolean {
|
||||
val now = clock()
|
||||
signatureAttemptTimes.removeAll { now - it >= 60_000 }
|
||||
if (signatureAttemptTimes.size >= SIGNATURE_ATTEMPTS_PER_MINUTE) return false
|
||||
signatureAttemptTimes += now
|
||||
return true
|
||||
}
|
||||
|
||||
private fun closeSubscriptions() {
|
||||
relayManager?.let { manager ->
|
||||
rendezvousSubscription.close(manager::unsubscribe)
|
||||
}
|
||||
subscribedCells = emptySet()
|
||||
}
|
||||
|
||||
private fun isOwnEvent(event: NostrEvent, cell: String): Boolean =
|
||||
runCatching {
|
||||
NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext())
|
||||
.publicKeyHex.equals(event.pubkey, ignoreCase = true)
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun isFresh(event: NostrEvent): Boolean =
|
||||
abs(clock() - event.createdAt * 1000L) <= MAX_EVENT_AGE_MS
|
||||
|
||||
private fun isValidGeohash(value: String): Boolean =
|
||||
value.length in 1..12 &&
|
||||
value.matches(Regex("^[0123456789bcdefghjkmnpqrstuvwxyz]+$", RegexOption.IGNORE_CASE))
|
||||
|
||||
private fun NostrEvent.tagValue(name: String): String? =
|
||||
tags.firstOrNull { it.size >= 2 && it[0] == name }?.get(1)
|
||||
|
||||
private fun currentMesh(): MeshService? = meshProvider()
|
||||
|
||||
private fun requireContext(): Context =
|
||||
checkNotNull(appContext) { "MeshBridgeService.initialize must be called first" }
|
||||
|
||||
private fun loadEnabledWithMigration(
|
||||
preferences: android.content.SharedPreferences
|
||||
): Boolean {
|
||||
if (preferences.contains(KEY_ENABLED)) return preferences.getBoolean(KEY_ENABLED, false)
|
||||
val legacyKeys = listOf("gateway_user_enabled", "gateway_enabled", "gateway.userEnabled")
|
||||
val migrated = legacyKeys.any { preferences.getBoolean(it, false) }
|
||||
preferences.edit {
|
||||
putBoolean(KEY_ENABLED, migrated)
|
||||
legacyKeys.forEach(::remove)
|
||||
}
|
||||
return migrated
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import android.content.Context
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.geohash.LiveLocationPrivacyGate
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.model.NostrCarrierPacket
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.nostr.*
|
||||
import com.bitchat.android.service.MeshServiceHolder
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
internal object GatewayEventPolicy {
|
||||
fun inspect(carrier: NostrCarrierPacket, nowSeconds: Long): NostrEvent? {
|
||||
if (!Regex("[0123456789bcdefghjkmnpqrstuvwxyz]{1,12}").matches(carrier.geohash)) return null
|
||||
val event = carrier.event() ?: return null
|
||||
if (event.kind != NostrKind.EPHEMERAL_EVENT ||
|
||||
event.tags.none { it.size >= 2 && it[0] == "g" && it[1] == carrier.geohash } ||
|
||||
nowSeconds - event.createdAt.toLong() !in -900L..900L) return null
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
/** Opt-in internet sharing for signed public geohash events (carrier directions 1 and 2). */
|
||||
object MeshGatewayService {
|
||||
private val dispatcher = Dispatchers.IO.limitedParallelism(1)
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
private val generation = AtomicLong()
|
||||
private val _enabled = MutableStateFlow(false)
|
||||
val enabled: StateFlow<Boolean> = _enabled.asStateFlow()
|
||||
private var context: Context? = null
|
||||
private val radioEvents = BoundedIdSet(512)
|
||||
private val published = BoundedIdSet(512)
|
||||
private val delivered = BoundedIdSet(512)
|
||||
private val inboundTimes = ArrayDeque<Long>()
|
||||
private val perPeer = linkedMapOf<String, ArrayDeque<Long>>()
|
||||
private val downlinkTimes = ArrayDeque<Long>()
|
||||
private val pending = linkedMapOf<String, Pair<NostrCarrierPacket, Long?>>()
|
||||
private var drain: Job? = null
|
||||
private val mesh get() = MeshServiceHolder.unifiedMeshService
|
||||
private val relays get() = context?.let(NostrRelayManager::getInstance)
|
||||
|
||||
@Synchronized
|
||||
fun initialize(application: Context) {
|
||||
if (context != null) return
|
||||
context = application.applicationContext
|
||||
_enabled.value = application.getSharedPreferences("mesh_gateway", Context.MODE_PRIVATE).getBoolean("enabled", false)
|
||||
PeerCapabilities.setGatewayEnabled(_enabled.value)
|
||||
}
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
generation.incrementAndGet()
|
||||
_enabled.value = enabled
|
||||
context?.getSharedPreferences("mesh_gateway", Context.MODE_PRIVATE)?.edit()?.putBoolean("enabled", enabled)?.apply()
|
||||
PeerCapabilities.setGatewayEnabled(enabled)
|
||||
if (!enabled) scope.launch { pending.clear(); drain?.cancel(); drain = null }
|
||||
mesh?.sendBroadcastAnnounce()
|
||||
}
|
||||
|
||||
suspend fun wipe() {
|
||||
setEnabled(false)
|
||||
withContext(dispatcher) {
|
||||
pending.clear()
|
||||
drain?.cancel()
|
||||
drain = null
|
||||
radioEvents.clear()
|
||||
published.clear()
|
||||
delivered.clear()
|
||||
inboundTimes.clear()
|
||||
perPeer.clear()
|
||||
downlinkTimes.clear()
|
||||
check(context?.getSharedPreferences("mesh_gateway", Context.MODE_PRIVATE)?.edit()?.clear()?.commit() != false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun permit(): () -> Boolean {
|
||||
val captured = generation.get()
|
||||
val enabledAtSend = _enabled.value
|
||||
return { enabledAtSend && _enabled.value && generation.get() == captured }
|
||||
}
|
||||
|
||||
fun uplinkIfOffline(event: NostrEvent, cell: String, liveToken: Long?) {
|
||||
val current = mesh ?: return
|
||||
if (liveToken != null && !LiveLocationPrivacyGate.accepts(liveToken)) return
|
||||
if (relays?.hasConnectedRelay(relays?.getRelaysForGeohash(cell).orEmpty()) == true) return
|
||||
val gateway = current.getPeerNicknames().keys.firstOrNull { peer ->
|
||||
current.getPeerInfo(peer)?.let { it.isConnected && it.hasVerifiedAnnouncement &&
|
||||
it.capabilities?.contains(PeerCapabilities.GATEWAY) == true } == true
|
||||
} ?: return
|
||||
val carrier = NostrCarrierPacket.fromEvent(NostrCarrierPacket.Direction.TO_GATEWAY, cell, event) ?: return
|
||||
scope.launch {
|
||||
if (liveToken != null && !LiveLocationPrivacyGate.accepts(liveToken)) return@launch
|
||||
radioEvents.add(event.id)
|
||||
current.sendNostrCarrier(carrier.encode(), gateway)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleCarrier(carrier: NostrCarrierPacket, peerID: String, directedToUs: Boolean) {
|
||||
val application = context ?: return
|
||||
val allowed = permit()
|
||||
scope.launch {
|
||||
val event = GatewayEventPolicy.inspect(carrier, System.currentTimeMillis() / 1000) ?: return@launch
|
||||
if (carrier.direction == NostrCarrierPacket.Direction.TO_GATEWAY) {
|
||||
if (!directedToUs || !allowed() || published.contains(event.id)) return@launch
|
||||
if (mesh?.getPeerInfo(peerID)?.hasVerifiedAnnouncement != true || !allowInbound(peerID)) return@launch
|
||||
if (!event.isValidSignature() || !allowed()) return@launch
|
||||
radioEvents.add(event.id)
|
||||
published.add(event.id)
|
||||
relays?.sendEventToGeohash(event, carrier.geohash, publicationAllowed = allowed)
|
||||
NostrBackgroundRuntime.receiveGatewayEvent(event, carrier.geohash)
|
||||
} else if (carrier.direction == NostrCarrierPacket.Direction.FROM_GATEWAY) {
|
||||
if (directedToUs || delivered.contains(event.id) || !allowInbound(peerID)) return@launch
|
||||
val channelManager = LocationChannelManager.getInstance(application)
|
||||
val selected = (channelManager.selectedChannel.value as? ChannelID.Location)?.channel ?: return@launch
|
||||
if (selected.geohash != carrier.geohash || !channelManager.canUseSelectedLocationChannel(selected)) return@launch
|
||||
if (!event.isValidSignature()) return@launch
|
||||
radioEvents.add(event.id)
|
||||
delivered.add(event.id)
|
||||
NostrBackgroundRuntime.receiveGatewayEvent(event, carrier.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rebroadcastRelayEvent(event: NostrEvent, cell: String, liveToken: Long?) {
|
||||
val allowed = permit()
|
||||
if (!allowed()) return
|
||||
val carrier = NostrCarrierPacket.fromEvent(NostrCarrierPacket.Direction.FROM_GATEWAY, cell, event) ?: return
|
||||
scope.launch {
|
||||
if (!allowed() || (liveToken != null && !LiveLocationPrivacyGate.accepts(liveToken)) ||
|
||||
GatewayEventPolicy.inspect(carrier, System.currentTimeMillis() / 1000) == null ||
|
||||
radioEvents.contains(event.id) || delivered.contains(event.id) || pending.containsKey(event.id)) return@launch
|
||||
if (!event.isValidSignature() || pending.size >= 100) return@launch
|
||||
pending[event.id] = carrier to liveToken
|
||||
if (drain?.isActive != true) drain = scope.launch {
|
||||
while (pending.isNotEmpty() && allowed()) {
|
||||
delay(kotlin.random.Random.nextLong(200, 1501))
|
||||
val now = System.currentTimeMillis()
|
||||
while (downlinkTimes.firstOrNull()?.let { now - it >= 60_000 } == true) downlinkTimes.removeFirst()
|
||||
if (downlinkTimes.size >= 30) { delay(1000); continue }
|
||||
val next = pending.entries.first()
|
||||
pending.remove(next.key)
|
||||
val (outgoing, token) = next.value
|
||||
if (!allowed() || (token != null && !LiveLocationPrivacyGate.accepts(token)) || radioEvents.contains(next.key) ||
|
||||
GatewayEventPolicy.inspect(outgoing, now / 1000) == null) continue
|
||||
mesh?.sendNostrCarrier(outgoing.encode())
|
||||
delivered.add(next.key)
|
||||
downlinkTimes.addLast(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun allowInbound(peerID: String): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
while (inboundTimes.firstOrNull()?.let { now - it >= 60_000 } == true) inboundTimes.removeFirst()
|
||||
perPeer.entries.removeAll { it.value.lastOrNull()?.let { now - it >= 60_000 } != false }
|
||||
if (perPeer.size >= 200 && peerID !in perPeer) return false
|
||||
val times = perPeer.getOrPut(peerID) { ArrayDeque() }
|
||||
while (times.firstOrNull()?.let { now - it >= 60_000 } == true) times.removeFirst()
|
||||
if (inboundTimes.size >= 20 || times.size >= 5) return false
|
||||
inboundTimes.addLast(now)
|
||||
times.addLast(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.PrekeyBundle
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
|
||||
/**
|
||||
* Coordinates authenticated prekey packets without owning cryptographic
|
||||
* persistence. [PrekeyManager] remains the repository/crypto boundary.
|
||||
*/
|
||||
internal class PrekeyCoordinator(
|
||||
private val manager: PrekeyManager,
|
||||
private val meshProvider: () -> MeshService?,
|
||||
private val peersProvider: () -> List<VerifiedBridgePeer>,
|
||||
private val clock: () -> Long = System::currentTimeMillis
|
||||
) {
|
||||
private val pendingPackets = linkedMapOf<String, BitchatPacket>()
|
||||
private var lastBroadcastMs = 0L
|
||||
|
||||
fun handlePacket(packet: BitchatPacket) {
|
||||
val bundle = PrekeyBundle.decode(packet.payload) ?: return
|
||||
val owner = ContactIdentityResolver.peerIdForNoiseKey(bundle.noiseStaticPublicKey)
|
||||
if (owner != packet.senderID.toHex()) return
|
||||
val peer = peersProvider().firstOrNull { it.peerId == owner }
|
||||
if (peer == null || !peer.noiseKey.contentEquals(bundle.noiseStaticPublicKey)) {
|
||||
if (pendingPackets.size < MAX_PENDING_PACKETS || owner in pendingPackets) {
|
||||
pendingPackets[owner] = packet
|
||||
}
|
||||
return
|
||||
}
|
||||
ingestVerified(packet, bundle, peer)
|
||||
}
|
||||
|
||||
fun handlePeerVerified(peerId: String) {
|
||||
pendingPackets.remove(peerId)?.let(::handlePacket)
|
||||
}
|
||||
|
||||
fun broadcast(force: Boolean = false) {
|
||||
val now = clock()
|
||||
if (!force && now - lastBroadcastMs < REBROADCAST_INTERVAL_MS) return
|
||||
val bundle = manager.currentSignedBundle(now) ?: return
|
||||
val encoded = bundle.encode() ?: return
|
||||
lastBroadcastMs = now
|
||||
meshProvider()?.sendPrekeyBundle(encoded)
|
||||
}
|
||||
|
||||
fun wipe() {
|
||||
pendingPackets.clear()
|
||||
lastBroadcastMs = 0L
|
||||
manager.wipe()
|
||||
}
|
||||
|
||||
private fun ingestVerified(
|
||||
packet: BitchatPacket,
|
||||
bundle: PrekeyBundle,
|
||||
peer: VerifiedBridgePeer
|
||||
) {
|
||||
if (!BridgePacketSignatureVerifier.verify(packet, peer.signingKey)) return
|
||||
if (manager.verifyAndIngest(bundle, peer.noiseKey, peer.signingKey, clock())) {
|
||||
com.bitchat.android.service.MeshServiceHolder.sharedGossipSyncManager?.onPublicPacketSeen(packet)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
private companion object {
|
||||
const val MAX_PENDING_PACKETS = 64
|
||||
const val REBROADCAST_INTERVAL_MS = 60L * 60 * 1000
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,331 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.model.PrekeyBundle
|
||||
import com.bitchat.android.noise.CourierNoiseCrypto
|
||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Owns local one-time prekeys and verified peer bundles for courier v2.
|
||||
*
|
||||
* Local private keys use EncryptedSharedPreferences through
|
||||
* [SecureIdentityStateManager]. Peer bundles contain public material only,
|
||||
* but their consumption assignments are persisted so retries of one message
|
||||
* never spend additional prekeys.
|
||||
*/
|
||||
class PrekeyManager internal constructor(
|
||||
private val identity: PrekeyIdentity,
|
||||
private val localStore: LocalPrekeyStore,
|
||||
private val peerStore: PeerPrekeyStore,
|
||||
private val randomBytes: () -> ByteArray
|
||||
) {
|
||||
data class Sealed(
|
||||
val ciphertext: ByteArray,
|
||||
val prekeyId: Long?
|
||||
)
|
||||
|
||||
data class Opened(
|
||||
val payload: ByteArray,
|
||||
val senderStaticKey: ByteArray,
|
||||
val consumedPrekey: Boolean
|
||||
)
|
||||
|
||||
private val lock = Any()
|
||||
private var local: LocalPrekeyState? = null
|
||||
private var peerBundles: MutableMap<String, StoredPeerPrekeyBundle>? = null
|
||||
|
||||
fun currentSignedBundle(nowMs: Long = System.currentTimeMillis()): PrekeyBundle? = synchronized(lock) {
|
||||
val staticKey = identity.staticKey()?.second ?: return@synchronized null
|
||||
val signingPrivateKey = identity.signingKey()?.first ?: return@synchronized null
|
||||
val state = loadLocalLocked()
|
||||
replenishLocked(state, nowMs)
|
||||
val prekeys = state.records
|
||||
.asSequence()
|
||||
.filter { it.consumedAt == null }
|
||||
.sortedBy { it.id }
|
||||
.mapNotNull { record ->
|
||||
decode(record.privateKey)?.let { privateKey ->
|
||||
PrekeyBundle.Prekey(record.id, CourierNoiseCrypto.publicKey(privateKey))
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
if (prekeys.isEmpty()) return@synchronized null
|
||||
|
||||
val unsigned = PrekeyBundle(
|
||||
noiseStaticPublicKey = staticKey,
|
||||
prekeys = prekeys,
|
||||
generatedAt = state.generatedAt,
|
||||
signature = ByteArray(PrekeyBundle.SIGNATURE_LENGTH)
|
||||
)
|
||||
val signature = signEd25519(unsigned.signableBytes(), signingPrivateKey) ?: return@synchronized null
|
||||
unsigned.copy(signature = signature)
|
||||
}
|
||||
|
||||
fun verifyAndIngest(
|
||||
bundle: PrekeyBundle,
|
||||
expectedNoiseKey: ByteArray,
|
||||
announceBoundSigningKey: ByteArray,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): Boolean {
|
||||
if (!bundle.noiseStaticPublicKey.contentEquals(expectedNoiseKey) ||
|
||||
announceBoundSigningKey.size != PrekeyBundle.KEY_LENGTH ||
|
||||
!verifyEd25519(bundle.signature, bundle.signableBytes(), announceBoundSigningKey)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
synchronized(lock) {
|
||||
val bundles = loadPeerBundlesLocked()
|
||||
val key = encode(bundle.noiseStaticPublicKey)
|
||||
val existing = bundles[key]
|
||||
if (existing != null && existing.generatedAt >= bundle.generatedAt) return false
|
||||
|
||||
val freshIds = bundle.prekeys.map { it.id }.toSet()
|
||||
bundles[key] = StoredPeerPrekeyBundle(
|
||||
noiseKey = key,
|
||||
generatedAt = bundle.generatedAt,
|
||||
prekeyIds = bundle.prekeys.map { it.id },
|
||||
prekeyPublicKeys = bundle.prekeys.map { encode(it.publicKey) },
|
||||
usedIds = existing?.usedIds?.filterTo(mutableSetOf()) { it in freshIds } ?: mutableSetOf(),
|
||||
assignments = existing?.assignments
|
||||
?.filterValues { it in freshIds }
|
||||
?.toMutableMap() ?: mutableMapOf(),
|
||||
updatedAt = nowMs
|
||||
)
|
||||
while (bundles.size > MAX_PEERS) {
|
||||
bundles.minByOrNull { it.value.updatedAt }?.key?.let(bundles::remove)
|
||||
}
|
||||
persistPeerBundlesLocked(bundles)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fun seal(
|
||||
payload: ByteArray,
|
||||
messageId: String,
|
||||
recipientNoiseKey: ByteArray,
|
||||
recipientAdvertisesPrekeys: Boolean,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): Sealed {
|
||||
val senderPrivateKey = identity.staticKey()?.first
|
||||
?: throw IllegalStateException("Noise static identity is unavailable")
|
||||
val assigned = if (recipientAdvertisesPrekeys) {
|
||||
assignPrekey(messageId, recipientNoiseKey, nowMs)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return if (assigned != null) {
|
||||
Sealed(
|
||||
CourierNoiseCrypto.sealToPrekey(payload, senderPrivateKey, assigned),
|
||||
assigned.id
|
||||
)
|
||||
} else {
|
||||
Sealed(
|
||||
CourierNoiseCrypto.seal(payload, senderPrivateKey, recipientNoiseKey),
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun open(
|
||||
ciphertext: ByteArray,
|
||||
prekeyId: Long?,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): Opened {
|
||||
if (prekeyId == null) {
|
||||
val staticPrivateKey = identity.staticKey()?.first
|
||||
?: throw IllegalStateException("Noise static identity is unavailable")
|
||||
val opened = CourierNoiseCrypto.open(ciphertext, staticPrivateKey)
|
||||
return Opened(opened.payload, opened.senderStaticKey, false)
|
||||
}
|
||||
|
||||
synchronized(lock) {
|
||||
val state = loadLocalLocked()
|
||||
pruneLocked(state, nowMs)
|
||||
val record = state.records.firstOrNull { it.id == prekeyId }
|
||||
?: throw IllegalArgumentException("Unknown or expired courier prekey")
|
||||
val consumedAt = record.consumedAt
|
||||
if (consumedAt != null && nowMs - consumedAt > CONSUMED_GRACE_MS) {
|
||||
throw IllegalArgumentException("Courier prekey grace window expired")
|
||||
}
|
||||
val privateKey = decode(record.privateKey)
|
||||
?: throw IllegalArgumentException("Invalid courier prekey")
|
||||
val opened = CourierNoiseCrypto.openWithPrekey(ciphertext, privateKey, prekeyId)
|
||||
val newlyConsumed = record.consumedAt == null
|
||||
if (newlyConsumed) {
|
||||
record.consumedAt = nowMs
|
||||
advanceGeneratedAtLocked(state, nowMs)
|
||||
replenishLocked(state, nowMs)
|
||||
persistLocalLocked(state)
|
||||
}
|
||||
return Opened(opened.payload, opened.senderStaticKey, newlyConsumed)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasUsableBundle(
|
||||
recipientNoiseKey: ByteArray,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): Boolean = synchronized(lock) {
|
||||
val bundle = loadPeerBundlesLocked()[encode(recipientNoiseKey)] ?: return@synchronized false
|
||||
isFresh(bundle, nowMs) && bundle.prekeyIds.any { it !in bundle.usedIds }
|
||||
}
|
||||
|
||||
fun wipe() = synchronized(lock) {
|
||||
local = LocalPrekeyState()
|
||||
peerBundles = mutableMapOf()
|
||||
localStore.clear()
|
||||
peerStore.clear()
|
||||
}
|
||||
|
||||
private fun assignPrekey(
|
||||
messageId: String,
|
||||
recipientNoiseKey: ByteArray,
|
||||
nowMs: Long
|
||||
): PrekeyBundle.Prekey? = synchronized(lock) {
|
||||
val bundles = loadPeerBundlesLocked()
|
||||
val key = encode(recipientNoiseKey)
|
||||
val bundle = bundles[key] ?: return@synchronized null
|
||||
if (!isFresh(bundle, nowMs)) return@synchronized null
|
||||
|
||||
bundle.assignments[messageId]?.let { assigned ->
|
||||
val index = bundle.prekeyIds.indexOf(assigned)
|
||||
if (index >= 0) {
|
||||
return@synchronized decode(bundle.prekeyPublicKeys[index])
|
||||
?.let { PrekeyBundle.Prekey(assigned, it) }
|
||||
}
|
||||
}
|
||||
|
||||
val index = bundle.prekeyIds.indices
|
||||
.filter { bundle.prekeyIds[it] !in bundle.usedIds }
|
||||
.minByOrNull { bundle.prekeyIds[it] }
|
||||
?: return@synchronized null
|
||||
val id = bundle.prekeyIds[index]
|
||||
val publicKey = decode(bundle.prekeyPublicKeys[index]) ?: return@synchronized null
|
||||
bundle.usedIds += id
|
||||
bundle.assignments[messageId] = id
|
||||
bundle.updatedAt = nowMs
|
||||
persistPeerBundlesLocked(bundles)
|
||||
PrekeyBundle.Prekey(id, publicKey)
|
||||
}
|
||||
|
||||
private fun replenishLocked(state: LocalPrekeyState, nowMs: Long): Boolean {
|
||||
val beforeRecords = state.records.size
|
||||
val beforeUnconsumed = state.records.count { it.consumedAt == null }
|
||||
pruneLocked(state, nowMs)
|
||||
val unconsumed = state.records.count { it.consumedAt == null }
|
||||
var changed = unconsumed != beforeUnconsumed
|
||||
if (unconsumed < REPLENISH_THRESHOLD) {
|
||||
repeat(PrekeyBundle.MAX_PREKEYS - unconsumed) {
|
||||
val privateKey = randomBytes()
|
||||
require(privateKey.size == PrekeyBundle.KEY_LENGTH)
|
||||
state.records += LocalPrekeyRecord(
|
||||
id = state.nextId and 0xFFFF_FFFFL,
|
||||
privateKey = encode(privateKey),
|
||||
createdAt = nowMs
|
||||
)
|
||||
state.nextId = (state.nextId + 1) and 0xFFFF_FFFFL
|
||||
}
|
||||
advanceGeneratedAtLocked(state, nowMs)
|
||||
changed = true
|
||||
}
|
||||
if (changed || state.records.size != beforeRecords) persistLocalLocked(state)
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun pruneLocked(state: LocalPrekeyState, nowMs: Long) {
|
||||
state.records.removeAll { record ->
|
||||
record.consumedAt?.let { nowMs - it > CONSUMED_GRACE_MS }
|
||||
?: (nowMs - record.createdAt > UNCONSUMED_RETENTION_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun advanceGeneratedAtLocked(state: LocalPrekeyState, nowMs: Long) {
|
||||
state.generatedAt = maxOf(nowMs.coerceAtLeast(0), state.generatedAt + 1)
|
||||
}
|
||||
|
||||
private fun loadLocalLocked(): LocalPrekeyState {
|
||||
local?.let { return it }
|
||||
val loaded = localStore.load()
|
||||
local = loaded
|
||||
return loaded
|
||||
}
|
||||
|
||||
private fun persistLocalLocked(state: LocalPrekeyState) {
|
||||
try { localStore.save(state) } catch (error: Exception) {
|
||||
local = null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPeerBundlesLocked(): MutableMap<String, StoredPeerPrekeyBundle> {
|
||||
peerBundles?.let { return it }
|
||||
return peerStore.load().also { peerBundles = it }
|
||||
}
|
||||
|
||||
private fun persistPeerBundlesLocked(bundles: Map<String, StoredPeerPrekeyBundle>) {
|
||||
try { peerStore.save(bundles) } catch (error: Exception) {
|
||||
peerBundles = null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private fun isFresh(bundle: StoredPeerPrekeyBundle, nowMs: Long): Boolean =
|
||||
nowMs - bundle.generatedAt <= MAX_BUNDLE_AGE_MS
|
||||
|
||||
private fun signEd25519(data: ByteArray, privateKey: ByteArray): ByteArray? = runCatching {
|
||||
Ed25519Signer().apply {
|
||||
init(true, Ed25519PrivateKeyParameters(privateKey, 0))
|
||||
update(data, 0, data.size)
|
||||
}.generateSignature()
|
||||
}.getOrNull()
|
||||
|
||||
private fun verifyEd25519(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean =
|
||||
runCatching {
|
||||
Ed25519Signer().apply {
|
||||
init(false, Ed25519PublicKeyParameters(publicKey, 0))
|
||||
update(data, 0, data.size)
|
||||
}.verifySignature(signature)
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun encode(data: ByteArray): String =
|
||||
Base64.encodeToString(data, Base64.NO_WRAP)
|
||||
|
||||
private fun decode(value: String): ByteArray? =
|
||||
runCatching { Base64.decode(value, Base64.NO_WRAP) }.getOrNull()
|
||||
|
||||
companion object {
|
||||
private const val PEER_PREFS = "bitchat_prekey_bundles"
|
||||
private const val REPLENISH_THRESHOLD = 3
|
||||
private const val CONSUMED_GRACE_MS = 48L * 60 * 60 * 1000
|
||||
private const val UNCONSUMED_RETENTION_MS = 30L * 24 * 60 * 60 * 1000
|
||||
private const val MAX_BUNDLE_AGE_MS = 7L * 24 * 60 * 60 * 1000
|
||||
private const val MAX_PEERS = 200
|
||||
|
||||
@Volatile
|
||||
private var instance: PrekeyManager? = null
|
||||
|
||||
fun getInstance(context: Context): PrekeyManager =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: run {
|
||||
val application = context.applicationContext
|
||||
val identityState = SecureIdentityStateManager(application)
|
||||
val random = SecureRandom()
|
||||
PrekeyManager(
|
||||
identity = AndroidPrekeyIdentity(identityState),
|
||||
localStore = SecureLocalPrekeyStore(identityState),
|
||||
peerStore = SharedPreferencesPeerPrekeyStore(
|
||||
application.getSharedPreferences(PEER_PREFS, Context.MODE_PRIVATE)
|
||||
),
|
||||
randomBytes = {
|
||||
ByteArray(PrekeyBundle.KEY_LENGTH).also(random::nextBytes)
|
||||
}
|
||||
).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
internal data class LocalPrekeyRecord(
|
||||
val id: Long,
|
||||
val privateKey: String,
|
||||
val createdAt: Long,
|
||||
var consumedAt: Long? = null
|
||||
)
|
||||
|
||||
internal data class LocalPrekeyState(
|
||||
var records: MutableList<LocalPrekeyRecord> = mutableListOf(),
|
||||
var nextId: Long = 0,
|
||||
var generatedAt: Long = 0
|
||||
)
|
||||
|
||||
internal data class StoredPeerPrekeyBundle(
|
||||
val noiseKey: String,
|
||||
var generatedAt: Long,
|
||||
var prekeyIds: List<Long>,
|
||||
var prekeyPublicKeys: List<String>,
|
||||
var usedIds: MutableSet<Long>,
|
||||
var assignments: MutableMap<String, Long>,
|
||||
var updatedAt: Long
|
||||
)
|
||||
|
||||
internal interface PrekeyIdentity {
|
||||
fun staticKey(): Pair<ByteArray, ByteArray>?
|
||||
fun signingKey(): Pair<ByteArray, ByteArray>?
|
||||
}
|
||||
|
||||
internal interface LocalPrekeyStore {
|
||||
fun load(): LocalPrekeyState
|
||||
fun save(state: LocalPrekeyState)
|
||||
fun clear()
|
||||
}
|
||||
|
||||
internal interface PeerPrekeyStore {
|
||||
fun load(): MutableMap<String, StoredPeerPrekeyBundle>
|
||||
fun save(bundles: Map<String, StoredPeerPrekeyBundle>)
|
||||
fun clear()
|
||||
}
|
||||
|
||||
internal class AndroidPrekeyIdentity(
|
||||
private val state: SecureIdentityStateManager
|
||||
) : PrekeyIdentity {
|
||||
override fun staticKey(): Pair<ByteArray, ByteArray>? = state.loadStaticKey()
|
||||
override fun signingKey(): Pair<ByteArray, ByteArray>? = state.loadSigningKey()
|
||||
}
|
||||
|
||||
internal class SecureLocalPrekeyStore(
|
||||
private val state: SecureIdentityStateManager,
|
||||
private val gson: Gson = Gson()
|
||||
) : LocalPrekeyStore {
|
||||
override fun load(): LocalPrekeyState =
|
||||
runCatching {
|
||||
state.getSecureValue(LOCAL_STORE_KEY)
|
||||
?.let { gson.fromJson(it, LocalPrekeyState::class.java) }
|
||||
}.getOrNull() ?: LocalPrekeyState()
|
||||
|
||||
override fun save(state: LocalPrekeyState) {
|
||||
check(this.state.storeSecureValueSynchronously(LOCAL_STORE_KEY, gson.toJson(state))) {
|
||||
"Unable to persist courier prekeys"
|
||||
}
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
check(state.clearSecureValuesSynchronously(LOCAL_STORE_KEY))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "LocalPrekeyStore"
|
||||
const val LOCAL_STORE_KEY = "courier_prekeys_v1"
|
||||
}
|
||||
}
|
||||
|
||||
internal class SharedPreferencesPeerPrekeyStore(
|
||||
private val preferences: SharedPreferences,
|
||||
private val gson: Gson = Gson()
|
||||
) : PeerPrekeyStore {
|
||||
override fun load(): MutableMap<String, StoredPeerPrekeyBundle> {
|
||||
val type = object : TypeToken<List<StoredPeerPrekeyBundle>>() {}.type
|
||||
val values: List<StoredPeerPrekeyBundle> = runCatching {
|
||||
preferences.getString(PEER_BUNDLES_KEY, null)
|
||||
?.let { json -> gson.fromJson<List<StoredPeerPrekeyBundle>>(json, type) }
|
||||
}.getOrNull() ?: emptyList()
|
||||
return values
|
||||
.filter { it.prekeyIds.size == it.prekeyPublicKeys.size }
|
||||
.associateByTo(mutableMapOf()) { it.noiseKey }
|
||||
}
|
||||
|
||||
override fun save(bundles: Map<String, StoredPeerPrekeyBundle>) {
|
||||
check(preferences.edit().putString(PEER_BUNDLES_KEY, gson.toJson(bundles.values.toList())).commit())
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
check(preferences.edit().clear().commit())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PEER_BUNDLES_KEY = "bundles_v1"
|
||||
}
|
||||
}
|
||||
@ -76,6 +76,31 @@ class MeshGraphService private constructor() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a shortest path using only bidirectionally confirmed topology claims.
|
||||
*/
|
||||
fun computeRoute(fromPeerID: String, toPeerID: String): List<String>? {
|
||||
if (fromPeerID == toPeerID) return listOf(fromPeerID)
|
||||
val adjacency = mutableMapOf<String, MutableSet<String>>()
|
||||
graphState.value.edges.filter(GraphEdge::isConfirmed).forEach { edge ->
|
||||
adjacency.getOrPut(edge.a) { mutableSetOf() }.add(edge.b)
|
||||
adjacency.getOrPut(edge.b) { mutableSetOf() }.add(edge.a)
|
||||
}
|
||||
val queue = ArrayDeque<List<String>>()
|
||||
val visited = mutableSetOf(fromPeerID)
|
||||
queue.add(listOf(fromPeerID))
|
||||
while (queue.isNotEmpty()) {
|
||||
val path = queue.removeFirst()
|
||||
adjacency[path.last()].orEmpty().sorted().forEach { neighbor ->
|
||||
if (!visited.add(neighbor)) return@forEach
|
||||
val next = path + neighbor
|
||||
if (neighbor == toPeerID) return next
|
||||
queue.add(next)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun publishSnapshot() {
|
||||
// Collect all known nodes from nicknames and announcements
|
||||
val allNodes = mutableSetOf<String>()
|
||||
|
||||
@ -21,9 +21,12 @@ object GCSFilter {
|
||||
data class Params(
|
||||
val p: Int, // Golomb-Rice parameter (>= 1)
|
||||
val m: Long, // Range M = N * 2^P
|
||||
val data: ByteArray // Encoded GR bitstream
|
||||
val data: ByteArray, // Encoded GR bitstream
|
||||
val includedCount: Int // Number of newest-first input IDs actually encoded
|
||||
)
|
||||
|
||||
const val MAX_P = 32
|
||||
|
||||
// Derive P from target FPR; FPR ~= 1 / 2^P
|
||||
fun deriveP(targetFpr: Double): Int {
|
||||
val f = targetFpr.coerceIn(0.000001, 0.25)
|
||||
@ -66,7 +69,12 @@ object GCSFilter {
|
||||
encoded = encode(mapped, p)
|
||||
}
|
||||
|
||||
return Params(p = p, m = finalM, data = encoded)
|
||||
return Params(
|
||||
p = p,
|
||||
m = finalM,
|
||||
data = encoded,
|
||||
includedCount = if (encoded.isEmpty()) 0 else trimmedN
|
||||
)
|
||||
}
|
||||
|
||||
fun decodeToSortedSet(p: Int, m: Long, data: ByteArray): LongArray {
|
||||
@ -196,4 +204,3 @@ object GCSFilter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,9 @@ import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
/**
|
||||
* Gossip-based synchronization manager using on-demand GCS filters.
|
||||
@ -17,7 +20,8 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
class GossipSyncManager(
|
||||
private val myPeerID: String,
|
||||
private val scope: CoroutineScope,
|
||||
private val configProvider: ConfigProvider
|
||||
private val configProvider: ConfigProvider,
|
||||
context: Context? = null
|
||||
) {
|
||||
interface Delegate {
|
||||
fun sendPacket(packet: BitchatPacket)
|
||||
@ -33,8 +37,21 @@ class GossipSyncManager(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "GossipSyncManager"
|
||||
const val PUBLIC_MESSAGE_MAX_AGE_MS = 6 * 60 * 60 * 1000L
|
||||
const val FRAGMENT_MAX_AGE_MS = 15 * 60 * 1000L
|
||||
const val PUBLIC_PACKET_FUTURE_SKEW_MS = 10 * 60 * 1000L
|
||||
private const val ARCHIVE_FILE = "gossip-public-history.bin"
|
||||
}
|
||||
|
||||
var boardPacketsProvider: (() -> List<BitchatPacket>)? = null
|
||||
private val responseTimesByPeer = ConcurrentHashMap<String, ArrayDeque<Long>>()
|
||||
private val observedBoardSyncPeers = ConcurrentHashMap.newKeySet<String>()
|
||||
private fun peerSupportsBoard(peerID: String): Boolean =
|
||||
peerID.lowercase() in observedBoardSyncPeers ||
|
||||
latestAnnouncementByPeer[peerID.lowercase()]?.second?.payload?.let {
|
||||
com.bitchat.android.model.IdentityAnnouncement.decode(it)?.capabilities
|
||||
?.contains(com.bitchat.android.model.PeerCapabilities.BOARD)
|
||||
} == true
|
||||
var delegate: Delegate? = null
|
||||
|
||||
// Defaults (configurable constants)
|
||||
@ -44,11 +61,16 @@ class GossipSyncManager(
|
||||
// Stored packets for sync:
|
||||
// - broadcast messages: keep up to seenCapacity() most recent, keyed by packetId
|
||||
private val messages = LinkedHashMap<String, BitchatPacket>()
|
||||
private val groupMessages = LinkedHashMap<String, BitchatPacket>()
|
||||
private val fragments = LinkedHashMap<String, BitchatPacket>()
|
||||
private val archiveFile = context?.applicationContext?.filesDir?.let { File(it, ARCHIVE_FILE) }
|
||||
private var restoringArchive = false
|
||||
// - announcements: only keep latest per sender peerID
|
||||
private val latestAnnouncementByPeer = ConcurrentHashMap<String, Pair<String, BitchatPacket>>()
|
||||
|
||||
private var periodicJob: Job? = null
|
||||
private var cleanupJob: Job? = null
|
||||
init { restoreArchive() }
|
||||
fun start() {
|
||||
periodicJob?.cancel()
|
||||
periodicJob = scope.launch(Dispatchers.IO) {
|
||||
@ -84,7 +106,12 @@ class GossipSyncManager(
|
||||
synchronized(messages) {
|
||||
messages.clear()
|
||||
}
|
||||
synchronized(fragments) { fragments.clear() }
|
||||
synchronized(groupMessages) { groupMessages.clear() }
|
||||
latestAnnouncementByPeer.clear()
|
||||
responseTimesByPeer.clear()
|
||||
observedBoardSyncPeers.clear()
|
||||
archiveFile?.delete()
|
||||
Log.d(TAG, "Cleared all gossip sync messages and announcements")
|
||||
}
|
||||
|
||||
@ -106,13 +133,33 @@ class GossipSyncManager(
|
||||
// Only ANNOUNCE or broadcast MESSAGE
|
||||
val mt = MessageType.fromValue(packet.type)
|
||||
val isBroadcastMessage = (mt == MessageType.MESSAGE && (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)))
|
||||
val isGroupMessage = mt in setOf(MessageType.GROUP_MESSAGE, MessageType.PREKEY_BUNDLE) &&
|
||||
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
|
||||
val isBroadcastFile = mt == MessageType.FILE_TRANSFER &&
|
||||
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
|
||||
val isAnnouncement = (mt == MessageType.ANNOUNCE)
|
||||
if (!isBroadcastMessage && !isAnnouncement) return
|
||||
val isFragment = (mt == MessageType.FRAGMENT || isBroadcastFile) &&
|
||||
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
|
||||
if (!isBroadcastMessage && !isAnnouncement && !isFragment && !isGroupMessage) return
|
||||
|
||||
val idBytes = PacketIdUtil.computeIdBytes(packet)
|
||||
val id = idBytes.joinToString("") { b -> "%02x".format(b) }
|
||||
|
||||
if (isBroadcastMessage) {
|
||||
if (isGroupMessage) {
|
||||
val age = System.currentTimeMillis() - packet.timestamp.toLong()
|
||||
if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..PUBLIC_MESSAGE_MAX_AGE_MS) return
|
||||
synchronized(groupMessages) {
|
||||
val cacheKey = if (mt == MessageType.PREKEY_BUNDLE) "prekey-${packet.senderID.toHexString()}" else id
|
||||
groupMessages[cacheKey] = packet
|
||||
while (groupMessages.size > 200) {
|
||||
val iterator = groupMessages.entries.iterator()
|
||||
iterator.next(); iterator.remove()
|
||||
}
|
||||
}
|
||||
} else if (isBroadcastMessage) {
|
||||
val now = System.currentTimeMillis()
|
||||
val age = now - packet.timestamp.toLong()
|
||||
if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..PUBLIC_MESSAGE_MAX_AGE_MS) return
|
||||
synchronized(messages) {
|
||||
messages[id] = packet
|
||||
// Enforce capacity (remove oldest when exceeded)
|
||||
@ -122,6 +169,17 @@ class GossipSyncManager(
|
||||
if (it.hasNext()) { it.next(); it.remove() } else break
|
||||
}
|
||||
}
|
||||
if (!restoringArchive) persistArchive()
|
||||
} else if (isFragment) {
|
||||
val age = System.currentTimeMillis() - packet.timestamp.toLong()
|
||||
if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..FRAGMENT_MAX_AGE_MS) return
|
||||
synchronized(fragments) {
|
||||
fragments[id] = packet
|
||||
while (fragments.size > configProvider.seenCapacity().coerceAtLeast(1)) {
|
||||
val iterator = fragments.entries.iterator()
|
||||
if (iterator.hasNext()) { iterator.next(); iterator.remove() } else break
|
||||
}
|
||||
}
|
||||
} else if (isAnnouncement) {
|
||||
// Ignore stale announcements older than STALE_PEER_TIMEOUT
|
||||
val now = System.currentTimeMillis()
|
||||
@ -143,22 +201,35 @@ class GossipSyncManager(
|
||||
}
|
||||
|
||||
private fun sendRequestSync() {
|
||||
val payload = buildGcsPayload()
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.REQUEST_SYNC.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbors only
|
||||
)
|
||||
// Sign and broadcast
|
||||
val signed = delegate?.signPacketForBroadcast(packet) ?: packet
|
||||
delegate?.sendPacket(signed)
|
||||
listOf(
|
||||
SyncTypeFlags.PUBLIC_MESSAGES,
|
||||
SyncTypeFlags.FRAGMENT,
|
||||
SyncTypeFlags.FILE_TRANSFER,
|
||||
SyncTypeFlags.GROUP_MESSAGE.union(SyncTypeFlags.fromMessageTypes(MessageType.PREKEY_BUNDLE))
|
||||
).forEach { types ->
|
||||
val payload = buildGcsPayload(types)
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.REQUEST_SYNC.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbors only
|
||||
)
|
||||
val signed = delegate?.signPacketForBroadcast(packet) ?: packet
|
||||
delegate?.sendPacket(signed)
|
||||
}
|
||||
if (boardPacketsProvider != null) {
|
||||
latestAnnouncementByPeer.keys.filter(::peerSupportsBoard).forEach {
|
||||
sendRequestSyncToPeer(it, SyncTypeFlags.BOARD)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendRequestSyncToPeer(peerID: String) {
|
||||
val payload = buildGcsPayload()
|
||||
private fun sendRequestSyncToPeer(peerID: String, requestedTypes: SyncTypeFlags? = null) {
|
||||
val types = requestedTypes ?: SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES)
|
||||
.union(SyncTypeFlags.GROUP_MESSAGE).union(SyncTypeFlags.fromMessageTypes(MessageType.PREKEY_BUNDLE))
|
||||
.let { if (boardPacketsProvider != null && peerSupportsBoard(peerID)) it.union(SyncTypeFlags.BOARD) else it }
|
||||
val payload = buildGcsPayload(types)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.REQUEST_SYNC.value,
|
||||
@ -175,6 +246,12 @@ class GossipSyncManager(
|
||||
}
|
||||
|
||||
fun handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
||||
val requestedTypes = request.types ?: SyncTypeFlags.PUBLIC_MESSAGES
|
||||
if (requestedTypes.contains(MessageType.BOARD_POST) && latestAnnouncementByPeer.containsKey(fromPeerID.lowercase())) {
|
||||
observedBoardSyncPeers.add(fromPeerID.lowercase())
|
||||
}
|
||||
if (!shouldRespondTo(fromPeerID)) return
|
||||
val sinceTimestamp = request.sinceTimestamp
|
||||
// Decode GCS into sorted set for membership checks
|
||||
val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data)
|
||||
fun mightContain(id: ByteArray): Boolean {
|
||||
@ -183,26 +260,85 @@ class GossipSyncManager(
|
||||
return GCSFilter.contains(sorted, nonZeroV)
|
||||
}
|
||||
|
||||
// 1) Announcements: send latest per peerID if remote doesn't have them
|
||||
for ((_, pair) in latestAnnouncementByPeer.entries) {
|
||||
val (id, pkt) = pair
|
||||
val idBytes = hexToBytes(id)
|
||||
if (!mightContain(idBytes)) {
|
||||
// Send original packet unchanged to requester only (keep local TTL)
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
// Announcements are exempt from the cursor: only the latest per peer is retained,
|
||||
// and peers need their signing keys before they can verify other sync responses.
|
||||
if (requestedTypes.contains(MessageType.ANNOUNCE)) {
|
||||
for ((_, pair) in latestAnnouncementByPeer.entries) {
|
||||
val (id, pkt) = pair
|
||||
val idBytes = hexToBytes(id)
|
||||
if (!mightContain(idBytes)) {
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Broadcast messages: send all they lack
|
||||
val toSendMsgs = synchronized(messages) { messages.values.toList() }
|
||||
for (pkt in toSendMsgs) {
|
||||
if (requestedTypes.contains(MessageType.MESSAGE)) {
|
||||
val toSendMsgs = synchronized(messages) { messages.values.toList() }
|
||||
for (pkt in toSendMsgs) {
|
||||
if (sinceTimestamp != null && pkt.timestamp < sinceTimestamp) continue
|
||||
val idBytes = PacketIdUtil.computeIdBytes(pkt)
|
||||
if (!mightContain(idBytes)) {
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedTypes.contains(MessageType.GROUP_MESSAGE) || requestedTypes.contains(MessageType.PREKEY_BUNDLE)) {
|
||||
typedSyncCandidates(requestedTypes).forEach { packet ->
|
||||
if ((sinceTimestamp == null || packet.timestamp >= sinceTimestamp) &&
|
||||
!mightContain(PacketIdUtil.computeIdBytes(packet))) {
|
||||
delegate?.sendPacketToPeer(fromPeerID, packet.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (requestedTypes.contains(MessageType.BOARD_POST)) {
|
||||
boardPacketsProvider?.invoke().orEmpty().take(200).forEach { packet ->
|
||||
if ((sinceTimestamp == null || packet.timestamp >= sinceTimestamp) &&
|
||||
!mightContain(PacketIdUtil.computeIdBytes(packet))) {
|
||||
delegate?.sendPacketToPeer(fromPeerID, packet.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS))
|
||||
}
|
||||
}
|
||||
}
|
||||
val toSendFragments = synchronized(fragments) { fragments.values.toList() }
|
||||
for (pkt in toSendFragments) {
|
||||
val type = MessageType.fromValue(pkt.type) ?: continue
|
||||
if (!requestedTypes.contains(type)) continue
|
||||
if (sinceTimestamp != null && pkt.timestamp < sinceTimestamp) continue
|
||||
val idBytes = PacketIdUtil.computeIdBytes(pkt)
|
||||
if (!mightContain(idBytes)) {
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
delegate?.sendPacketToPeer(fromPeerID, pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun typedSyncCandidates(types: SyncTypeFlags): List<BitchatPacket> = synchronized(groupMessages) {
|
||||
val now = System.currentTimeMillis()
|
||||
groupMessages.entries.removeAll { now - it.value.timestamp.toLong() > PUBLIC_MESSAGE_MAX_AGE_MS }
|
||||
groupMessages.values.filter { packet ->
|
||||
MessageType.fromValue(packet.type)?.let(types::contains) == true
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldRespondTo(peerID: String): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
responseTimesByPeer.entries.removeIf { (_, times) ->
|
||||
synchronized(times) { times.isEmpty() || now - times.last() >= 30_000L }
|
||||
}
|
||||
if (responseTimesByPeer.size >= 256 && !responseTimesByPeer.containsKey(peerID)) return false
|
||||
val times = responseTimesByPeer.computeIfAbsent(peerID) { ArrayDeque() }
|
||||
return synchronized(times) {
|
||||
while (times.isNotEmpty() && now - times.first() >= 30_000L) {
|
||||
times.removeFirst()
|
||||
}
|
||||
if (times.size >= 8) {
|
||||
false
|
||||
} else {
|
||||
times.addLast(now)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -232,16 +368,25 @@ class GossipSyncManager(
|
||||
return out
|
||||
}
|
||||
|
||||
private fun buildGcsPayload(): ByteArray {
|
||||
// Collect candidates: latest announcement per peer + recent broadcast messages
|
||||
internal fun buildGcsPayload(types: SyncTypeFlags): ByteArray {
|
||||
// Collect only the packet types represented by this filter.
|
||||
val list = ArrayList<BitchatPacket>()
|
||||
// announcements
|
||||
for ((_, pair) in latestAnnouncementByPeer) {
|
||||
list.add(pair.second)
|
||||
if (types.contains(MessageType.BOARD_POST)) list.addAll(boardPacketsProvider?.invoke().orEmpty().take(200))
|
||||
list.addAll(typedSyncCandidates(types))
|
||||
if (types.contains(MessageType.ANNOUNCE)) {
|
||||
for ((_, pair) in latestAnnouncementByPeer) {
|
||||
list.add(pair.second)
|
||||
}
|
||||
}
|
||||
// messages
|
||||
synchronized(messages) {
|
||||
list.addAll(messages.values)
|
||||
if (types.contains(MessageType.MESSAGE)) {
|
||||
synchronized(messages) {
|
||||
list.addAll(messages.values)
|
||||
}
|
||||
}
|
||||
synchronized(fragments) {
|
||||
list.addAll(fragments.values.filter { packet ->
|
||||
MessageType.fromValue(packet.type)?.let(types::contains) == true
|
||||
})
|
||||
}
|
||||
// sort by timestamp desc, then take up to min(seenCapacity, fit capacity)
|
||||
list.sortByDescending { it.timestamp.toLong() }
|
||||
@ -254,15 +399,24 @@ class GossipSyncManager(
|
||||
val takeN = minOf(nMax, cap, list.size)
|
||||
if (takeN <= 0) {
|
||||
val p0 = GCSFilter.deriveP(fpr)
|
||||
return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0)).encode()
|
||||
return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0), types = types).encode()
|
||||
}
|
||||
val ids = list.take(takeN).map { pkt -> PacketIdUtil.computeIdBytes(pkt) }
|
||||
val included = list.take(takeN)
|
||||
val ids = included.map { pkt -> PacketIdUtil.computeIdBytes(pkt) }
|
||||
val params = GCSFilter.buildFilter(ids, maxBytes, fpr)
|
||||
val mVal = if (params.m <= 0L) 1 else params.m
|
||||
return RequestSyncPacket(p = params.p, m = mVal, data = params.data).encode()
|
||||
val covered = params.includedCount
|
||||
val sinceTimestamp = if (covered in 1 until list.size) included[covered - 1].timestamp else null
|
||||
return RequestSyncPacket(
|
||||
p = params.p,
|
||||
m = mVal,
|
||||
data = params.data,
|
||||
types = types,
|
||||
sinceTimestamp = sinceTimestamp
|
||||
).encode()
|
||||
}
|
||||
|
||||
// Periodically remove stale announcements and all their messages
|
||||
// Announcements age out quickly; public history remains independently sync-able for six hours.
|
||||
private fun pruneStaleAnnouncements() {
|
||||
val now = System.currentTimeMillis()
|
||||
val stalePeers = mutableListOf<String>()
|
||||
@ -276,26 +430,17 @@ class GossipSyncManager(
|
||||
}
|
||||
}
|
||||
|
||||
if (stalePeers.isEmpty()) return
|
||||
|
||||
// Remove announcements and their messages
|
||||
var totalPrunedMsgs = 0
|
||||
var changed = false
|
||||
for (peerID in stalePeers) {
|
||||
// Count messages to be pruned for logging
|
||||
val toRemove = mutableListOf<String>()
|
||||
synchronized(messages) {
|
||||
for ((id, message) in messages) {
|
||||
val sender = message.senderID.joinToString("") { b -> "%02x".format(b) }
|
||||
if (sender == peerID) toRemove.add(id)
|
||||
}
|
||||
}
|
||||
totalPrunedMsgs += toRemove.size
|
||||
|
||||
// Reuse existing removal which also clears announcement entry
|
||||
removeAnnouncementForPeer(peerID)
|
||||
changed = latestAnnouncementByPeer.remove(peerID) != null || changed
|
||||
}
|
||||
|
||||
Log.d(TAG, "Pruned ${stalePeers.size} stale announcements and $totalPrunedMsgs messages")
|
||||
synchronized(messages) {
|
||||
changed = messages.entries.removeAll { now - it.value.timestamp.toLong() > PUBLIC_MESSAGE_MAX_AGE_MS } || changed
|
||||
}
|
||||
synchronized(fragments) {
|
||||
fragments.entries.removeAll { now - it.value.timestamp.toLong() > FRAGMENT_MAX_AGE_MS }
|
||||
}
|
||||
if (changed) persistArchive()
|
||||
}
|
||||
|
||||
// Explicitly remove stored announcement for a given peer (hex ID)
|
||||
@ -305,26 +450,66 @@ class GossipSyncManager(
|
||||
Log.d(TAG, "Removed stored announcement for peer $peerID")
|
||||
}
|
||||
|
||||
// Collect IDs to remove first to avoid modifying collection while iterating
|
||||
val idsToRemove = mutableListOf<String>()
|
||||
synchronized(messages) {
|
||||
for ((id, message) in messages) {
|
||||
val sender = message.senderID.joinToString("") { b -> "%02x".format(b) }
|
||||
if (sender == key) {
|
||||
idsToRemove.add(id)
|
||||
}
|
||||
|
||||
private fun restoreArchive() {
|
||||
val file = archiveFile ?: return
|
||||
if (!file.exists()) return
|
||||
try {
|
||||
restoringArchive = true
|
||||
java.io.DataInputStream(file.inputStream().buffered()).use { input ->
|
||||
val count = input.readInt()
|
||||
require(count in 0..configProvider.seenCapacity())
|
||||
repeat(count) {
|
||||
val length = input.readInt()
|
||||
require(length in 1..(com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + 256))
|
||||
val bytes = ByteArray(length)
|
||||
input.readFully(bytes)
|
||||
val packet = com.bitchat.android.protocol.BinaryProtocol.decode(bytes) ?: return@repeat
|
||||
onPublicPacketSeen(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now remove the collected IDs
|
||||
synchronized(messages) {
|
||||
for (id in idsToRemove) {
|
||||
messages.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (idsToRemove.isNotEmpty()) {
|
||||
Log.d(TAG, "Pruned ${idsToRemove.size} messages with senders without announcements")
|
||||
} catch (_: Exception) {
|
||||
synchronized(messages) { messages.clear() }
|
||||
} finally {
|
||||
restoringArchive = false
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun persistArchive() {
|
||||
val file = archiveFile ?: return
|
||||
try {
|
||||
val packets = synchronized(messages) { messages.values.toList() }
|
||||
val temporary = File(file.parentFile, "${file.name}.tmp")
|
||||
java.io.DataOutputStream(temporary.outputStream().buffered()).use { output ->
|
||||
output.writeInt(packets.size)
|
||||
packets.forEach { packet ->
|
||||
val encoded = com.bitchat.android.protocol.BinaryProtocol.encode(packet, padding = false) ?: return@forEach
|
||||
output.writeInt(encoded.size)
|
||||
output.write(encoded)
|
||||
}
|
||||
}
|
||||
try {
|
||||
java.nio.file.Files.move(
|
||||
temporary.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// Some Android filesystems do not support ATOMIC_MOVE. Preserve the durable
|
||||
// public-history guarantee with a regular replacement move before giving up.
|
||||
try {
|
||||
java.nio.file.Files.move(
|
||||
temporary.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
temporary.delete()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
80
app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt
Normal file
80
app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt
Normal file
@ -0,0 +1,80 @@
|
||||
package com.bitchat.android.sync
|
||||
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
|
||||
/** Compact little-endian bitfield matching iOS SyncTypeFlags. */
|
||||
@JvmInline
|
||||
value class SyncTypeFlags private constructor(val rawValue: ULong) {
|
||||
companion object {
|
||||
private const val KNOWN_TYPE_MASK: ULong = 0x7ffu
|
||||
|
||||
val BOARD = fromMessageTypes(MessageType.BOARD_POST)
|
||||
val GROUP_MESSAGE = fromMessageTypes(MessageType.GROUP_MESSAGE)
|
||||
fun of(vararg types: MessageType) = fromMessageTypes(*types)
|
||||
|
||||
val ANNOUNCE = fromMessageTypes(MessageType.ANNOUNCE)
|
||||
val MESSAGE = fromMessageTypes(MessageType.MESSAGE)
|
||||
val FRAGMENT = fromMessageTypes(MessageType.FRAGMENT)
|
||||
val FILE_TRANSFER = fromMessageTypes(MessageType.FILE_TRANSFER)
|
||||
val PUBLIC_MESSAGES = fromMessageTypes(MessageType.ANNOUNCE, MessageType.MESSAGE)
|
||||
val FRAGMENTS_AND_FILES = fromMessageTypes(MessageType.FRAGMENT, MessageType.FILE_TRANSFER)
|
||||
|
||||
fun fromRawValue(rawValue: ULong): SyncTypeFlags = SyncTypeFlags(rawValue and KNOWN_TYPE_MASK)
|
||||
|
||||
fun fromMessageTypes(vararg types: MessageType): SyncTypeFlags {
|
||||
var rawValue = 0uL
|
||||
types.forEach { type ->
|
||||
bitIndex(type)?.let { bit -> rawValue = rawValue or (1uL shl bit) }
|
||||
}
|
||||
return fromRawValue(rawValue)
|
||||
}
|
||||
|
||||
fun decode(data: ByteArray): SyncTypeFlags? {
|
||||
if (data.size !in 1..8) return null
|
||||
var rawValue = 0uL
|
||||
data.forEachIndexed { index, byte ->
|
||||
rawValue = rawValue or (byte.toUByte().toULong() shl (index * 8))
|
||||
}
|
||||
return fromRawValue(rawValue)
|
||||
}
|
||||
|
||||
private fun bitIndex(type: MessageType): Int? = when (type) {
|
||||
MessageType.ANNOUNCE -> 0
|
||||
MessageType.MESSAGE -> 1
|
||||
MessageType.LEAVE -> 2
|
||||
MessageType.NOISE_HANDSHAKE -> 3
|
||||
MessageType.NOISE_ENCRYPTED -> 4
|
||||
MessageType.FRAGMENT -> 5
|
||||
MessageType.REQUEST_SYNC -> 6
|
||||
MessageType.FILE_TRANSFER -> 7
|
||||
MessageType.BOARD_POST -> 8
|
||||
MessageType.PREKEY_BUNDLE -> 9
|
||||
MessageType.GROUP_MESSAGE -> 10
|
||||
MessageType.PING,
|
||||
MessageType.PONG,
|
||||
MessageType.NOSTR_CARRIER,
|
||||
MessageType.COURIER_ENVELOPE,
|
||||
MessageType.VOICE_FRAME -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun contains(type: MessageType): Boolean {
|
||||
val bit = bitIndex(type) ?: return false
|
||||
return (rawValue and (1uL shl bit)) != 0uL
|
||||
}
|
||||
|
||||
fun union(other: SyncTypeFlags): SyncTypeFlags = fromRawValue(rawValue or other.rawValue)
|
||||
|
||||
fun encoded(): ByteArray? = encode()
|
||||
|
||||
fun encode(): ByteArray? {
|
||||
if (rawValue == 0uL) return null
|
||||
var remaining = rawValue
|
||||
val bytes = ArrayList<Byte>(8)
|
||||
while (remaining != 0uL && bytes.size < 8) {
|
||||
bytes += (remaining and 0xffu).toByte()
|
||||
remaining = remaining shr 8
|
||||
}
|
||||
return bytes.toByteArray()
|
||||
}
|
||||
}
|
||||
@ -303,11 +303,14 @@ private fun SettingsToggleRow(
|
||||
fun AboutSheet(
|
||||
isPresented: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
bridgeEnabled: Boolean,
|
||||
onBridgeEnabledChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onShowDebug: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
onShowMeshTopology: (() -> Unit)? = null
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
|
||||
// Get version name from package info
|
||||
val versionName = remember {
|
||||
try {
|
||||
@ -445,6 +448,8 @@ fun AboutSheet(
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "client_privacy") { ClientSettingsSection() }
|
||||
|
||||
item(key = "language") {
|
||||
val selectedLanguageName = supportedLanguages
|
||||
.firstOrNull { it.languageTag == selectedLanguageTag }
|
||||
@ -544,6 +549,19 @@ fun AboutSheet(
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 56.dp),
|
||||
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||
)
|
||||
|
||||
SettingsToggleRow(
|
||||
icon = Icons.Filled.Public,
|
||||
title = stringResource(R.string.mesh_bridge_title),
|
||||
subtitle = stringResource(R.string.mesh_bridge_description),
|
||||
checked = bridgeEnabled,
|
||||
onCheckedChange = onBridgeEnabledChange
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 54.dp),
|
||||
thickness = 1.dp,
|
||||
@ -1256,6 +1274,16 @@ fun AboutSheet(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onShowMeshTopology != null) {
|
||||
TextButton(onClick = onShowMeshTopology) {
|
||||
Text(
|
||||
text = "network → mesh topology",
|
||||
fontSize = 13.sp,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.about_footer),
|
||||
fontSize = 11.sp,
|
||||
@ -1303,7 +1331,7 @@ fun PasswordPromptDialog(
|
||||
) {
|
||||
if (show && channelName != null) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
@ -1321,7 +1349,7 @@ fun PasswordPromptDialog(
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
|
||||
OutlinedTextField(
|
||||
value = passwordInput,
|
||||
onValueChange = onPasswordChange,
|
||||
|
||||
@ -355,9 +355,9 @@ fun NoiseSessionIcon(
|
||||
)
|
||||
}
|
||||
|
||||
// Closed once the handshake resolves (success or failure); open while idle or in flight.
|
||||
// A closed lock only represents an established encrypted session.
|
||||
val lockIconRes = when {
|
||||
sessionState == "established" || sessionState?.startsWith("failed") == true ->
|
||||
sessionState == "established" ->
|
||||
R.drawable.ic_spec_lock
|
||||
else -> R.drawable.ic_spec_lock_open
|
||||
}
|
||||
@ -564,6 +564,7 @@ fun NicknameEditor(
|
||||
@Composable
|
||||
fun PeerCounter(
|
||||
connectedPeers: List<String>,
|
||||
bridgedPeopleCount: Int = 0,
|
||||
joinedChannels: Set<String>,
|
||||
hasUnreadChannels: Map<String, Int>,
|
||||
isConnected: Boolean,
|
||||
@ -586,8 +587,8 @@ fun PeerCounter(
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh,
|
||||
null -> {
|
||||
// Mesh channel: show Bluetooth-connected peers (excluding self)
|
||||
val count = connectedPeers.size
|
||||
Pair(count, if (isConnected && count > 0) colorScheme.secondary else palette.textTertiary)
|
||||
val count = connectedPeers.size + bridgedPeopleCount
|
||||
Pair(count, if ((isConnected || bridgedPeopleCount > 0) && count > 0) colorScheme.secondary else palette.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
@ -727,6 +728,7 @@ private fun MainHeader(
|
||||
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val crowdingMode = headerCrowdingMode(maxWidth)
|
||||
@ -809,6 +811,7 @@ private fun MainHeader(
|
||||
|
||||
PeerCounter(
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
|
||||
bridgedPeopleCount = bridgeUiState.participants.size,
|
||||
joinedChannels = joinedChannels,
|
||||
hasUnreadChannels = hasUnreadChannels,
|
||||
isConnected = isConnected,
|
||||
|
||||
@ -83,6 +83,10 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
|
||||
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
|
||||
val legacyPrivateMediaConsent by viewModel.legacyPrivateMediaConsent.collectAsStateWithLifecycle()
|
||||
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
|
||||
|
||||
val panicWipeState by viewModel.panicWipeState.collectAsStateWithLifecycle()
|
||||
PanicWipeDialog(panicWipeState, viewModel::panicClearAllData, viewModel::dismissPanicClear)
|
||||
|
||||
var messageText by remember { mutableStateOf(TextFieldValue("")) }
|
||||
var showPasswordPrompt by remember { mutableStateOf(false) }
|
||||
@ -107,6 +111,15 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
)
|
||||
}
|
||||
|
||||
val sharedDraft by viewModel.sharedDraft.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(sharedDraft) {
|
||||
sharedDraft?.let { shared ->
|
||||
val combined = listOf(messageText.text, shared).filter { it.isNotBlank() }.joinToString("\n")
|
||||
messageText = TextFieldValue(combined, androidx.compose.ui.text.TextRange(combined.length))
|
||||
viewModel.consumeSharedText()
|
||||
}
|
||||
}
|
||||
|
||||
// Show password dialog when needed
|
||||
LaunchedEffect(showPasswordPrompt) {
|
||||
showPasswordDialog = showPasswordPrompt
|
||||
@ -307,7 +320,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
conversationKey = conversationKey,
|
||||
contentPadding = PaddingValues(
|
||||
top = statusBarHeight + headerHeight +
|
||||
(if (showNotesStrip) notesStripHeight else 0.dp),
|
||||
notesStripHeight,
|
||||
bottom = composerHeight
|
||||
),
|
||||
forceScrollToBottom = forceScrollToBottom,
|
||||
@ -361,16 +374,13 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
)
|
||||
|
||||
if (showNotesStrip) {
|
||||
NearbyNotesStrip(
|
||||
Column(modifier = Modifier.align(Alignment.TopCenter)
|
||||
.padding(top = statusBarHeight + headerHeight)
|
||||
.onSizeChanged { notesStripHeight = with(density) { it.height.toDp() } }) {
|
||||
ConnectivityBanner()
|
||||
if (showNotesStrip) NearbyNotesStrip(
|
||||
noteCount = nearbyNotes.size,
|
||||
onClick = { showLocationNotesSheet = true },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = statusBarHeight + headerHeight)
|
||||
.onSizeChanged { size ->
|
||||
notesStripHeight = with(density) { size.height.toDp() }
|
||||
},
|
||||
onClick = { showLocationNotesSheet = true }
|
||||
)
|
||||
}
|
||||
|
||||
@ -443,7 +453,12 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
showMediaButtons = showMediaButtons
|
||||
showMediaButtons = showMediaButtons,
|
||||
showBridgeControls = bridgeUiState.enabled &&
|
||||
currentChannel == null &&
|
||||
selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location,
|
||||
nearbyOnly = bridgeUiState.nearbyOnly,
|
||||
onNearbyOnlyChange = viewModel::setBridgeNearbyOnly
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -457,7 +472,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
colorScheme = colorScheme,
|
||||
onSidebarToggle = { viewModel.showMeshPeerList() },
|
||||
onShowAppInfo = { viewModel.showAppInfo() },
|
||||
onPanicClear = { viewModel.panicClearAllData() },
|
||||
onPanicClear = { viewModel.requestPanicClear() },
|
||||
onLocationChannelsClick = { showLocationChannelsSheet = true },
|
||||
onLocationNotesClick = {
|
||||
nearbyNotesController.reveal()
|
||||
@ -645,7 +660,10 @@ fun ChatInputSection(
|
||||
colorScheme: ColorScheme,
|
||||
showMediaButtons: Boolean,
|
||||
recorderFactory: ((String?, String?) -> com.bitchat.android.features.voice.VoiceRecorder)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
showBridgeControls: Boolean = false,
|
||||
nearbyOnly: Boolean = false,
|
||||
onNearbyOnlyChange: (Boolean) -> Unit = {}
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val activePublicTalker by remember(context) {
|
||||
@ -726,6 +744,9 @@ fun ChatInputSection(
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
showMediaButtons = showMediaButtons,
|
||||
showBridgeControls = showBridgeControls,
|
||||
nearbyOnly = nearbyOnly,
|
||||
onNearbyOnlyChange = onNearbyOnlyChange,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
recorderFactory = recorderFactory,
|
||||
activePublicTalker = activePublicTalker,
|
||||
@ -840,6 +861,7 @@ private fun ChatDialogs(
|
||||
onMeshPeerListDismiss: () -> Unit,
|
||||
) {
|
||||
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
|
||||
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
|
||||
|
||||
// Password dialog
|
||||
PasswordPromptDialog(
|
||||
@ -853,10 +875,14 @@ private fun ChatDialogs(
|
||||
|
||||
// About sheet
|
||||
var showDebugSheet by remember { mutableStateOf(false) }
|
||||
var showMeshTopology by remember { mutableStateOf(false) }
|
||||
AboutSheet(
|
||||
isPresented = showAppInfo,
|
||||
onDismiss = onAppInfoDismiss,
|
||||
onShowDebug = { showDebugSheet = true }
|
||||
onShowDebug = { showDebugSheet = true },
|
||||
bridgeEnabled = bridgeUiState.enabled,
|
||||
onBridgeEnabledChange = viewModel::setBridgeEnabled,
|
||||
onShowMeshTopology = { showMeshTopology = true },
|
||||
)
|
||||
if (showDebugSheet) {
|
||||
com.bitchat.android.ui.debug.DebugSettingsSheet(
|
||||
@ -865,6 +891,11 @@ private fun ChatDialogs(
|
||||
meshService = viewModel.meshService
|
||||
)
|
||||
}
|
||||
MeshTopologySheet(
|
||||
isPresented = showMeshTopology,
|
||||
onDismiss = { showMeshTopology = false },
|
||||
meshService = viewModel.meshServiceFacade,
|
||||
)
|
||||
|
||||
// Location channels sheet
|
||||
if (showLocationChannelsSheet) {
|
||||
@ -876,9 +907,9 @@ private fun ChatDialogs(
|
||||
)
|
||||
}
|
||||
|
||||
// Location notes sheet (extracted to separate presenter)
|
||||
// Unified mesh and geohash notices sheet.
|
||||
if (showLocationNotesSheet) {
|
||||
LocationNotesSheetPresenter(
|
||||
NoticesSheetPresenter(
|
||||
viewModel = viewModel,
|
||||
onDismiss = onLocationNotesSheetDismiss
|
||||
)
|
||||
|
||||
@ -2,6 +2,7 @@ package com.bitchat.android.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.bitchat.android.R
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@ -39,10 +40,25 @@ import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import com.bitchat.android.services.bridge.BridgeUiState
|
||||
import com.bitchat.android.services.bridge.MeshBridgeService
|
||||
import com.bitchat.android.features.voice.LiveVoicePreferences
|
||||
import com.bitchat.android.features.voice.LiveVoiceTarget
|
||||
import com.bitchat.android.features.voice.VoiceRecorder
|
||||
|
||||
import com.bitchat.android.groups.BitchatGroup
|
||||
import com.bitchat.android.groups.GroupCommandResult
|
||||
import com.bitchat.android.groups.GroupCoordinator
|
||||
import com.bitchat.android.groups.GroupCoordinatorContext
|
||||
import com.bitchat.android.groups.GroupIds
|
||||
import com.bitchat.android.groups.GroupPeerIdentity
|
||||
import com.bitchat.android.groups.GroupStore
|
||||
import com.bitchat.android.groups.PeerGroupCapability
|
||||
|
||||
import com.bitchat.android.board.BoardManager
|
||||
import com.bitchat.android.board.BoardSigningIdentity
|
||||
import com.bitchat.android.board.BoardStore
|
||||
|
||||
private data class ConversationLiveIdentityState(
|
||||
val connectedPeerIDs: List<String>,
|
||||
val peerNicknames: Map<String, String>,
|
||||
@ -70,6 +86,9 @@ class ChatViewModel(
|
||||
companion object {
|
||||
private const val TAG = "ChatViewModel"
|
||||
private const val CONVERSATION_DISCONNECT_GRACE_MS = 3_000L
|
||||
private const val GROUP_COMMAND_USAGE =
|
||||
"usage: /group create <name> · invite @name[#identity] · " +
|
||||
"remove @name[#identity] · leave · list"
|
||||
}
|
||||
|
||||
fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
|
||||
@ -151,6 +170,10 @@ class ChatViewModel(
|
||||
.select(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||
}
|
||||
)
|
||||
private val groupRuntime = com.bitchat.android.groups.GroupRuntime.getInstance(application)
|
||||
private val groupStore = groupRuntime.store
|
||||
private val groupCoordinator = groupRuntime.coordinator
|
||||
val groups: StateFlow<List<BitchatGroup>> = groupStore.groups
|
||||
|
||||
// Create Noise session delegate for clean dependency injection
|
||||
private val noiseSessionDelegate = object : NoiseSessionDelegate {
|
||||
@ -178,11 +201,87 @@ class ChatViewModel(
|
||||
privateChatManager,
|
||||
viewModelScope
|
||||
)
|
||||
val boardManager = BoardManager(
|
||||
store = BoardStore.getInstance(application.applicationContext),
|
||||
scope = viewModelScope,
|
||||
meshProvider = { mesh },
|
||||
geoIdentityProvider = { geohash ->
|
||||
runCatching {
|
||||
NostrIdentityBridge.deriveIdentity(
|
||||
forGeohash = geohash,
|
||||
context = application.applicationContext
|
||||
)
|
||||
}.getOrNull()?.let { identity ->
|
||||
BoardSigningIdentity.fromNostrPrivateKeyHex(identity.privateKeyHex)
|
||||
}
|
||||
},
|
||||
onUrgentPosts = { geohash, posts ->
|
||||
val text = if (posts.size == 1) {
|
||||
val post = posts.single()
|
||||
application.getString(
|
||||
R.string.notices_alert_urgent_single,
|
||||
post.authorNickname.trim().ifEmpty { "anon" },
|
||||
post.content.truncateNoticeAlert()
|
||||
)
|
||||
} else {
|
||||
application.getString(
|
||||
R.string.notices_alert_urgent_collapsed,
|
||||
posts.size
|
||||
)
|
||||
}
|
||||
if (geohash.isEmpty()) {
|
||||
messageManager.addSystemMessage(text)
|
||||
} else {
|
||||
messageManager.addChannelMessage(
|
||||
"geo:$geohash",
|
||||
BitchatMessage(
|
||||
sender = "system",
|
||||
content = text,
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
private val notificationManager = NotificationManager(
|
||||
application.applicationContext,
|
||||
NotificationManagerCompat.from(application.applicationContext)
|
||||
)
|
||||
|
||||
private val groupContext = object : com.bitchat.android.groups.GroupUiDelegate {
|
||||
override val nickname get() = state.getNicknameValue()
|
||||
override fun markGroupUnread(groupPeerID: String) {
|
||||
state.setUnreadPrivateMessages(state.getUnreadPrivateMessagesValue() + groupPeerID)
|
||||
}
|
||||
override fun openGroupConversation(groupPeerID: String) {
|
||||
privateChatManager.startPrivateChat(groupPeerID, mesh)
|
||||
showPrivateChatSheet(groupPeerID)
|
||||
}
|
||||
override fun closeGroupConversation() { endPrivateChat() }
|
||||
}
|
||||
|
||||
fun executeGroupCommand(arguments: List<String>, onResult: (GroupCommandResult) -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val result = handleGroupCommand(arguments)
|
||||
kotlinx.coroutines.withContext(Dispatchers.Main) { onResult(result) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGroupCommand(arguments: List<String>): GroupCommandResult {
|
||||
val subcommand = arguments.firstOrNull()?.lowercase()
|
||||
?: return GroupCommandResult(false, GROUP_COMMAND_USAGE)
|
||||
val value = arguments.drop(1).joinToString(" ")
|
||||
return when (subcommand) {
|
||||
"create" -> groupCoordinator.createGroup(value)
|
||||
"invite" -> groupCoordinator.inviteMember(value)
|
||||
"remove" -> groupCoordinator.removeMember(value)
|
||||
"leave" -> groupCoordinator.leaveGroup()
|
||||
"list" -> groupCoordinator.listGroups()
|
||||
else -> GroupCommandResult(false, GROUP_COMMAND_USAGE)
|
||||
}
|
||||
}
|
||||
|
||||
private val verificationHandler = VerificationHandler(
|
||||
context = application.applicationContext,
|
||||
scope = viewModelScope,
|
||||
@ -193,6 +292,7 @@ class ChatViewModel(
|
||||
messageManager = messageManager
|
||||
)
|
||||
val verifiedFingerprints = verificationHandler.verifiedFingerprints
|
||||
val vouchedFingerprints = verificationHandler.vouchedFingerprints
|
||||
|
||||
// Media file sending manager
|
||||
private val mediaSendingManager = MediaSendingManager(
|
||||
@ -410,6 +510,21 @@ class ChatViewModel(
|
||||
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
|
||||
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
|
||||
val bridgeUiState: StateFlow<BridgeUiState> = combine(
|
||||
MeshBridgeService.isEnabled,
|
||||
MeshBridgeService.nearbyOnly,
|
||||
MeshBridgeService.bridgedParticipants
|
||||
) { enabled, nearbyOnly, participants ->
|
||||
BridgeUiState(enabled, nearbyOnly, participants)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = BridgeUiState(
|
||||
enabled = MeshBridgeService.isEnabled.value,
|
||||
nearbyOnly = MeshBridgeService.nearbyOnly.value,
|
||||
participants = MeshBridgeService.bridgedParticipants.value
|
||||
)
|
||||
)
|
||||
val meshServiceFacade: MeshService
|
||||
get() = mesh
|
||||
val myPeerID: String
|
||||
@ -425,6 +540,7 @@ class ChatViewModel(
|
||||
|
||||
init {
|
||||
observeConversationPresenceWithDisconnectGrace()
|
||||
groupRuntime.attach(groupContext)
|
||||
// Note: Mesh service delegate is now set by MainActivity
|
||||
loadAndInitialize()
|
||||
ContactDirectory.initialize(getApplication()) { mesh }
|
||||
@ -646,6 +762,7 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
groupRuntime.detach(groupContext)
|
||||
if (favoriteRelationshipListenerRegistered) {
|
||||
runCatching {
|
||||
FavoritesPersistenceService.shared.removeListener(
|
||||
@ -976,6 +1093,14 @@ class ChatViewModel(
|
||||
val currentChannelValue = state.getCurrentChannelValue()
|
||||
|
||||
if (selectedPeer != null) {
|
||||
if (GroupIds.isGroup(selectedPeer)) {
|
||||
val groupPeer = selectedPeer
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val accepted = groupCoordinator.sendMessage(content, groupPeer)
|
||||
kotlinx.coroutines.withContext(Dispatchers.Main) { onAccepted(accepted) }
|
||||
}
|
||||
return
|
||||
}
|
||||
// If the selected peer is a temporary Nostr alias or a noise-hex identity, resolve to a canonical target
|
||||
selectedPeer = ContactDirectory.canonicalConversationId(
|
||||
com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||
@ -1020,6 +1145,11 @@ class ChatViewModel(
|
||||
messageId,
|
||||
com.bitchat.android.model.DeliveryStatus.Sent
|
||||
)
|
||||
} else if (route == com.bitchat.android.services.MessageRouter.RouteResult.QUEUED) {
|
||||
messageManager.updateMessageDeliveryStatus(
|
||||
messageId,
|
||||
com.bitchat.android.model.DeliveryStatus.Queued
|
||||
)
|
||||
}
|
||||
}
|
||||
onAccepted(accepted)
|
||||
@ -1269,6 +1399,20 @@ class ChatViewModel(
|
||||
return verifiedFingerprints.contains(fingerprint)
|
||||
}
|
||||
|
||||
fun isFingerprintVouched(fingerprint: String): Boolean =
|
||||
verificationHandler.isFingerprintVouched(fingerprint)
|
||||
|
||||
fun isNoisePublicKeyVouched(noisePublicKey: ByteArray): Boolean =
|
||||
verificationHandler.isFingerprintVouched(
|
||||
verificationHandler.fingerprintFromNoiseBytes(noisePublicKey)
|
||||
)
|
||||
|
||||
fun vouchersForFingerprint(fingerprint: String) =
|
||||
verificationHandler.vouchersForFingerprint(fingerprint)
|
||||
|
||||
fun voucherNamesForFingerprint(fingerprint: String): List<String> =
|
||||
verificationHandler.voucherNamesForFingerprint(fingerprint)
|
||||
|
||||
fun unverifyFingerprint(peerID: String) {
|
||||
verificationHandler.unverifyFingerprint(peerID)
|
||||
}
|
||||
@ -1332,6 +1476,14 @@ class ChatViewModel(
|
||||
state.setShowMeshPeerList(true)
|
||||
}
|
||||
|
||||
fun setBridgeEnabled(enabled: Boolean) {
|
||||
MeshBridgeService.setEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setBridgeNearbyOnly(enabled: Boolean) {
|
||||
MeshBridgeService.setNearbyOnly(enabled)
|
||||
}
|
||||
|
||||
fun hideMeshPeerList() {
|
||||
state.setShowMeshPeerList(false)
|
||||
}
|
||||
@ -1421,6 +1573,27 @@ class ChatViewModel(
|
||||
|
||||
override fun didResolvePrivateMediaPolicy(peerID: String) {
|
||||
mediaSendingManager.retryPendingPrivateMedia(peerID)
|
||||
groupCoordinator.handlePeerAuthenticated(peerID)
|
||||
}
|
||||
|
||||
override fun didReceiveGroupInvite(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
groupCoordinator.handleInvite(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun didReceiveGroupKeyUpdate(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
groupCoordinator.handleKeyUpdate(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun didReceiveGroupMessage(payload: ByteArray, timestampMs: Long) {
|
||||
groupCoordinator.handleMessage(payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
@ -1435,102 +1608,78 @@ class ChatViewModel(
|
||||
return meshDelegateHandler.isFavorite(peerID)
|
||||
}
|
||||
|
||||
private val _sharedDraft = MutableStateFlow<String?>(null)
|
||||
val sharedDraft: StateFlow<String?> = _sharedDraft.asStateFlow()
|
||||
fun receiveSharedText(text: String) { _sharedDraft.value = text.take(16_000) }
|
||||
fun consumeSharedText() { _sharedDraft.value = null }
|
||||
|
||||
// MARK: - Emergency Clear
|
||||
|
||||
private var panicClearInProgress = false
|
||||
private val _panicWipeState = MutableStateFlow(PanicWipeState.IDLE)
|
||||
val panicWipeState: StateFlow<PanicWipeState> = _panicWipeState.asStateFlow()
|
||||
fun requestPanicClear() {
|
||||
if (_panicWipeState.value != PanicWipeState.ERASING) _panicWipeState.value = PanicWipeState.CONFIRM
|
||||
}
|
||||
fun dismissPanicClear() {
|
||||
if (_panicWipeState.value != PanicWipeState.ERASING) _panicWipeState.value = PanicWipeState.IDLE
|
||||
}
|
||||
|
||||
fun panicClearAllData() {
|
||||
if (panicClearInProgress) return
|
||||
panicClearInProgress = true
|
||||
if (_panicWipeState.value == PanicWipeState.ERASING) return
|
||||
_panicWipeState.value = PanicWipeState.ERASING
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
performPanicClearAllData()
|
||||
} finally {
|
||||
panicClearInProgress = false
|
||||
}
|
||||
val completed = try { performPanicClearAllData() } catch (_: Exception) { false }
|
||||
_panicWipeState.value = if (completed) PanicWipeState.COMPLETE else PanicWipeState.FAILED
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performPanicClearAllData() {
|
||||
Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data")
|
||||
try {
|
||||
com.bitchat.android.geohash.LocationChannelManager
|
||||
.getInstance(getApplication())
|
||||
.disableLocationServices()
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// A pending one-shot downgrade confirmation must not survive panic or
|
||||
// become actionable against the fresh post-wipe identity.
|
||||
mediaSendingManager.clearPendingPrivateMediaConsent()
|
||||
|
||||
// Stop all message admission before wiping storage. The AppStateStore gate also rejects
|
||||
// any transport callback already in flight until the fresh identity is ready.
|
||||
clearAllMeshServiceData()
|
||||
val conversationsCleared =
|
||||
com.bitchat.android.services.AppStateStore
|
||||
.panicClearPrivateConversations()
|
||||
|
||||
// Clear all UI managers
|
||||
com.bitchat.android.services.AppStateStore.clear()
|
||||
messageManager.clearAllMessages()
|
||||
channelManager.clearAllChannels()
|
||||
privateChatManager.clearAllPrivateChats()
|
||||
dataManager.clearAllData()
|
||||
conversationListPreferences.clearAll()
|
||||
|
||||
// Clear seen message store and MessageRouter outbox
|
||||
try {
|
||||
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear()
|
||||
} catch (_: Exception) { }
|
||||
try {
|
||||
com.bitchat.android.services.MessageRouter.tryGetInstance()?.clearAll()
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Clear all cryptographic data
|
||||
clearAllCryptographicData()
|
||||
|
||||
// Clear all notifications
|
||||
notificationManager.clearAllNotifications(removeConversationShortcuts = true)
|
||||
|
||||
// Clear all media files
|
||||
com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication())
|
||||
|
||||
// Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch
|
||||
try {
|
||||
// Clear geohash bookmarks too (panic should remove everything)
|
||||
try {
|
||||
val store = com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication())
|
||||
store.clearAll()
|
||||
} catch (_: Exception) { }
|
||||
|
||||
try {
|
||||
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
|
||||
locationManager.clearPersistedChannel()
|
||||
} catch (_: Exception) { }
|
||||
|
||||
geohashViewModel.panicReset()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
|
||||
private suspend fun performPanicClearAllData(): Boolean {
|
||||
var successful = true
|
||||
suspend fun step(action: suspend () -> Unit) {
|
||||
try { action() } catch (_: Exception) { successful = false }
|
||||
}
|
||||
groupCoordinator.suspendForPanic()
|
||||
step { com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()).disableLocationServices() }
|
||||
step { com.bitchat.android.services.bridge.MeshGatewayService.wipe() }
|
||||
step { MeshBridgeService.wipe() }
|
||||
step { mesh.stopServices(); mesh.clearAllInternalData() }
|
||||
step { check(com.bitchat.android.services.AppStateStore.panicClearPrivateConversations()) }
|
||||
step { com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.wipe() }
|
||||
mediaSendingManager.clearPendingPrivateMediaConsent()
|
||||
step {
|
||||
com.bitchat.android.services.AppStateStore.clear()
|
||||
messageManager.clearAllMessages()
|
||||
channelManager.clearAllChannels()
|
||||
privateChatManager.clearAllPrivateChats()
|
||||
check(groupStore.wipe())
|
||||
dataManager.clearAllData()
|
||||
conversationListPreferences.clearAll()
|
||||
boardManager.clearTransientState()
|
||||
}
|
||||
step { com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() }
|
||||
step { com.bitchat.android.services.MessageRouter.panicClear(getApplication()) }
|
||||
step {
|
||||
mesh.clearAllEncryptionData()
|
||||
check(SecureIdentityStateManager(getApplication()).clearIdentityData())
|
||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||
}
|
||||
step { notificationManager.clearAllNotifications(removeConversationShortcuts = true) }
|
||||
step { check(com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication())) }
|
||||
step { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication()).clearAll() }
|
||||
step { com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()).panicReset() }
|
||||
step { com.bitchat.android.nostr.NostrRelayManager.getInstance(getApplication()).clearCustomRelays() }
|
||||
step { geohashViewModel.panicReset() }
|
||||
if (!successful) return false
|
||||
|
||||
// Reset nickname
|
||||
val newNickname = "anon${Random.nextInt(1000, 9999)}"
|
||||
state.setNickname(newNickname)
|
||||
dataManager.saveNickname(newNickname)
|
||||
|
||||
if (!conversationsCleared) {
|
||||
// Privacy wins over availability: keep private-message admission and transports
|
||||
// stopped if SQLite could not prove that the conversation history was erased.
|
||||
Log.e(TAG, "🚨 PANIC MODE INCOMPLETE - conversation database wipe failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Recreate mesh service with fresh identity
|
||||
com.bitchat.android.services.AppStateStore
|
||||
.resumePrivateConversationsAfterPanic()
|
||||
recreateMeshServiceAfterPanic()
|
||||
|
||||
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}")
|
||||
com.bitchat.android.services.AppStateStore.resumePrivateConversationsAfterPanic()
|
||||
groupCoordinator.resumeAfterPanic()
|
||||
MeshBridgeService.resumeAfterPanic()
|
||||
com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.resume()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1562,53 +1711,6 @@ class ChatViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all mesh service related data
|
||||
*/
|
||||
private fun clearAllMeshServiceData() {
|
||||
try {
|
||||
// Request mesh service to clear all its internal data
|
||||
mesh.clearAllInternalData()
|
||||
|
||||
Log.d(TAG, "✅ Cleared all mesh service data")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Error clearing mesh service data: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cryptographic data including persistent identity
|
||||
*/
|
||||
private fun clearAllCryptographicData() {
|
||||
try {
|
||||
// Clear encryption service persistent identity (Ed25519 signing keys)
|
||||
mesh.clearAllEncryptionData()
|
||||
|
||||
// Clear secure identity state (if used)
|
||||
try {
|
||||
val identityManager = SecureIdentityStateManager(getApplication())
|
||||
identityManager.clearIdentityData()
|
||||
// Also clear secure values used by FavoritesPersistenceService (favorites + peerID index)
|
||||
try {
|
||||
identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index")
|
||||
} catch (_: Exception) { }
|
||||
Log.d(TAG, "✅ Cleared secure identity state and secure favorites store")
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}")
|
||||
}
|
||||
|
||||
// Clear FavoritesPersistenceService persistent relationships
|
||||
try {
|
||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||
Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships")
|
||||
} catch (_: Exception) { }
|
||||
|
||||
Log.d(TAG, "✅ Cleared all cryptographic data")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get participant count for a specific geohash (5-minute activity window)
|
||||
*/
|
||||
@ -1730,4 +1832,6 @@ class ChatViewModel(
|
||||
fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity =
|
||||
geohashViewModel.peerIdentityForNostrPubkey(pubkeyHex)
|
||||
|
||||
private fun String.truncateNoticeAlert(): String =
|
||||
if (length <= 120) this else take(120) + "…"
|
||||
}
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object ClientPrivacyPreferences {
|
||||
fun showNotificationPreviews(context: Context): Boolean =
|
||||
context.getSharedPreferences("client_privacy", Context.MODE_PRIVATE).getBoolean("notification_previews", false)
|
||||
|
||||
fun setNotificationPreviews(context: Context, enabled: Boolean) {
|
||||
context.getSharedPreferences("client_privacy", Context.MODE_PRIVATE).edit()
|
||||
.putBoolean("notification_previews", enabled).apply()
|
||||
NotificationManager.clearAllForPrivacyChange(context)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.nostr.NostrRelayManager
|
||||
|
||||
@Composable
|
||||
fun ClientSettingsSection() {
|
||||
val context = LocalContext.current
|
||||
val relayManager = remember { NostrRelayManager.getInstance(context) }
|
||||
val relays by relayManager.customRelays.collectAsStateWithLifecycle()
|
||||
val gateway by com.bitchat.android.services.bridge.MeshGatewayService.enabled.collectAsStateWithLifecycle()
|
||||
var previews by remember { mutableStateOf(ClientPrivacyPreferences.showNotificationPreviews(context)) }
|
||||
var relayInput by remember { mutableStateOf("") }
|
||||
var relayError by remember { mutableStateOf(false) }
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.notification_previews), style = MaterialTheme.typography.titleSmall)
|
||||
Text(stringResource(R.string.notification_previews_description), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Switch(checked = previews, onCheckedChange = {
|
||||
previews = it
|
||||
ClientPrivacyPreferences.setNotificationPreviews(context, it)
|
||||
})
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.gateway_title), style = MaterialTheme.typography.titleSmall)
|
||||
Text(stringResource(R.string.gateway_description), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Switch(gateway, onCheckedChange = com.bitchat.android.services.bridge.MeshGatewayService::setEnabled)
|
||||
}
|
||||
Text(stringResource(R.string.custom_relays), style = MaterialTheme.typography.titleSmall)
|
||||
Text(stringResource(R.string.custom_relays_description), style = MaterialTheme.typography.bodySmall)
|
||||
relays.forEach { url ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(url, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodySmall)
|
||||
TextButton(onClick = { relayManager.removeCustomRelay(url) }) { Text(stringResource(R.string.relay_remove)) }
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = relayInput,
|
||||
onValueChange = { relayInput = it; relayError = false },
|
||||
label = { Text(stringResource(R.string.relay_url)) },
|
||||
placeholder = { Text("wss://relay.example") },
|
||||
singleLine = true,
|
||||
isError = relayError,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (relayError) Text(stringResource(R.string.relay_invalid), color = MaterialTheme.colorScheme.error)
|
||||
TextButton(onClick = {
|
||||
relayError = !relayManager.addCustomRelay(relayInput)
|
||||
if (!relayError) relayInput = ""
|
||||
}, enabled = relayInput.isNotBlank()) { Text(stringResource(R.string.relay_add)) }
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.services.meshgraph.MeshGraphService
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -23,11 +25,14 @@ class CommandProcessor(
|
||||
CommandSuggestion("/block", emptyList(), "[nickname]", "block or list blocked peers"),
|
||||
CommandSuggestion("/channels", emptyList(), null, "show all discovered channels"),
|
||||
CommandSuggestion("/clear", emptyList(), null, "clear chat messages"),
|
||||
CommandSuggestion("/group", emptyList(), "<create|invite|remove|leave|list>", "manage private groups"),
|
||||
CommandSuggestion("/hug", emptyList(), "<nickname>", "send someone a warm hug"),
|
||||
CommandSuggestion("/j", listOf("/join"), "<channel>", "join or create a channel"),
|
||||
CommandSuggestion("/m", listOf("/msg"), "<nickname> [message]", "send private message"),
|
||||
CommandSuggestion("/pay", emptyList(), "<token> [public]", "send a Cashu ecash token"),
|
||||
CommandSuggestion("/ping", emptyList(), "<nickname>", "measure mesh round-trip time"),
|
||||
CommandSuggestion("/slap", emptyList(), "<nickname>", "slap someone with a trout"),
|
||||
CommandSuggestion("/trace", emptyList(), "<nickname>", "estimate the mesh path"),
|
||||
CommandSuggestion("/unblock", emptyList(), "<nickname>", "unblock a peer"),
|
||||
CommandSuggestion("/w", emptyList(), null, "see who's online")
|
||||
)
|
||||
@ -51,11 +56,50 @@ class CommandProcessor(
|
||||
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage, viewModel)
|
||||
"/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage, viewModel)
|
||||
"/channels" -> handleChannelsCommand()
|
||||
"/group" -> handleGroupCommand(parts, viewModel)
|
||||
"/ping" -> handlePingCommand(parts, meshService)
|
||||
"/trace" -> handleTraceCommand(parts, meshService)
|
||||
else -> handleUnknownCommand(cmd)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun handleGroupCommand(parts: List<String>, viewModel: ChatViewModel?) {
|
||||
if (viewModel == null) {
|
||||
addCommandOutput("private groups are unavailable")
|
||||
return
|
||||
}
|
||||
val selectedPeer = state.getSelectedPrivateChatPeerValue()
|
||||
if (viewModel.selectedLocationChannel.value is ChannelID.Location ||
|
||||
selectedPeer?.startsWith("nostr_") == true ||
|
||||
selectedPeer?.startsWith("nostr:") == true
|
||||
) {
|
||||
addCommandOutput("groups are only for mesh peers in #mesh")
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.executeGroupCommand(parts.drop(1).filter(String::isNotBlank)) { result ->
|
||||
addCommandOutput(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addCommandOutput(message: String) {
|
||||
val destination = state.getSelectedPrivateChatPeerValue()
|
||||
if (destination != null) {
|
||||
messageManager.addPrivateMessage(
|
||||
destination,
|
||||
BitchatMessage(
|
||||
sender = "system",
|
||||
content = message,
|
||||
timestamp = Date(),
|
||||
isPrivate = true
|
||||
)
|
||||
)
|
||||
} else {
|
||||
messageManager.addSystemMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleJoinCommand(parts: List<String>, myPeerID: String) {
|
||||
if (parts.size > 1) {
|
||||
@ -454,6 +498,104 @@ class CommandProcessor(
|
||||
else -> messageManager.addMessage(message)
|
||||
}
|
||||
}
|
||||
private fun handlePingCommand(parts: List<String>, meshService: MeshService) {
|
||||
if (!isMeshContext()) {
|
||||
addCommandOutput("mesh diagnostics are only available in #mesh")
|
||||
return
|
||||
}
|
||||
val targetName = parts.getOrNull(1)?.removePrefix("@")
|
||||
val peerID = targetName?.let { getPeerIDForNickname(it, meshService) }
|
||||
if (targetName == null) {
|
||||
addCommandOutput("usage: /ping <nickname>")
|
||||
return
|
||||
}
|
||||
if (peerID == null) {
|
||||
addCommandOutput("user '$targetName' not found")
|
||||
return
|
||||
}
|
||||
val destination = currentCommandDestination()
|
||||
addCommandOutput("pinging $targetName…", destination)
|
||||
meshService.sendMeshPing(peerID) { result ->
|
||||
val output = if (result == null) {
|
||||
"no reply from $targetName"
|
||||
} else {
|
||||
val hops = if (result.hopCount == 1) {
|
||||
"direct (1 hop)"
|
||||
} else {
|
||||
"${result.hopCount} hops"
|
||||
}
|
||||
"pong from $targetName: ${result.rttMillis} ms · $hops"
|
||||
}
|
||||
addCommandOutput(output, destination)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTraceCommand(parts: List<String>, meshService: MeshService) {
|
||||
if (!isMeshContext()) {
|
||||
addCommandOutput("mesh diagnostics are only available in #mesh")
|
||||
return
|
||||
}
|
||||
val targetName = parts.getOrNull(1)?.removePrefix("@")
|
||||
val peerID = targetName?.let { getPeerIDForNickname(it, meshService) }
|
||||
if (targetName == null) {
|
||||
addCommandOutput("usage: /trace <nickname>")
|
||||
return
|
||||
}
|
||||
if (peerID == null) {
|
||||
addCommandOutput("user '$targetName' not found")
|
||||
return
|
||||
}
|
||||
val graph = MeshGraphService.getInstance()
|
||||
val route = graph.computeRoute(meshService.myPeerID, peerID)
|
||||
?: meshService.getPeerInfo(peerID)
|
||||
?.takeIf { it.isConnected && it.isDirectConnection }
|
||||
?.let { listOf(meshService.myPeerID, peerID) }
|
||||
if (route == null) {
|
||||
addCommandOutput("no known path to $targetName")
|
||||
return
|
||||
}
|
||||
val labels = route.mapIndexed { index, id ->
|
||||
when {
|
||||
index == 0 -> "you"
|
||||
id == peerID -> targetName
|
||||
else -> meshService.getPeerNicknames()[id] ?: id.take(8)
|
||||
}
|
||||
}
|
||||
val hopCount = route.size - 1
|
||||
val hops = if (hopCount == 1) "1 hop" else "$hopCount hops"
|
||||
addCommandOutput("estimated path: ${labels.joinToString(" → ")} ($hops)")
|
||||
}
|
||||
|
||||
private sealed interface CommandDestination {
|
||||
data object Main : CommandDestination
|
||||
data class Channel(val name: String) : CommandDestination
|
||||
data class Private(val peerID: String) : CommandDestination
|
||||
}
|
||||
|
||||
private fun currentCommandDestination(): CommandDestination =
|
||||
state.getSelectedPrivateChatPeerValue()?.let(CommandDestination::Private)
|
||||
?: state.getCurrentChannelValue()?.let(CommandDestination::Channel)
|
||||
?: CommandDestination.Main
|
||||
|
||||
private fun addCommandOutput(
|
||||
text: String,
|
||||
destination: CommandDestination = currentCommandDestination(),
|
||||
) {
|
||||
val message = BitchatMessage(
|
||||
sender = "system",
|
||||
content = text,
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
)
|
||||
when (destination) {
|
||||
CommandDestination.Main -> messageManager.addMessage(message)
|
||||
is CommandDestination.Channel -> messageManager.addChannelMessage(destination.name, message)
|
||||
is CommandDestination.Private -> messageManager.addPrivateMessage(destination.peerID, message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isMeshContext(): Boolean =
|
||||
state.selectedLocationChannel.value.let { it == null || it is ChannelID.Mesh }
|
||||
|
||||
private fun handleUnknownCommand(cmd: String) {
|
||||
val systemMessage = BitchatMessage(
|
||||
@ -517,7 +659,10 @@ class CommandProcessor(
|
||||
state.getSelectedPrivateChatPeerValue() == null &&
|
||||
state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location
|
||||
return (baseCommands + channelCommands).filterNot {
|
||||
isPublicGeohash && it.command == "/pay"
|
||||
(isPublicGeohash && it.command == "/pay") ||
|
||||
(!isMeshContext() && it.command in setOf("/ping", "/trace")) ||
|
||||
(it.command == "/group" && (state.selectedLocationChannel.value is ChannelID.Location ||
|
||||
state.getSelectedPrivateChatPeerValue()?.startsWith("nostr") == true))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.net.ArtiTorManager
|
||||
import com.bitchat.android.net.TorMode
|
||||
|
||||
@Composable
|
||||
fun ConnectivityBanner() {
|
||||
val context = LocalContext.current
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
fun bluetoothReady(): Boolean = runCatching {
|
||||
if (Build.VERSION.SDK_INT >= 31 && ContextCompat.checkSelfPermission(context,
|
||||
Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) false
|
||||
else context.getSystemService(BluetoothManager::class.java)?.adapter?.isEnabled == true
|
||||
}.getOrDefault(false)
|
||||
var bluetoothEnabled by remember { mutableStateOf(bluetoothReady()) }
|
||||
val tor by remember { ArtiTorManager.getInstance().statusFlow }.collectAsStateWithLifecycle()
|
||||
DisposableEffect(context, lifecycle) {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) { bluetoothEnabled = bluetoothReady() }
|
||||
}
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) bluetoothEnabled = bluetoothReady()
|
||||
}
|
||||
ContextCompat.registerReceiver(context, receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED), ContextCompat.RECEIVER_EXPORTED)
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose { context.unregisterReceiver(receiver); lifecycle.removeObserver(observer) }
|
||||
}
|
||||
val warning = when {
|
||||
!bluetoothEnabled -> stringResource(R.string.bluetooth_unavailable_banner)
|
||||
tor.mode == TorMode.ON && tor.state == ArtiTorManager.TorState.ERROR -> stringResource(R.string.tor_failed_banner)
|
||||
tor.mode == TorMode.ON && tor.state != ArtiTorManager.TorState.RUNNING -> stringResource(R.string.tor_connecting_banner, tor.bootstrapPercent)
|
||||
else -> null
|
||||
}
|
||||
warning?.let {
|
||||
Surface(color = MaterialTheme.colorScheme.errorContainer, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(it, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,9 +14,21 @@ class DataManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DataManager"
|
||||
private const val PREFS_NAME = "bitchat_prefs"
|
||||
private const val BLOCKED_USERS_KEY = "blocked_users"
|
||||
|
||||
/**
|
||||
* Reads the current persisted block list for ingress paths that have a
|
||||
* full Noise key but no live mesh peer/session.
|
||||
*/
|
||||
fun isFingerprintBlocked(context: Context, fingerprint: String): Boolean =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getStringSet(BLOCKED_USERS_KEY, emptySet())
|
||||
?.contains(fingerprint) == true
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
|
||||
// Channel-related maps that need to persist state
|
||||
@ -200,13 +212,13 @@ class DataManager(private val context: Context) {
|
||||
|
||||
@Synchronized
|
||||
fun loadBlockedUsers() {
|
||||
val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet()
|
||||
val savedBlockedUsers = prefs.getStringSet(BLOCKED_USERS_KEY, emptySet()) ?: emptySet()
|
||||
_blockedUsers.clear()
|
||||
_blockedUsers.addAll(savedBlockedUsers)
|
||||
}
|
||||
|
||||
fun saveBlockedUsers() {
|
||||
prefs.edit().putStringSet("blocked_users", _blockedUsers).apply()
|
||||
prefs.edit().putStringSet(BLOCKED_USERS_KEY, _blockedUsers).apply()
|
||||
}
|
||||
|
||||
fun addBlockedUser(fingerprint: String) {
|
||||
|
||||
@ -164,6 +164,7 @@ class GeohashViewModel(
|
||||
teleported
|
||||
)
|
||||
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||
com.bitchat.android.services.bridge.MeshGatewayService.uplinkIfOffline(event, channel.geohash, liveLocationToken)
|
||||
relayManager.sendEventToGeohash(
|
||||
event,
|
||||
channel.geohash,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user