mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Complete durable delivery and client privacy parity
This commit is contained in:
parent
71092a5e17
commit
43d9d85797
@ -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>
|
||||
|
||||
|
||||
@ -82,6 +82,11 @@ class BitchatApplication : Application() {
|
||||
)
|
||||
} 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
|
||||
|
||||
@ -143,7 +143,7 @@ class BoardStore(
|
||||
fun wipe() = synchronized(lock) {
|
||||
posts.clear()
|
||||
tombstones.clear()
|
||||
file?.let { runCatching { if (it.exists()) it.delete() } }
|
||||
file?.let { check(!it.exists() || it.delete()) }
|
||||
publishSnapshotLocked()
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -112,7 +112,9 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
|
||||
@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>")
|
||||
@ -131,7 +133,9 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
|
||||
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]")
|
||||
@ -171,7 +175,9 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
|
||||
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]")
|
||||
@ -205,7 +211,9 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
|
||||
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) {
|
||||
@ -225,6 +233,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
|
||||
return success("left #${group.name}")
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun listGroups(): GroupCommandResult {
|
||||
if (!context.groupStore.isReady) return loadingError()
|
||||
val groups = context.groupStore.groups.value
|
||||
@ -237,26 +246,28 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
|
||||
return success("private groups:\n$lines")
|
||||
}
|
||||
|
||||
fun sendMessage(content: String, groupPeerID: String) {
|
||||
@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
|
||||
return false
|
||||
}
|
||||
if (content.isEmpty() ||
|
||||
content.codePointCount(0, content.length) > MAX_MESSAGE_LENGTH
|
||||
) {
|
||||
return
|
||||
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
|
||||
return false
|
||||
}
|
||||
val signingKey = context.mySigningPublicKey()
|
||||
if (signingKey?.size != 32) {
|
||||
context.addGroupSystemMessage(groupPeerID, "your signing identity is unavailable")
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
val messageID = UUID.randomUUID().toString()
|
||||
@ -275,10 +286,10 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
context.addGroupSystemMessage(groupPeerID, "could not encrypt group message")
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
context.appendGroupMessage(
|
||||
val stored = context.appendGroupMessage(
|
||||
groupPeerID,
|
||||
BitchatMessage(
|
||||
id = messageID,
|
||||
@ -291,9 +302,12 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
|
||||
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
|
||||
@ -346,6 +360,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun handleInvite(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
@ -370,6 +385,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun handleKeyUpdate(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
@ -399,6 +415,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
|
||||
* 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
|
||||
@ -425,6 +442,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
|
||||
/**
|
||||
* Drains packets received during asynchronous store initialization.
|
||||
*/
|
||||
@Synchronized
|
||||
fun onStoreReady() {
|
||||
val generation = inboundGeneration
|
||||
if (!acceptsInboundEvents) return
|
||||
@ -460,6 +478,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun suspendForPanic() {
|
||||
synchronized(lifecycleLock) {
|
||||
acceptsInboundEvents = false
|
||||
@ -471,6 +490,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun resumeAfterPanic() {
|
||||
synchronized(lifecycleLock) {
|
||||
acceptsInboundEvents = true
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -668,22 +668,18 @@ class SecureIdentityStateManager {
|
||||
* Clear all identity data (for panic mode)
|
||||
*/
|
||||
@SuppressLint("UseKtx")
|
||||
fun clearIdentityData() {
|
||||
try {
|
||||
synchronized(identityPersistenceLock) {
|
||||
identityPersistenceEpoch += 1
|
||||
identityPersistenceEpochAtCreation = identityPersistenceEpoch
|
||||
if (!prefs.edit().clear().commit()) {
|
||||
Log.e(TAG, "Identity preference wipe could not be committed")
|
||||
}
|
||||
identityChanges.tryEmit(Unit)
|
||||
}
|
||||
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
|
||||
*/
|
||||
|
||||
@ -80,7 +80,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
},
|
||||
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(
|
||||
@ -558,6 +559,7 @@ 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.
|
||||
@ -571,6 +573,7 @@ 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(
|
||||
@ -594,7 +597,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload)
|
||||
GroupMessagePort.receiver?.invite(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun onGroupKeyUpdateReceived(
|
||||
@ -602,11 +605,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload)
|
||||
GroupMessagePort.receiver?.keyUpdate(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveGroupMessage(payload, timestampMs)
|
||||
GroupMessagePort.receiver?.message(payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun onVouchPayloadReceived(peerID: String, payload: ByteArray) {
|
||||
@ -1137,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(
|
||||
@ -2094,11 +2103,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
bridgeCourierService::stop,
|
||||
gossipSyncManager::clear
|
||||
)
|
||||
var failure: Exception? = null
|
||||
operations.forEach { operation ->
|
||||
try { operation() } catch (e: Exception) {
|
||||
Log.e(TAG, "Error clearing mesh service internal data: ${e.message}")
|
||||
}
|
||||
try { operation() } catch (error: Exception) { failure = error }
|
||||
}
|
||||
failure?.let { throw IllegalStateException("Mesh data wipe incomplete", it) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -50,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,
|
||||
@ -76,7 +77,8 @@ class MeshCore(
|
||||
},
|
||||
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(
|
||||
@ -437,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,
|
||||
@ -447,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,
|
||||
@ -469,7 +473,7 @@ class MeshCore(
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload)
|
||||
GroupMessagePort.receiver?.invite(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun onGroupKeyUpdateReceived(
|
||||
@ -477,11 +481,11 @@ class MeshCore(
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload)
|
||||
GroupMessagePort.receiver?.keyUpdate(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveGroupMessage(payload, timestampMs)
|
||||
GroupMessagePort.receiver?.message(payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun onVouchPayloadReceived(peerID: String, payload: ByteArray) {
|
||||
@ -851,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,
|
||||
|
||||
@ -38,6 +38,7 @@ internal class MeshPingManager(
|
||||
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 {
|
||||
@ -73,7 +74,16 @@ internal class MeshPingManager(
|
||||
|
||||
private fun consumeInboundBudget(link: String): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
val timestamps = inboundByLink.computeIfAbsent(link) { ArrayDeque() }
|
||||
synchronized(inboundByLink) {
|
||||
inboundByLink.entries.removeAll { (_, times) ->
|
||||
synchronized(times) {
|
||||
times.lastOrNull()?.let { now - it >= MeshDiagnosticsConstants.INBOUND_RATE_WINDOW_MILLIS } != false
|
||||
}
|
||||
}
|
||||
if (inboundByLink.size >= 256 && link !in inboundByLink) 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
|
||||
|
||||
@ -17,6 +17,8 @@ interface MeshService {
|
||||
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) {}
|
||||
|
||||
@ -119,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)
|
||||
}
|
||||
}
|
||||
@ -129,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,
|
||||
@ -147,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")
|
||||
}
|
||||
@ -248,7 +271,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
senderPeerID = peerID
|
||||
)
|
||||
delegate?.onMessageReceived(message)
|
||||
return true
|
||||
return com.bitchat.android.services.AppStateStore.hasPrivateTextReceipt(message)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -161,6 +161,9 @@ class UnifiedMeshService(
|
||||
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)
|
||||
@ -471,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() {
|
||||
|
||||
@ -58,10 +58,16 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
|
||||
|
||||
/** Capabilities implemented by this Android build. */
|
||||
@Deprecated("Use localSupported() so runtime bridge state is included")
|
||||
val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or PREKEYS.rawValue or GROUPS.rawValue or BOARD.rawValue or VOUCH.rawValue or MESH_DIAGNOSTICS.rawValue)
|
||||
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
|
||||
@ -69,8 +75,9 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
|
||||
|
||||
fun localSupported(): PeerCapabilities = PeerCapabilities(
|
||||
LOCAL_SUPPORTED.rawValue or
|
||||
PREKEYS.rawValue or
|
||||
if (bridgeEnabled) BRIDGE.rawValue else 0L
|
||||
(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)
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
@ -76,7 +76,8 @@ private class NostrBridgeCourierRelay(
|
||||
event: NostrEvent,
|
||||
relayUrls: List<String>,
|
||||
onAccepted: () -> Unit
|
||||
): Boolean = relayManager.sendEvent(event, relayUrls, onAccepted = onAccepted)
|
||||
): Boolean = relayManager.sendEvent(event, relayUrls, onAccepted = onAccepted,
|
||||
publicationAllowed = com.bitchat.android.services.bridge.MeshBridgeService.publicationPermit())
|
||||
}
|
||||
|
||||
private class EncryptionBridgeCourierCipher(
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -37,6 +37,7 @@ class NostrRelayManager private constructor() {
|
||||
*/
|
||||
fun getInstance(context: android.content.Context): NostrRelayManager {
|
||||
shared.appContext = context.applicationContext
|
||||
shared.loadCustomRelays(context.applicationContext)
|
||||
return shared
|
||||
}
|
||||
|
||||
@ -61,7 +62,7 @@ class NostrRelayManager private constructor() {
|
||||
pendingGiftWrapIDs.add(id)
|
||||
}
|
||||
|
||||
fun defaultRelays(): List<String> = DEFAULT_RELAYS
|
||||
fun defaultRelays(): List<String> = (DEFAULT_RELAYS + shared.customRelays.value).distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -79,6 +80,67 @@ 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
|
||||
|
||||
@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()
|
||||
_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
|
||||
_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()
|
||||
@ -237,23 +299,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 ---
|
||||
@ -458,8 +521,10 @@ class NostrRelayManager private constructor() {
|
||||
event: NostrEvent,
|
||||
relayUrls: List<String>? = null,
|
||||
liveLocationToken: Long? = null,
|
||||
onAccepted: (() -> Unit)? = null
|
||||
onAccepted: (() -> Unit)? = null,
|
||||
publicationAllowed: () -> Boolean = { true }
|
||||
): Boolean {
|
||||
if (!publicationAllowed()) return false
|
||||
val targetRelays = (relayUrls ?: relaysList.map { it.url })
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
@ -470,7 +535,8 @@ class NostrRelayManager private constructor() {
|
||||
val queueId = messageQueue.enqueue(
|
||||
event = event,
|
||||
relayUrls = targetRelays,
|
||||
liveLocationToken = liveLocationToken
|
||||
liveLocationToken = liveLocationToken,
|
||||
publicationAllowed = publicationAllowed
|
||||
) ?: return@runNetworkAction
|
||||
enqueued = true
|
||||
if (onAccepted != null) {
|
||||
@ -484,7 +550,7 @@ class NostrRelayManager private constructor() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -904,16 +970,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() || !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()) success = webSocket.send(message)
|
||||
}
|
||||
if (success) {
|
||||
// Update relay stats
|
||||
@ -1193,7 +1260,8 @@ class NostrRelayManager private constructor() {
|
||||
delivery.event,
|
||||
webSocket,
|
||||
relayUrl,
|
||||
delivery.liveLocationToken
|
||||
delivery.liveLocationToken,
|
||||
delivery.publicationAllowed
|
||||
)
|
||||
) {
|
||||
messageQueue.markDelivered(delivery.queueId, relayUrl)
|
||||
|
||||
@ -272,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,
|
||||
|
||||
@ -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" }
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
|
||||
/**
|
||||
* Owns public-timeline replay deduplication and bridge/radio reconciliation.
|
||||
*
|
||||
* The store remains responsible for synchronization and cross-timeline IDs;
|
||||
* this class keeps bridge-specific alias policy independently testable.
|
||||
*/
|
||||
internal class PublicMessageReconciler {
|
||||
data class Result(
|
||||
val messages: List<BitchatMessage>,
|
||||
val accepted: Boolean
|
||||
)
|
||||
|
||||
private val seenKeys = mutableSetOf<String>()
|
||||
|
||||
fun reconcile(
|
||||
existing: List<BitchatMessage>,
|
||||
incoming: BitchatMessage,
|
||||
messageIdAlreadySeen: Boolean
|
||||
): Result {
|
||||
val withoutBridgeAliases = if (incoming.isBridged) {
|
||||
existing
|
||||
} else {
|
||||
existing.filterNot {
|
||||
it.isBridged && it.bridgeRadioMessageIdHint == incoming.id
|
||||
}
|
||||
}
|
||||
val key = publicMessageKey(incoming)
|
||||
if (messageIdAlreadySeen || key in seenKeys) {
|
||||
return Result(withoutBridgeAliases, accepted = false)
|
||||
}
|
||||
seenKeys += key
|
||||
return Result(withoutBridgeAliases + incoming, accepted = true)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
seenKeys.clear()
|
||||
}
|
||||
|
||||
private fun publicMessageKey(message: BitchatMessage): String {
|
||||
val sender = message.senderPeerID ?: message.sender
|
||||
return listOf(
|
||||
sender,
|
||||
message.timestamp.time.toString(),
|
||||
message.type.name,
|
||||
message.channel ?: "",
|
||||
message.content
|
||||
).joinToString("\u001F")
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,5 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
internal class BoundedIdSet(private val capacity: Int) {
|
||||
private val values = LinkedHashSet<String>()
|
||||
@ -49,52 +45,3 @@ internal class RelaySubscriptionSlot(private val idPrefix: String) {
|
||||
close(id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A small insertion-ordered expiring set. Callers own synchronization; bridge
|
||||
* coordinators keep each instance confined to their serial dispatcher.
|
||||
*/
|
||||
internal class PersistentExpiringIdSet(
|
||||
private val preferences: SharedPreferences,
|
||||
private val key: String,
|
||||
private val capacity: Int
|
||||
) {
|
||||
private val gson = Gson()
|
||||
private val values: LinkedHashMap<String, Long> = load()
|
||||
|
||||
fun contains(id: String, nowMs: Long = System.currentTimeMillis()): Boolean {
|
||||
prune(nowMs)
|
||||
return (values[id] ?: return false) > nowMs
|
||||
}
|
||||
|
||||
fun add(id: String, lifetimeMs: Long, nowMs: Long = System.currentTimeMillis()) {
|
||||
prune(nowMs)
|
||||
values.remove(id)
|
||||
values[id] = nowMs + lifetimeMs
|
||||
while (values.size > capacity) values.remove(values.keys.first())
|
||||
persist()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
values.clear()
|
||||
preferences.edit { remove(key) }
|
||||
}
|
||||
|
||||
private fun prune(nowMs: Long) {
|
||||
val changed = values.entries.removeAll { it.value <= nowMs }
|
||||
if (changed) persist()
|
||||
}
|
||||
|
||||
private fun load(): LinkedHashMap<String, Long> {
|
||||
val type = object : TypeToken<Map<String, Long>>() {}.type
|
||||
val decoded: Map<String, Long> = runCatching {
|
||||
preferences.getString(key, null)
|
||||
?.let { json -> gson.fromJson<Map<String, Long>>(json, type) }
|
||||
}.getOrNull() ?: emptyMap()
|
||||
return LinkedHashMap(decoded)
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
preferences.edit { putString(key, gson.toJson(values)) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,21 +17,6 @@ data class BridgeUiState(
|
||||
val participants: List<BridgedParticipant> = emptyList()
|
||||
)
|
||||
|
||||
sealed interface CourierDepositResult {
|
||||
data object Published : CourierDepositResult
|
||||
data object ForwardedToGateway : CourierDepositResult
|
||||
data object QueuedLocally : CourierDepositResult
|
||||
data object AlreadyPublished : CourierDepositResult
|
||||
data class Rejected(val reason: Reason) : CourierDepositResult
|
||||
|
||||
enum class Reason {
|
||||
BRIDGE_DISABLED,
|
||||
CONTENT_TOO_LARGE,
|
||||
INVALID_MESSAGE,
|
||||
ENCRYPTION_FAILED
|
||||
}
|
||||
}
|
||||
|
||||
internal data class VerifiedBridgePeer(
|
||||
val peerId: String,
|
||||
val nickname: String,
|
||||
|
||||
@ -189,19 +189,6 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
val defaultRelays = NostrRelayManager.defaultRelays().toSet()
|
||||
relayManager?.relays
|
||||
?.map { relays ->
|
||||
relays.asSequence()
|
||||
.filter { it.isConnected && it.url in defaultRelays }
|
||||
.map { it.url }
|
||||
.toSet()
|
||||
}
|
||||
?.distinctUntilChanged()
|
||||
?.collect { connectedDefaults ->
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
if (_isEnabled.value) {
|
||||
relayManager?.connect()
|
||||
@ -217,12 +204,20 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
@ -289,7 +284,7 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
publishedEventIds.add(event.id)
|
||||
injectedEventIds.add(event.id)
|
||||
if (relayManager?.isConnected?.value == true) {
|
||||
relayManager?.sendEventToGeohash(event, cell)
|
||||
relayManager?.sendEventToGeohash(event, cell, publicationAllowed = publicationPermit())
|
||||
} else {
|
||||
val peer = availableBridgePeer() ?: return@launch
|
||||
NostrCarrierPacket.fromEvent(
|
||||
@ -354,7 +349,7 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
if (!directedToUs) handleDownlink(carrier)
|
||||
}
|
||||
NostrCarrierPacket.Direction.TO_GATEWAY,
|
||||
NostrCarrierPacket.Direction.FROM_GATEWAY -> Unit
|
||||
NostrCarrierPacket.Direction.FROM_GATEWAY -> MeshGatewayService.handleCarrier(carrier, fromPeerId, directedToUs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -363,6 +358,7 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
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
|
||||
@ -626,7 +622,7 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
|
||||
private fun publishCarriedEvent(event: NostrEvent, cell: String) {
|
||||
publishedEventIds.add(event.id)
|
||||
relayManager?.sendEventToGeohash(event, cell)
|
||||
relayManager?.sendEventToGeohash(event, cell, publicationAllowed = publicationPermit())
|
||||
}
|
||||
|
||||
private fun publishPresence() {
|
||||
@ -635,7 +631,7 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
val identity = NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext())
|
||||
val event = NostrProtocol.createBridgePresenceEvent(cell, identity)
|
||||
publishedEventIds.add(event.id)
|
||||
relayManager?.sendEventToGeohash(event, cell)
|
||||
relayManager?.sendEventToGeohash(event, cell, publicationAllowed = publicationPermit())
|
||||
}
|
||||
|
||||
private fun startPresenceLoop() {
|
||||
@ -663,6 +659,12 @@ object MeshBridgeService : BridgeMeshDelegate {
|
||||
presenceJob = null
|
||||
}
|
||||
|
||||
fun resumeAfterPanic() {
|
||||
panicQuiesced = false
|
||||
suppressPrekeyBroadcasts = false
|
||||
refreshPrekeys()
|
||||
}
|
||||
|
||||
fun refreshPrekeys() {
|
||||
scope.launch { broadcastPrekeys(force = true) }
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -448,6 +448,8 @@ fun AboutSheet(
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "client_privacy") { ClientSettingsSection() }
|
||||
|
||||
item(key = "language") {
|
||||
val selectedLanguageName = supportedLanguages
|
||||
.firstOrNull { it.languageTag == selectedLanguageTag }
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -85,6 +85,9 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
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) }
|
||||
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||
@ -108,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
|
||||
@ -308,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,
|
||||
@ -362,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 }
|
||||
)
|
||||
}
|
||||
|
||||
@ -463,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()
|
||||
|
||||
@ -170,7 +170,9 @@ class ChatViewModel(
|
||||
.select(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||
}
|
||||
)
|
||||
private val groupStore = GroupStore(application.applicationContext)
|
||||
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
|
||||
@ -247,136 +249,26 @@ class ChatViewModel(
|
||||
NotificationManagerCompat.from(application.applicationContext)
|
||||
)
|
||||
|
||||
private val groupCoordinator = GroupCoordinator(object : GroupCoordinatorContext {
|
||||
override val groupStore: GroupStore
|
||||
get() = this@ChatViewModel.groupStore
|
||||
override val nickname: String
|
||||
get() = state.getNicknameValue()
|
||||
override val myPeerID: String
|
||||
get() = mesh.myPeerID
|
||||
override val selectedConversationID: String?
|
||||
get() = state.getSelectedPrivateChatPeerValue()
|
||||
|
||||
override fun myNoiseFingerprint(): String = mesh.getIdentityFingerprint()
|
||||
override fun mySigningPublicKey(): ByteArray? = mesh.getSigningPublicKey()
|
||||
override fun sign(data: ByteArray): ByteArray? = mesh.signData(data)
|
||||
|
||||
override fun peerIDsForNickname(nickname: String): List<String> =
|
||||
mesh.getPeerNicknames().entries.filter {
|
||||
it.value.equals(nickname, ignoreCase = true)
|
||||
}.map { it.key }
|
||||
|
||||
override fun isPeerConnected(peerID: String): Boolean =
|
||||
mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID)
|
||||
|
||||
override fun peerGroupCapability(peerID: String): PeerGroupCapability {
|
||||
val peerInfo = mesh.getPeerInfo(peerID) ?: return PeerGroupCapability.UNKNOWN
|
||||
return PeerGroupCapability.fromPeerState(
|
||||
peerInfo.capabilities,
|
||||
peerInfo.hasVerifiedAnnouncement
|
||||
)
|
||||
}
|
||||
|
||||
override fun peerNickname(peerID: String): String? =
|
||||
mesh.getPeerNicknames()[peerID]
|
||||
|
||||
override fun peerIdentity(peerID: String): GroupPeerIdentity? {
|
||||
val info = mesh.getPeerInfo(peerID) ?: return null
|
||||
if (info.signingPublicKey?.size != 32) return null
|
||||
val liveFingerprint = mesh.getPeerFingerprint(peerID) ?: return null
|
||||
val announcedNoiseKey = info.noisePublicKey ?: return null
|
||||
val announcedFingerprint = ContactIdentityResolver.fingerprintHex(announcedNoiseKey)
|
||||
if (!liveFingerprint.equals(announcedFingerprint, ignoreCase = true)) return null
|
||||
return GroupPeerIdentity(
|
||||
liveFingerprint.lowercase(),
|
||||
info.signingPublicKey!!.copyOf()
|
||||
)
|
||||
}
|
||||
|
||||
override fun connectedPeerID(fingerprint: String): String? =
|
||||
mesh.getPeerNicknames().keys.firstOrNull { peerID ->
|
||||
mesh.getPeerInfo(peerID)?.isConnected == true &&
|
||||
mesh.hasEstablishedSession(peerID) &&
|
||||
mesh.getPeerFingerprint(peerID).equals(fingerprint, ignoreCase = true)
|
||||
}
|
||||
|
||||
override fun isFingerprintBlocked(fingerprint: String): Boolean =
|
||||
dataManager.isUserBlocked(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 {
|
||||
val existing = state.getPrivateChatsValue()[groupPeerID].orEmpty()
|
||||
if (existing.any { it.id == message.id }) return false
|
||||
messageManager.addPrivateMessage(groupPeerID, message)
|
||||
if (state.getSelectedPrivateChatPeerValue() == groupPeerID) {
|
||||
try {
|
||||
com.bitchat.android.services.AppStateStore.markPrivateMessageRead(message.id)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
state.setUnreadPrivateMessages(state.getUnreadPrivateMessagesValue() + groupPeerID)
|
||||
}
|
||||
|
||||
override fun removeGroupConversation(groupPeerID: String) {
|
||||
messageManager.removePrivateChat(groupPeerID)
|
||||
}
|
||||
|
||||
override fun openGroupConversation(groupPeerID: String) {
|
||||
privateChatManager.startPrivateChat(groupPeerID, mesh)
|
||||
showPrivateChatSheet(groupPeerID)
|
||||
}
|
||||
override fun closeGroupConversation() { endPrivateChat() }
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun addSystemMessage(message: String) {
|
||||
messageManager.addSystemMessage(message)
|
||||
}
|
||||
|
||||
override fun addGroupSystemMessage(groupPeerID: String, message: String) {
|
||||
messageManager.addPrivateMessage(
|
||||
groupPeerID,
|
||||
BitchatMessage(
|
||||
sender = "system",
|
||||
content = message,
|
||||
timestamp = Date(),
|
||||
isPrivate = true,
|
||||
recipientNickname = groupStore.group(groupPeerID)?.name
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun notifyGroupMessage(
|
||||
groupPeerID: String,
|
||||
sender: String,
|
||||
message: String
|
||||
) {
|
||||
notificationManager.showPrivateMessageNotification(groupPeerID, sender, message)
|
||||
}
|
||||
})
|
||||
|
||||
fun handleGroupCommand(arguments: List<String>): GroupCommandResult {
|
||||
private fun handleGroupCommand(arguments: List<String>): GroupCommandResult {
|
||||
val subcommand = arguments.firstOrNull()?.lowercase()
|
||||
?: return GroupCommandResult(false, GROUP_COMMAND_USAGE)
|
||||
val value = arguments.drop(1).joinToString(" ")
|
||||
@ -648,11 +540,7 @@ class ChatViewModel(
|
||||
|
||||
init {
|
||||
observeConversationPresenceWithDisconnectGrace()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
if (groupStore.initialize()) {
|
||||
groupCoordinator.onStoreReady()
|
||||
}
|
||||
}
|
||||
groupRuntime.attach(groupContext)
|
||||
// Note: Mesh service delegate is now set by MainActivity
|
||||
loadAndInitialize()
|
||||
ContactDirectory.initialize(getApplication()) { mesh }
|
||||
@ -874,6 +762,7 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
groupRuntime.detach(groupContext)
|
||||
if (favoriteRelationshipListenerRegistered) {
|
||||
runCatching {
|
||||
FavoritesPersistenceService.shared.removeListener(
|
||||
@ -1205,7 +1094,11 @@ class ChatViewModel(
|
||||
|
||||
if (selectedPeer != null) {
|
||||
if (GroupIds.isGroup(selectedPeer)) {
|
||||
groupCoordinator.sendMessage(content, 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
|
||||
@ -1715,107 +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() {
|
||||
groupCoordinator.suspendForPanic()
|
||||
Log.w(TAG, "Panic wipe started")
|
||||
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()
|
||||
MeshBridgeService.wipe()
|
||||
|
||||
// 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()
|
||||
val groupsCleared = groupStore.wipe()
|
||||
dataManager.clearAllData()
|
||||
conversationListPreferences.clearAll()
|
||||
boardManager.clearTransientState()
|
||||
|
||||
// Clear seen message store and MessageRouter outbox
|
||||
try {
|
||||
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear()
|
||||
} catch (_: Exception) { }
|
||||
try {
|
||||
com.bitchat.android.services.MessageRouter.panicClear(getApplication())
|
||||
} 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.panicReset()
|
||||
} 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 || !groupsCleared) {
|
||||
// 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()
|
||||
com.bitchat.android.services.AppStateStore.resumePrivateConversationsAfterPanic()
|
||||
groupCoordinator.resumeAfterPanic()
|
||||
|
||||
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}")
|
||||
MeshBridgeService.resumeAfterPanic()
|
||||
com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.resume()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1847,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)
|
||||
*/
|
||||
|
||||
@ -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)) }
|
||||
}
|
||||
}
|
||||
@ -79,10 +79,9 @@ class CommandProcessor(
|
||||
return
|
||||
}
|
||||
|
||||
val result = viewModel.handleGroupCommand(
|
||||
parts.drop(1).filter(String::isNotBlank)
|
||||
)
|
||||
addCommandOutput(result.message)
|
||||
viewModel.executeGroupCommand(parts.drop(1).filter(String::isNotBlank)) { result ->
|
||||
addCommandOutput(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addCommandOutput(message: String) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -231,6 +231,18 @@ fun LocationChannelsSheet(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
// Mesh section: icon + title header, offline subtitle, then selection card
|
||||
(selectedChannel as? ChannelID.Location)?.channel?.geohash?.let { cell ->
|
||||
item(key = "share_channel") {
|
||||
androidx.compose.material3.TextButton(onClick = {
|
||||
val link = com.bitchat.android.services.ChannelInvitation.link(cell)
|
||||
if (link != null) context.startActivity(android.content.Intent.createChooser(
|
||||
android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(android.content.Intent.EXTRA_TEXT, context.getString(R.string.channel_invitation, cell, link))
|
||||
}, context.getString(R.string.share_channel)))
|
||||
}, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.share_channel)) }
|
||||
}
|
||||
}
|
||||
item(key = "mesh_card") {
|
||||
Column {
|
||||
SheetIconSectionHeader(
|
||||
|
||||
@ -50,6 +50,7 @@ class MediaSendingManager(
|
||||
// Track in-flight transfer progress: transferId -> messageId and reverse
|
||||
private val transferMessageMap = mutableMapOf<String, String>()
|
||||
private val messageTransferMap = mutableMapOf<String, String>()
|
||||
private val privateTransferMessageIDs = mutableSetOf<String>()
|
||||
private val pendingConsentLock = Any()
|
||||
private val _legacyPrivateMediaConsent = MutableStateFlow<LegacyPrivateMediaConsentRequest?>(null)
|
||||
val legacyPrivateMediaConsent: StateFlow<LegacyPrivateMediaConsentRequest?> =
|
||||
@ -288,10 +289,17 @@ class MediaSendingManager(
|
||||
*/
|
||||
private suspend fun sendPrivateFile(
|
||||
toPeerID: String,
|
||||
filePacket: BitchatFilePacket,
|
||||
originalPacket: BitchatFilePacket,
|
||||
filePath: String,
|
||||
messageType: BitchatMessageType
|
||||
) {
|
||||
val filePacket = if (com.bitchat.android.model.PrivateMediaMessageIdentity.stableID(
|
||||
meshService.myPeerID, meshService.myPeerID, originalPacket.fileName) != null) originalPacket
|
||||
else when (originalPacket.mimeType) {
|
||||
"image/jpeg" -> originalPacket.copy(fileName = "img_${UUID.randomUUID()}.jpg")
|
||||
"audio/mp4", "audio/m4a" -> originalPacket.copy(fileName = "voice_${UUID.randomUUID()}.m4a")
|
||||
else -> originalPacket
|
||||
}
|
||||
val payload = withContext(mediaWorkDispatcher) { filePacket.encode() }
|
||||
?: run {
|
||||
Log.e(TAG, "Failed to encode file packet for private send")
|
||||
@ -454,7 +462,8 @@ class MediaSendingManager(
|
||||
pending.recipientMeshPeerID,
|
||||
pending.filePath,
|
||||
pending.messageType,
|
||||
pending.transferId
|
||||
pending.transferId,
|
||||
pending.filePacket
|
||||
)
|
||||
}
|
||||
|
||||
@ -605,15 +614,20 @@ class MediaSendingManager(
|
||||
recipientMeshPeerID: String,
|
||||
filePath: String,
|
||||
messageType: BitchatMessageType,
|
||||
transferId: String
|
||||
transferId: String,
|
||||
filePacket: BitchatFilePacket
|
||||
) {
|
||||
if (preparation.transfer.transferId != transferId) {
|
||||
Log.e(TAG, "Prepared private-media transfer ID changed; send aborted")
|
||||
return
|
||||
}
|
||||
|
||||
val stableID = if (preparation.transfer.wireMode == com.bitchat.android.mesh.PrivateMediaWireMode.ENCRYPTED_NOISE_0X20) {
|
||||
com.bitchat.android.model.PrivateMediaMessageIdentity.stableID(meshService.myPeerID, recipientMeshPeerID, filePacket.fileName)
|
||||
} else null
|
||||
val expectsReceipt = stableID != null && meshService.supportsPrivateMediaReceipts(recipientMeshPeerID)
|
||||
val msg = BitchatMessage(
|
||||
id = UUID.randomUUID().toString().uppercase(),
|
||||
id = stableID ?: UUID.randomUUID().toString().uppercase(),
|
||||
sender = state.getNicknameValue() ?: "me",
|
||||
content = filePath,
|
||||
type = messageType,
|
||||
@ -638,7 +652,14 @@ class MediaSendingManager(
|
||||
)
|
||||
return
|
||||
}
|
||||
if (expectsReceipt && com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()
|
||||
?.enqueue(msg.id, conversationID, recipientMeshPeerID, filePacket) != true) {
|
||||
messageManager.updateMessageDeliveryStatus(msg.id,
|
||||
com.bitchat.android.model.DeliveryStatus.Failed("Unable to save media for reliable delivery"))
|
||||
return
|
||||
}
|
||||
synchronized(transferMessageMap) {
|
||||
privateTransferMessageIDs += msg.id
|
||||
transferMessageMap[transferId] = msg.id
|
||||
messageTransferMap[msg.id] = transferId
|
||||
}
|
||||
@ -777,16 +798,24 @@ class MediaSendingManager(
|
||||
)
|
||||
synchronized(transferMessageMap) {
|
||||
val msgIdRemoved = transferMessageMap.remove(evt.transferId)
|
||||
if (msgIdRemoved != null) messageTransferMap.remove(msgIdRemoved)
|
||||
if (msgIdRemoved != null) {
|
||||
messageTransferMap.remove(msgIdRemoved)
|
||||
privateTransferMessageIDs.remove(msgIdRemoved)
|
||||
}
|
||||
}
|
||||
} else if (evt.completed) {
|
||||
messageManager.updateMessageDeliveryStatus(
|
||||
msgId,
|
||||
com.bitchat.android.model.DeliveryStatus.Delivered(to = "mesh", at = java.util.Date())
|
||||
if (synchronized(transferMessageMap) { msgId in privateTransferMessageIDs }) {
|
||||
com.bitchat.android.model.DeliveryStatus.Sent
|
||||
} else com.bitchat.android.model.DeliveryStatus.Delivered(to = "mesh", at = java.util.Date())
|
||||
)
|
||||
synchronized(transferMessageMap) {
|
||||
val msgIdRemoved = transferMessageMap.remove(evt.transferId)
|
||||
if (msgIdRemoved != null) messageTransferMap.remove(msgIdRemoved)
|
||||
if (msgIdRemoved != null) {
|
||||
messageTransferMap.remove(msgIdRemoved)
|
||||
privateTransferMessageIDs.remove(msgIdRemoved)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messageManager.updateMessageDeliveryStatus(
|
||||
|
||||
@ -68,6 +68,12 @@ class NotificationManager(
|
||||
private val liveManagers: MutableSet<NotificationManager> =
|
||||
Collections.newSetFromMap(WeakHashMap<NotificationManager, Boolean>())
|
||||
|
||||
fun clearAllForPrivacyChange(context: Context) {
|
||||
synchronized(liveManagers) { liveManagers.toList() }
|
||||
.forEach { it.clearAllNotifications(removeConversationShortcuts = true) }
|
||||
NotificationManagerCompat.from(context).cancelAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes notification action receivers with every manager instance in this process.
|
||||
* Without this, an old in-memory MessagingStyle history could reappear on the next DM.
|
||||
@ -198,8 +204,8 @@ class NotificationManager(
|
||||
|
||||
val notification = PendingNotification(
|
||||
senderPeerID = conversationID,
|
||||
senderNickname = senderNickname,
|
||||
messageContent = messageContent,
|
||||
senderNickname = if (ClientPrivacyPreferences.showNotificationPreviews(context)) senderNickname else context.getString(R.string.app_name),
|
||||
messageContent = if (ClientPrivacyPreferences.showNotificationPreviews(context)) messageContent else context.getString(R.string.notification_hidden_message),
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@ -243,11 +249,9 @@ class NotificationManager(
|
||||
.setKey(senderPeerID)
|
||||
.build()
|
||||
val shortcutID = conversationShortcutID(senderPeerID)
|
||||
publishConversationShortcut(
|
||||
shortcutID = shortcutID,
|
||||
person = person,
|
||||
contentIntent = intent
|
||||
)
|
||||
if (ClientPrivacyPreferences.showNotificationPreviews(context)) {
|
||||
publishConversationShortcut(shortcutID = shortcutID, person = person, contentIntent = intent)
|
||||
}
|
||||
|
||||
// Build notification content
|
||||
val contentText = if (messageCount == 1) {
|
||||
@ -523,8 +527,8 @@ class NotificationManager(
|
||||
|
||||
val notification = GeohashNotification(
|
||||
geohash = geohash,
|
||||
senderNickname = senderNickname,
|
||||
messageContent = messageContent,
|
||||
senderNickname = if (ClientPrivacyPreferences.showNotificationPreviews(context)) senderNickname else context.getString(R.string.app_name),
|
||||
messageContent = if (ClientPrivacyPreferences.showNotificationPreviews(context)) messageContent else context.getString(R.string.notification_hidden_message),
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isMention = isMention,
|
||||
isFirstMessage = isFirstMessage,
|
||||
@ -748,8 +752,8 @@ class NotificationManager(
|
||||
val meshMentionKey = "mesh_mentions"
|
||||
val notification = PendingNotification(
|
||||
senderPeerID = senderPeerID ?: meshMentionKey,
|
||||
senderNickname = senderNickname,
|
||||
messageContent = messageContent,
|
||||
senderNickname = if (ClientPrivacyPreferences.showNotificationPreviews(context)) senderNickname else context.getString(R.string.app_name),
|
||||
messageContent = if (ClientPrivacyPreferences.showNotificationPreviews(context)) messageContent else context.getString(R.string.notification_hidden_message),
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
|
||||
42
app/src/main/java/com/bitchat/android/ui/PanicWipeDialog.kt
Normal file
42
app/src/main/java/com/bitchat/android/ui/PanicWipeDialog.kt
Normal file
@ -0,0 +1,42 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.R
|
||||
|
||||
enum class PanicWipeState { IDLE, CONFIRM, ERASING, COMPLETE, FAILED }
|
||||
|
||||
@Composable
|
||||
fun PanicWipeDialog(state: PanicWipeState, onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
if (state == PanicWipeState.IDLE) return
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (state != PanicWipeState.ERASING) onDismiss() },
|
||||
title = { Text(stringResource(when (state) {
|
||||
PanicWipeState.CONFIRM -> R.string.panic_confirm_title
|
||||
PanicWipeState.ERASING -> R.string.panic_erasing_title
|
||||
PanicWipeState.COMPLETE -> R.string.panic_complete_title
|
||||
else -> R.string.panic_failed_title
|
||||
})) },
|
||||
text = {
|
||||
if (state == PanicWipeState.ERASING) CircularProgressIndicator()
|
||||
else Text(stringResource(when (state) {
|
||||
PanicWipeState.CONFIRM -> R.string.panic_confirm_body
|
||||
PanicWipeState.COMPLETE -> R.string.panic_complete_body
|
||||
else -> R.string.panic_failed_body
|
||||
}))
|
||||
},
|
||||
confirmButton = {
|
||||
if (state != PanicWipeState.ERASING) TextButton(onClick = {
|
||||
if (state == PanicWipeState.CONFIRM || state == PanicWipeState.FAILED) onConfirm() else onDismiss()
|
||||
}) { Text(stringResource(when (state) {
|
||||
PanicWipeState.CONFIRM -> R.string.panic_erase
|
||||
PanicWipeState.FAILED -> R.string.panic_retry
|
||||
else -> android.R.string.ok
|
||||
})) }
|
||||
},
|
||||
dismissButton = {
|
||||
if (state == PanicWipeState.CONFIRM) TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -167,6 +167,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
},
|
||||
hooks = MeshCore.Hooks(
|
||||
onMessageReceived = { message -> handleMessageReceived(message) },
|
||||
onDeliveryReceipt = { id, peer ->
|
||||
com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.acknowledge(id, peer)
|
||||
com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(id, peer)
|
||||
},
|
||||
onAnnounceProcessed = { routed, _ ->
|
||||
publishControllerDebugSnapshot()
|
||||
routed.peerID?.let { pid ->
|
||||
@ -1460,6 +1464,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
meshCore.sendVoiceFrame(recipientPeerID, payload)
|
||||
}
|
||||
|
||||
override fun supportsPrivateMediaReceipts(peerID: String): Boolean = meshCore.supportsPrivateMediaReceipts(peerID)
|
||||
|
||||
override fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
|
||||
@ -694,4 +694,29 @@
|
||||
<string name="about_app_language">App language</string>
|
||||
<string name="about_system_default">System default</string>
|
||||
<string name="about_select_language">Select language</string>
|
||||
<string name="notification_hidden_message" tools:ignore="MissingTranslation">New message</string>
|
||||
<string name="notification_previews" tools:ignore="MissingTranslation">Notification previews</string>
|
||||
<string name="notification_previews_description" tools:ignore="MissingTranslation">Show sender names and message text in notifications. Hidden by default.</string>
|
||||
<string name="custom_relays" tools:ignore="MissingTranslation">Custom relays</string>
|
||||
<string name="custom_relays_description" tools:ignore="MissingTranslation">Add relays for your direct messages. Built-in relays stay available.</string>
|
||||
<string name="relay_url" tools:ignore="MissingTranslation">Relay URL</string>
|
||||
<string name="relay_add" tools:ignore="MissingTranslation">Add relay</string>
|
||||
<string name="relay_remove" tools:ignore="MissingTranslation">Remove</string>
|
||||
<string name="relay_invalid" tools:ignore="MissingTranslation">Enter a valid wss URL without credentials or query parameters. Up to 20 custom relays are supported.</string>
|
||||
<string name="share_channel" tools:ignore="MissingTranslation">Share channel invitation</string>
|
||||
<string name="channel_invitation" tools:ignore="MissingTranslation">Join #%1$s on bitchat: %2$s — get the app at https://bitchat.free</string>
|
||||
<string name="bluetooth_unavailable_banner" tools:ignore="MissingTranslation">Bluetooth is unavailable. Nearby Bluetooth messaging is paused.</string>
|
||||
<string name="tor_failed_banner" tools:ignore="MissingTranslation">Tor could not connect. Internet messages are paused.</string>
|
||||
<string name="tor_connecting_banner" tools:ignore="MissingTranslation">Connecting to Tor (%1$d%%). Internet messages are waiting.</string>
|
||||
<string name="panic_confirm_title" tools:ignore="MissingTranslation">Erase local bitchat data?</string>
|
||||
<string name="panic_confirm_body" tools:ignore="MissingTranslation">This removes conversations, media, keys, favorites, groups, and custom relays from this app. Copies held by other people or relays remain. This cannot be undone.</string>
|
||||
<string name="panic_erasing_title" tools:ignore="MissingTranslation">Erasing local data…</string>
|
||||
<string name="panic_complete_title" tools:ignore="MissingTranslation">Local data erased</string>
|
||||
<string name="panic_complete_body" tools:ignore="MissingTranslation">A new identity is ready. Copies on other devices and relays were not erased.</string>
|
||||
<string name="panic_failed_title" tools:ignore="MissingTranslation">Erase incomplete</string>
|
||||
<string name="panic_failed_body" tools:ignore="MissingTranslation">Some local data could not be erased. Messaging remains paused. Retry to finish.</string>
|
||||
<string name="panic_erase" tools:ignore="MissingTranslation">Erase data</string>
|
||||
<string name="panic_retry" tools:ignore="MissingTranslation">Retry erase</string>
|
||||
<string name="gateway_title" tools:ignore="MissingTranslation">Share internet with the mesh</string>
|
||||
<string name="gateway_description" tools:ignore="MissingTranslation">Carry signed public channel messages between nearby peers and relays. Off by default.</string>
|
||||
</resources>
|
||||
|
||||
@ -41,7 +41,7 @@ class VouchPersistenceTest {
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = manager.clearIdentityData()
|
||||
fun tearDown() { manager.clearIdentityData() }
|
||||
|
||||
@Test
|
||||
fun `vouch persists and derives trust only while voucher remains verified`() {
|
||||
|
||||
@ -439,7 +439,7 @@ class MessageHandlerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `opened courier private message is admitted as its Noise sender`() = runBlocking {
|
||||
fun `opened courier message retains its authenticated sender but cannot acknowledge without persistence`() = runBlocking {
|
||||
whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname)
|
||||
whenever(delegate.getMyNickname()).thenReturn("me")
|
||||
val payload = NoisePayload(
|
||||
@ -447,7 +447,7 @@ class MessageHandlerTest {
|
||||
requireNotNull(PrivateMessagePacket("courier-message", "opaque courier content").encode())
|
||||
).encode()
|
||||
|
||||
assertTrue(
|
||||
assertFalse(
|
||||
handler.handleOpenedCourierPayload(
|
||||
RoutedPacket(encryptedPacket().copy(payload = payload), peerID, "courier-ingress")
|
||||
)
|
||||
|
||||
@ -101,10 +101,11 @@ class IdentityAnnouncementTest {
|
||||
|
||||
@Test
|
||||
fun `local announcement send advertises private media and groups`() {
|
||||
PeerCapabilities.setPhoneFeaturesEnabled(true)
|
||||
val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!!
|
||||
|
||||
assertArrayEquals(
|
||||
byteArrayOf(0x05, 0x02, 0x79, 0x01),
|
||||
byteArrayOf(0x05, 0x02, 0x79, 0x03),
|
||||
encoded.takeLast(4).toByteArray()
|
||||
)
|
||||
val capabilities = IdentityAnnouncement.decode(encoded)!!.capabilities!!
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PrivateMediaMessageIdentityTest {
|
||||
private val sender = "0011223344556677"
|
||||
private val recipient = "8899aabbccddeeff"
|
||||
|
||||
@Test
|
||||
fun `matches the iOS version one golden vector`() {
|
||||
assertEquals("media-910bd42c65060ab76bb6406f220c4516",
|
||||
PrivateMediaMessageIdentity.stableID(sender, recipient,
|
||||
"img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retries keep identity while direction and name changes do not collide`() {
|
||||
val name = "voice_0011223344556677.m4a"
|
||||
val id = PrivateMediaMessageIdentity.stableID(sender, recipient, name)
|
||||
assertNotNull(id)
|
||||
assertTrue(PrivateMediaMessageIdentity.isStableID(id!!))
|
||||
assertNotEquals(id, PrivateMediaMessageIdentity.stableID(recipient, sender, name))
|
||||
assertNotEquals(id, PrivateMediaMessageIdentity.stableID(sender, recipient, "voice_0011223344556678.m4a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ordinary names and paths do not acquire receipt identities`() {
|
||||
listOf("voice_20260908.m4a", "img_20260908.jpg", "../voice_0011223344556677.m4a",
|
||||
"voice_0011223344556677.mp3", "photo.png").forEach {
|
||||
assertNull(PrivateMediaMessageIdentity.stableID(sender, recipient, it))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class CustomRelayUrlTest {
|
||||
@Test fun `normalizes secure relay without dropping path or port`() {
|
||||
assertEquals("wss://relay.example", CustomRelayUrl.normalize(" WSS://RELAY.EXAMPLE/ "))
|
||||
assertEquals("wss://relay.example:8443/nostr", CustomRelayUrl.normalize("wss://relay.example:8443/nostr"))
|
||||
}
|
||||
@Test fun `rejects insecure credentials queries and invalid endpoints`() {
|
||||
listOf("ws://relay.example", "https://relay.example", "wss://user:secret@relay.example",
|
||||
"wss://relay.example?secret=x", "wss://relay.example#fragment", "wss://relay.example:0",
|
||||
"wss://relay.example:65536", "wss:///nostr", "not a URL").forEach {
|
||||
assertNull(it, CustomRelayUrl.normalize(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -68,6 +68,20 @@ class NostrPendingEventQueueTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `revocation invalidates queued and already selected deliveries even after reenable`() {
|
||||
val queue = NostrPendingEventQueue(4)
|
||||
var generation = 1
|
||||
val captured = generation
|
||||
queue.enqueue(event("bridge"), listOf("relay"), null) { generation == captured }
|
||||
val selected = queue.pendingForRelay("relay").single()
|
||||
generation++ // Disable.
|
||||
generation++ // Re-enable; old permission must remain invalid.
|
||||
org.junit.Assert.assertFalse(selected.publicationAllowed())
|
||||
assertEquals(emptyList<NostrPendingEventQueue.Delivery>(), queue.pendingForRelay("relay"))
|
||||
assertEquals(0, queue.size())
|
||||
}
|
||||
|
||||
private fun event(content: String): NostrEvent {
|
||||
val privateKey = "0".repeat(63) + "1"
|
||||
return NostrEvent(
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class ChannelInvitationTest {
|
||||
@Test fun `explicit synthetic cell round trips without location access`() {
|
||||
assertEquals("s000", ChannelInvitation.decode(ChannelInvitation.link("s000")!!))
|
||||
}
|
||||
@Test fun `rejects ambiguous or malformed invitations`() {
|
||||
listOf("https://geohash/s000", "bitchat://geohash/s000?live=1",
|
||||
"bitchat://geohash/s000#extra", "bitchat://user@geohash/s000",
|
||||
"bitchat://geohash:42/s000", "bitchat://geohash/", "bitchat://geohash/invalid",
|
||||
"bitchat://geohash/s000/extra").forEach { assertNull(it, ChannelInvitation.decode(it)) }
|
||||
}
|
||||
}
|
||||
@ -38,6 +38,42 @@ class ConversationRepositoryTest {
|
||||
context.deleteDatabase(databaseName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text receipts require matching durable content and survive deletion`() = runBlocking {
|
||||
repository = ConversationRepository(context, dispatcher, databaseName, InMemoryConversationStorageCipher())
|
||||
val message = BitchatMessage(id = "synthetic-receipt", sender = "alice", content = "durable text",
|
||||
timestamp = Date(100), isPrivate = true, senderPeerID = "peer-alice")
|
||||
org.junit.Assert.assertFalse(repository.hasPrivateTextReceipt(message))
|
||||
assertTrue(repository.upsertMessageAndWait("peer-alice", setOf("peer-alice"), "alice", message, false))
|
||||
assertTrue(repository.hasPrivateTextReceipt(message))
|
||||
org.junit.Assert.assertFalse(repository.hasPrivateTextReceipt(message.copy(senderPeerID = "peer-other")))
|
||||
org.junit.Assert.assertFalse(repository.hasPrivateTextReceipt(message.copy(content = "replacement")))
|
||||
repository.deleteMessage(message.id)
|
||||
repository.awaitPendingWrites()
|
||||
assertTrue(repository.hasPrivateTextReceipt(message))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stable media is acknowledged only while durable or explicitly deleted`() = runBlocking {
|
||||
val cipher = InMemoryConversationStorageCipher()
|
||||
repository = ConversationRepository(context, dispatcher, databaseName, cipher)
|
||||
val id = "media-00112233445566778899aabbccddeeff"
|
||||
val payload = java.io.File(context.filesDir, "synthetic-media.m4a").apply { writeBytes(byteArrayOf(1, 2)) }
|
||||
val message = BitchatMessage(id = id, sender = "alice", content = payload.absolutePath,
|
||||
type = com.bitchat.android.model.BitchatMessageType.Audio, timestamp = Date(100), isPrivate = true)
|
||||
assertEquals(PrivateMediaReceiptState.ABSENT, repository.privateMediaReceiptState(id))
|
||||
assertTrue(repository.upsertMessageAndWait("peer-alice", setOf("peer-alice"), "alice", message, false))
|
||||
assertEquals(PrivateMediaReceiptState.ACCEPTED, repository.privateMediaReceiptState(id))
|
||||
payload.delete()
|
||||
assertEquals(PrivateMediaReceiptState.UNAVAILABLE, repository.privateMediaReceiptState(id))
|
||||
repository.deleteMessage(id)
|
||||
repository.awaitPendingWrites()
|
||||
assertEquals(PrivateMediaReceiptState.TOMBSTONED, repository.privateMediaReceiptState(id))
|
||||
repository.closeForTest()
|
||||
repository = ConversationRepository(context, dispatcher, databaseName, cipher)
|
||||
assertEquals(PrivateMediaReceiptState.TOMBSTONED, repository.privateMediaReceiptState(id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reload restores persisted history after initial process restore`() {
|
||||
repository = ConversationRepository(
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE)
|
||||
class PrivateMediaOutboxStoreTest {
|
||||
private val id = "media-00112233445566778899aabbccddeeff"
|
||||
|
||||
@Test
|
||||
fun `restart retains original payload and attempt count and wipe removes it`() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
File(context.filesDir, "private-media-outbox").deleteRecursively()
|
||||
val cipher = InMemoryConversationStorageCipher()
|
||||
val store = PrivateMediaOutboxStore(context, cipher)
|
||||
val entry = PrivateMediaOutboxStore.Entry(id, "conversation", "0011223344556677",
|
||||
"synthetic-payload", 100, attempts = 4, lastAttemptAt = 500)
|
||||
store.save(entry)
|
||||
assertEquals(listOf(entry), PrivateMediaOutboxStore(context, cipher).load())
|
||||
assertFalse(File(context.filesDir, "private-media-outbox/$id").readText().contains("synthetic-payload"))
|
||||
store.wipe()
|
||||
assertTrue(PrivateMediaOutboxStore(context, cipher).load().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `corrupt record is never retried or silently overwritten on load`() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val directory = File(context.filesDir, "private-media-outbox").apply { mkdirs() }
|
||||
val file = File(directory, id).apply { writeText("corrupt") }
|
||||
assertTrue(PrivateMediaOutboxStore(context, InMemoryConversationStorageCipher()).load().isEmpty())
|
||||
assertTrue(file.exists())
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package com.bitchat.android.services.bridge
|
||||
|
||||
import com.bitchat.android.model.NostrCarrierPacket
|
||||
import com.bitchat.android.nostr.NostrEvent
|
||||
import com.bitchat.android.nostr.NostrKind
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class GatewayEventPolicyTest {
|
||||
private val now = 1_800_000_000L
|
||||
private fun carrier(age: Long = 0, kind: Int = NostrKind.EPHEMERAL_EVENT, cell: String = "s000") =
|
||||
NostrCarrierPacket.fromEvent(NostrCarrierPacket.Direction.TO_GATEWAY, "s000",
|
||||
NostrEvent(pubkey = "00".repeat(32), createdAt = (now - age).toInt(), kind = kind,
|
||||
tags = listOf(listOf("g", cell)), content = "synthetic gateway event"))!!
|
||||
|
||||
@Test fun `accepts exact timestamp boundaries for signature verification`() {
|
||||
listOf(-900L, 0L, 900L).forEach { assertNotNull(GatewayEventPolicy.inspect(carrier(it), now)) }
|
||||
}
|
||||
@Test fun `rejects stale future wrong kind and mismatched channel before signature work`() {
|
||||
assertNull(GatewayEventPolicy.inspect(carrier(901), now))
|
||||
assertNull(GatewayEventPolicy.inspect(carrier(-901), now))
|
||||
assertNull(GatewayEventPolicy.inspect(carrier(kind = NostrKind.TEXT_NOTE), now))
|
||||
assertNull(GatewayEventPolicy.inspect(carrier(cell = "s001"), now))
|
||||
}
|
||||
}
|
||||
123
docs/ios-feature-parity-review.md
Normal file
123
docs/ios-feature-parity-review.md
Normal file
@ -0,0 +1,123 @@
|
||||
# iOS feature parity review
|
||||
|
||||
Reviewed against iOS commit `9b84b361225facd8e623f25d76f889d3dc54a879` and Android main
|
||||
`936a4cdf6d91a944698f7b46528962aef09c1f9c`. The merged-PR window is August 9 through
|
||||
September 8, 2026 (UTC). This is a source and automated-test assessment; it is not
|
||||
an assertion of physical Android/iOS interoperability.
|
||||
|
||||
## Recent iOS changes that matter
|
||||
|
||||
| Merged PR | Android finding and action |
|
||||
| --- | --- |
|
||||
| [#1647 — timestamp sanity](https://github.com/permissionlesstech/bitchat/pull/1647) | Bound verification QR timestamps in both directions without integer overflow; reject implausible Nostr DM rumor timestamps separately from randomized outer envelopes. Validate kind, signature, recipient and rumor/seal consistency on the common decrypt path. |
|
||||
| [#1598 — reachable recent chats](https://github.com/permissionlesstech/bitchat/pull/1598) | Android main already persists and exposes recent private conversations. Preserve that implementation; integrate new outboxes and groups with it. |
|
||||
| [#1597 — connectivity truthfulness](https://github.com/permissionlesstech/bitchat/pull/1597) | Add a persistent Bluetooth/Tor availability banner. Failed handshakes display an open lock; a closed lock requires an established session. |
|
||||
| [#1595 — honest privacy claims](https://github.com/permissionlesstech/bitchat/pull/1595) | Default notification text previews off, make enabling them explicit, and describe panic wipe as local deletion with visible outcomes. |
|
||||
| [#1588 — confirmable panic wipe](https://github.com/permissionlesstech/bitchat/pull/1588) | Replace immediate triple-tap erasure with confirmation, progress, success, and retryable failure. Suspend private writes and background forwarding during erasure; surface failures from persistent stores. |
|
||||
| [#1596 — remove screenshot broadcast](https://github.com/permissionlesstech/bitchat/pull/1596) | No new screenshot reporting is added. This iOS removal is a privacy correction, not a feature to port. |
|
||||
| [#1657](https://github.com/permissionlesstech/bitchat/pull/1657), [#1656](https://github.com/permissionlesstech/bitchat/pull/1656), [#1654](https://github.com/permissionlesstech/bitchat/pull/1654) — localization | Android has its own resource/localization system. New controls use resources; full translation coverage for the newly integrated group/board/settings surface remains follow-up work. |
|
||||
| [#1653](https://github.com/permissionlesstech/bitchat/pull/1653), [#1651](https://github.com/permissionlesstech/bitchat/pull/1651) — deterministic tests | Keep coroutine/queue tests deterministic and exercise observable admission and revocation behavior. Swift-specific timing changes do not require an Android port. |
|
||||
| [#1648 — blocking dead-code scan](https://github.com/permissionlesstech/bitchat/pull/1648) | Preserve Android lint and client rewrite contract gates; Periphery itself is Swift-specific. |
|
||||
|
||||
The immediately preceding [#1645](https://github.com/permissionlesstech/bitchat/pull/1645)
|
||||
(fail-closed handshake identity and secure randomness) and
|
||||
[#1646](https://github.com/permissionlesstech/bitchat/pull/1646) (blocking SwiftLint)
|
||||
were also inspected as context, but merged August 8 and are outside this window.
|
||||
Android already has authenticated Noise peer binding and secure-random primitives;
|
||||
new courier/group paths must preserve those properties.
|
||||
|
||||
## Feature matrix and implementation
|
||||
|
||||
Paths in the iOS column are relative to the iOS repository. Android paths are
|
||||
relative to this repository. Features below also include older iOS functionality
|
||||
still absent from Android main; they are not all attributed to the recent PRs.
|
||||
|
||||
| Capability | iOS evidence | Android main | This implementation |
|
||||
| --- | --- | --- | --- |
|
||||
| Durable private text retries, offline courier | `bitchat/Services/Courier/MessageOutboxStore.swift`, `bitchat/Services/Gateway/BridgeCourierService.swift` | No equivalent durable outbound courier pipeline | Keystore-encrypted text outbox, retry/expiry and authenticated acknowledgements; direct mesh deposits and opt-in relay courier. |
|
||||
| Durable private media receipts | `bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift`, `BLEPrivateMediaSessionStore.swift` | Encrypted private media and PTT already exist; no stable receipt/retry lifecycle | iOS-compatible stable IDs, encrypted bounded media outbox, durable receiver admission, duplicate/tombstone ACKs, matching-recipient retries, and single-row finalized PTT notes. |
|
||||
| One-time prekeys | `bitchat/Services/Prekeys/LocalPrekeyStore.swift`, `PrekeyBundleStore.swift` | Not available | Signed bundle validation, persist-before-use assignment/consumption, refresh and typed synchronization. |
|
||||
| Private groups | `bitchat/Services/Groups/GroupProtocol.swift`, `GroupStore.swift`, `bitchat/ViewModels/ChatGroupCoordinator.swift` | Not available | Creator-signed membership/key epochs, encrypted messages, 16-member bound, replay checks, group commands/list, durable history and process-lifetime handling. |
|
||||
| Signed notices and mesh board synchronization | `bitchat/Protocols/BoardPackets.swift`, `bitchat/Services/Board/BoardManager.swift` | Location notes already exist, signed mesh board absent | Signed creation/deletion, TTL, scoped synchronization, storage and unified notices UI. Preserve existing location privacy gates. |
|
||||
| Vouch attestations | `bitchat/Protocols/VouchAttestation.swift`, `bitchat/ViewModels/ChatVouchCoordinator.swift` | Direct verification only | Signed bounded-age attestations and persisted derived trust; a vouch remains distinct from direct verification. |
|
||||
| Mesh-to-mesh relay bridge | `bitchat/Services/Gateway/BridgeService.swift` | Not available | Explicit opt-in forwarding with event validation, bounded dedup/rate limits, and revocable queued publication permissions. |
|
||||
| Nearby internet gateway | `bitchat/Services/Gateway/GatewayService.swift` | Not available | Separate default-off gateway toggle; signed geohash events use carrier directions 1/2, authenticated capability discovery, timestamp checks, bounded forwarding and relay-echo suppression. |
|
||||
| Custom relays | `bitchat/Nostr/NostrRelaySettings.swift`, `NostrRelayURL.swift` | Built-in relay selection | Persistent custom secure WebSocket relays, URL validation, subscription updates, removal and panic cleanup. |
|
||||
| Incoming text/URL sharing | `bitchat/Services/SharedContentHandoff.swift`, `bitchat/App/SharedContentImportModel.swift` | No incoming text share target | Android text share target stages a bounded draft for user review; never auto-sends. |
|
||||
| Channel invitations | `bitchat/Services/ChannelShare.swift` | No invitation import/export | Explicit `bitchat://geohash/…` links and share action; import selects a manual channel without requesting device location. |
|
||||
| Notification privacy | `bitchat/Services/NotificationPrivacySettings.swift` | Sender/text previews exposed by default | Generic notification content by default and explicit preview control; turning previews off clears existing notification/shortcut surfaces. |
|
||||
| Ping/route diagnostics | iOS mesh diagnostic packet/command paths | No matching commands | Versioned ping/pong payloads, bounded TTL/rate handling, `/ping`, `/trace` and mesh topology display. |
|
||||
|
||||
Android main already supports Noise encryption, Nostr/Tor location chat, QR
|
||||
verification, private image/audio transfer, live PTT, Cashu handling, location
|
||||
notes and retained private conversations. These are integration constraints,
|
||||
not missing features. Peer-ID rotation is not included: an iOS primitive alone
|
||||
does not establish an end-to-end feature to advertise.
|
||||
|
||||
## Implementation strategy
|
||||
|
||||
1. Reuse and integrate the existing Android work for courier delivery, groups,
|
||||
signed boards, vouching, bridging and diagnostics instead of introducing a
|
||||
competing router or conversation database. The integration builds on PRs
|
||||
[#877](https://github.com/permissionlesstech/bitchat-android/pull/877),
|
||||
[#769](https://github.com/permissionlesstech/bitchat-android/pull/769),
|
||||
[#768](https://github.com/permissionlesstech/bitchat-android/pull/768),
|
||||
[#778](https://github.com/permissionlesstech/bitchat-android/pull/778) and
|
||||
[#770](https://github.com/permissionlesstech/bitchat-android/pull/770), with
|
||||
verification hardening informed by #910 and #927.
|
||||
2. Treat durable receipt semantics, authenticated capabilities, timestamp checks,
|
||||
panic admission gates and publication revocation as prerequisites for safe
|
||||
feature exposure. A relay accepting an event is not a recipient delivery ACK.
|
||||
3. Own group and retry work at application/process lifetime. Activity attachment
|
||||
supplies navigation only; it must not decide whether an encrypted message is
|
||||
persisted or acknowledged.
|
||||
4. Keep phone-only capability bits disabled on Wear until their runtimes are
|
||||
present. Shared packet parsing/sync comes from `app/` through the Wear source
|
||||
include list. Private-media encryption remains independent of receipt support.
|
||||
5. Validate wire vectors and storage/race behavior locally, then run physical
|
||||
Mesh Lab and iOS interop before treating parity as release-ready.
|
||||
|
||||
## Wire and persistence contract
|
||||
|
||||
- Existing wire values are retained. Group invite/update use Noise types 6/7;
|
||||
group messages use packet `0x25`; diagnostics use `0x26`/`0x27`.
|
||||
- Capability flags are advertised only when the owning runtime is present:
|
||||
prekeys bit 0, gateway bit 2, groups bit 3, boards bit 4, vouch bit 5,
|
||||
diagnostics bit 6, bridge bit 7, private media bit 8 and media receipts bit 9.
|
||||
Gateway and bridge bits follow their independent opt-in settings.
|
||||
- Typed gossip includes signed boards, prekeys and groups, retaining compatibility
|
||||
with legacy sync masks. See [sync.md](sync.md).
|
||||
- Stable media message IDs hash the iOS domain and length-prefixed sender,
|
||||
recipient and supported filename. `PrivateMediaMessageIdentityTest` contains
|
||||
an exact iOS-compatible vector. Original encoded payloads survive retries.
|
||||
- Private-media outbox limits: 100 records, 8 MiB per record, 64 MiB total,
|
||||
eight attempts and 24-hour lifetime. Only authenticated live receipt support
|
||||
enables retry. Missing/corrupt storage does not produce a delivery ACK.
|
||||
- A deleted received message remains acknowledged through its durable tombstone;
|
||||
replay must not recreate it. Encrypted media also requires a readable saved file.
|
||||
- Bridge/gateway publication permissions carry a generation. Turning a feature
|
||||
off invalidates both disconnected-queue entries and already-selected writes;
|
||||
re-enabling cannot revive old permission.
|
||||
- Channel invitation tests use synthetic cells only. No implementation/test
|
||||
workflow requires reading real device location.
|
||||
|
||||
## Release-readiness and remaining work
|
||||
|
||||
Local validation results and visual evidence are reported in the pull request.
|
||||
Physical Mesh Lab and Android-to-iOS interoperability are required separately:
|
||||
|
||||
- Offline text/media delivery, process death and restart, duplicate receipt after
|
||||
deletion, blocked/expired/corrupt payloads and panic during retry.
|
||||
- BLE and Wi-Fi Aware delivery with the Activity closed; multi-hop courier with
|
||||
the original sender unavailable; phone-to-watch capability fallback.
|
||||
- Group invite/update/removal, stale epochs, restart and background notification.
|
||||
- Board signature/delete/sync and bridge/gateway disable/re-enable while relays
|
||||
disconnect or delayed writes are queued.
|
||||
- One-time-prekey races and consumption persistence, plus iOS vectors on both
|
||||
real clients. Static screenshots cannot establish any of these properties.
|
||||
|
||||
Full locale coverage, accessibility review and the physical matrix remain
|
||||
release work. Manual-channel Nostr board publication still obeys the existing
|
||||
location-note privacy gate; this integration does not weaken it to force a
|
||||
successful publish. No claim is made that this large integration is ready to
|
||||
merge without the hardware and cross-client review above.
|
||||
Loading…
x
Reference in New Issue
Block a user