Merge main into background power optimization

This commit is contained in:
callebtc 2026-07-27 17:47:58 +02:00
commit 6703f1bd20
104 changed files with 6762 additions and 887 deletions

6
.gitignore vendored
View File

@ -7,6 +7,7 @@ build/
!*/build/intermediates/
local.properties
.gradle/
.kotlin/
captures/
.externalNativeBuild/
debug_keystore/
@ -40,6 +41,11 @@ dependency-reduced-pom.xml
# Linters
.lint/
# Python test tooling
**/__pycache__/
*.py[cod]
release-gate-results/
# Other
*.log
.cxx/

View File

@ -21,6 +21,7 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.Lifecycle
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.onboarding.BluetoothCheckScreen
import com.bitchat.android.onboarding.BluetoothStatus
import com.bitchat.android.onboarding.BluetoothStatusManager
@ -743,6 +744,9 @@ class MainActivity : OrientationAwareActivity() {
override fun onResume() {
super.onResume()
// Revoke stale live-location work before any resumed UI can use cached channels.
LocationChannelManager.getInstance(applicationContext).syncPermissionState()
// Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
@ -817,7 +821,7 @@ class MainActivity : OrientationAwareActivity() {
val geohash = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_GEOHASH)
if (geohash != null) {
Log.d("MainActivity", "Opening geohash chat #$geohash from notification")
Log.d("MainActivity", "Opening geohash chat from notification")
// Switch to the geohash channel - create appropriate geohash channel level
val level = when (geohash.length) {

View File

@ -14,27 +14,53 @@ class AndroidGeocoderProvider(context: Context) : GeocoderProvider {
private val geocoder = Geocoder(context, Locale.getDefault())
private val TAG = "AndroidGeocoderProvider"
override suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address> {
override suspend fun getFromLocation(
latitude: Double,
longitude: Double,
maxResults: Int,
liveLocationToken: Long?
): List<Address> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
suspendCancellableCoroutine { cont ->
try {
geocoder.getFromLocation(
latitude,
longitude,
maxResults,
object : Geocoder.GeocodeListener {
override fun onGeocode(addresses: MutableList<Address>) {
if (cont.isActive) cont.resume(addresses)
}
val startRequest = {
geocoder.getFromLocation(
latitude,
longitude,
maxResults,
object : Geocoder.GeocodeListener {
override fun onGeocode(addresses: MutableList<Address>) {
if (cont.isActive) {
val result = if (liveLocationToken == null ||
LiveLocationPrivacyGate.accepts(liveLocationToken)
) {
addresses
} else {
emptyList()
}
cont.resume(result)
}
}
override fun onError(errorMessage: String?) {
if (cont.isActive) {
Log.e(TAG, "Geocode error: $errorMessage")
cont.resume(emptyList())
override fun onError(errorMessage: String?) {
if (cont.isActive) {
Log.e(TAG, "Geocode error")
cont.resume(emptyList())
}
}
}
}
)
)
}
val started = if (liveLocationToken == null) {
startRequest()
true
} else {
LiveLocationPrivacyGate.runIfAllowed(
liveLocationToken,
startRequest
)
}
if (!started && cont.isActive) cont.resume(emptyList())
} catch (e: Exception) {
if (cont.isActive) cont.resumeWithException(e)
}
@ -42,9 +68,27 @@ class AndroidGeocoderProvider(context: Context) : GeocoderProvider {
} else {
@Suppress("DEPRECATION")
try {
geocoder.getFromLocation(latitude, longitude, maxResults) ?: emptyList()
if (liveLocationToken != null &&
!LiveLocationPrivacyGate.accepts(liveLocationToken)
) return emptyList()
// This legacy API blocks and cannot be cancelled. Never hold the privacy
// gate's read lock across the call: revocation must remain immediate.
val addresses = geocoder.getFromLocation(
latitude,
longitude,
maxResults
) ?: emptyList()
if (liveLocationToken == null ||
LiveLocationPrivacyGate.accepts(liveLocationToken)
) {
addresses
} else {
emptyList()
}
} catch (e: Exception) {
Log.e(TAG, "Geocode failed", e)
Log.e(TAG, "Geocode failed")
emptyList()
}
}

View File

@ -9,8 +9,9 @@ import android.os.Looper
import android.util.Log
import androidx.core.app.ActivityCompat
import com.google.android.gms.location.*
import com.google.android.gms.tasks.CancellationTokenSource
class FusedLocationProvider(private val context: Context) : LocationProvider {
internal class FusedLocationProvider(private val context: Context) : LocationProvider {
companion object {
private const val TAG = "FusedLocationProvider"
@ -20,10 +21,13 @@ class FusedLocationProvider(private val context: Context) : LocationProvider {
// Map to keep track of callbacks to remove them later
private val activeCallbacks = mutableMapOf<(Location) -> Unit, LocationCallback>()
private val activeCurrentLocationRequests = mutableSetOf<CancellationTokenSource>()
private fun hasLocationPermission(): Boolean {
return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
return LiveLocationPrivacyGate.isEnabled &&
(ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
)
}
@SuppressLint("MissingPermission")
@ -36,14 +40,14 @@ class FusedLocationProvider(private val context: Context) : LocationProvider {
try {
fusedLocationClient.lastLocation
.addOnSuccessListener { location ->
callback(location)
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
}
.addOnFailureListener { e ->
Log.e(TAG, "Error getting last known fused location: ${e.message}")
Log.e(TAG, "Error getting last-known fused location")
callback(null)
}
} catch (e: Exception) {
Log.e(TAG, "Exception getting last known fused location: ${e.message}")
Log.e(TAG, "Exception getting last-known fused location")
callback(null)
}
}
@ -60,17 +64,27 @@ class FusedLocationProvider(private val context: Context) : LocationProvider {
.setPriority(Priority.PRIORITY_HIGH_ACCURACY)
.setDurationMillis(30000)
.build()
val cancellation = CancellationTokenSource()
fusedLocationClient.getCurrentLocation(request, null)
synchronized(activeCurrentLocationRequests) {
activeCurrentLocationRequests.add(cancellation)
}
fusedLocationClient.getCurrentLocation(request, cancellation.token)
.addOnSuccessListener { location ->
callback(location)
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
}
.addOnFailureListener { e ->
Log.e(TAG, "Error getting fresh fused location: ${e.message}")
Log.e(TAG, "Error getting fresh fused location")
callback(null)
}
.addOnCompleteListener {
synchronized(activeCurrentLocationRequests) {
activeCurrentLocationRequests.remove(cancellation)
}
}
} catch (e: Exception) {
Log.e(TAG, "Exception getting fresh fused location: ${e.message}")
Log.e(TAG, "Exception getting fresh fused location")
callback(null)
}
}
@ -91,7 +105,9 @@ class FusedLocationProvider(private val context: Context) : LocationProvider {
val locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { callback(it) }
if (LiveLocationPrivacyGate.isEnabled) {
result.lastLocation?.let { callback(it) }
}
}
}
@ -107,7 +123,7 @@ class FusedLocationProvider(private val context: Context) : LocationProvider {
Log.d(TAG, "Registered fused updates")
} catch (e: Exception) {
Log.e(TAG, "Error requesting fused updates: ${e.message}")
Log.e(TAG, "Error requesting fused updates")
}
}
@ -122,21 +138,25 @@ class FusedLocationProvider(private val context: Context) : LocationProvider {
Log.d(TAG, "Removed fused updates")
}
} catch (e: Exception) {
Log.e(TAG, "Error removing fused updates: ${e.message}")
Log.e(TAG, "Error removing fused updates")
}
}
override fun cancel() {
try {
synchronized(activeCallbacks) {
for ((callback, locationCallback) in activeCallbacks) {
for ((_, locationCallback) in activeCallbacks) {
fusedLocationClient.removeLocationUpdates(locationCallback)
}
activeCallbacks.clear()
}
synchronized(activeCurrentLocationRequests) {
activeCurrentLocationRequests.forEach { it.cancel() }
activeCurrentLocationRequests.clear()
}
Log.d(TAG, "Cancelled all fused updates")
} catch (e: Exception) {
Log.e(TAG, "Error cancelling fused provider: ${e.message}")
Log.e(TAG, "Error cancelling fused provider")
}
}
}

View File

@ -9,5 +9,10 @@ interface GeocoderProvider {
/**
* Get a list of Address objects from latitude and longitude.
*/
suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address>
suspend fun getFromLocation(
latitude: Double,
longitude: Double,
maxResults: Int,
liveLocationToken: Long? = null
): List<Address>
}

View File

@ -112,7 +112,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
_bookmarks.value = ordered
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load bookmarks: ${e.message}")
Log.e(TAG, "Failed to load bookmarks")
}
try {
val namesJson = prefs.getString(NAMES_STORE_KEY, null)
@ -122,7 +122,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
_bookmarkNames.value = dict
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load bookmark names: ${e.message}")
Log.e(TAG, "Failed to load bookmark names")
}
}
@ -155,7 +155,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
resolving.clear()
Log.i(TAG, "Cleared all geohash bookmarks and names")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear geohash bookmarks: ${e.message}")
Log.e(TAG, "Failed to clear geohash bookmarks")
}
}
@ -213,7 +213,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
persistNames(current)
}
} catch (e: Exception) {
Log.w(TAG, "Name resolution failed for #$gh: ${e.message}")
Log.w(TAG, "Bookmark name resolution failed")
} finally {
resolving.remove(gh)
}

View File

@ -0,0 +1,24 @@
package com.bitchat.android.geohash
internal object GeohashNostrPrivacyPolicy {
fun livePresenceTargets(
availableChannels: Collection<GeohashChannel>,
liveLocationEnabled: Boolean,
): Set<String> {
if (!liveLocationEnabled) return emptySet()
return availableChannels
.asSequence()
.filter { it.level.precision <= GeohashChannelLevel.CITY.precision }
.map { it.geohash }
.toSet()
}
fun samplingTargets(
liveLocationGeohashes: Collection<String>,
userSelectedGeohashes: Collection<String>,
liveLocationEnabled: Boolean,
): Set<String> = buildSet {
addAll(userSelectedGeohashes)
if (liveLocationEnabled) addAll(liveLocationGeohashes)
}
}

View File

@ -0,0 +1,119 @@
package com.bitchat.android.geohash
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Process-wide, fail-closed consent gate for accessing live device location.
*
* A generation token prevents callbacks that were started under an older consent
* state from being accepted after location access is disabled or re-enabled.
*/
internal class LiveLocationAccessPolicy(
initialEnabled: Boolean = DEFAULT_LIVE_LOCATION_ENABLED,
) {
private val accessLock = ReentrantReadWriteLock()
private val generation = AtomicLong(0L)
private val _enabled = MutableStateFlow(initialEnabled)
private var accessAvailable = initialEnabled
val enabled: StateFlow<Boolean> = _enabled.asStateFlow()
val isEnabled: Boolean
get() = _enabled.value
fun update(enabled: Boolean) {
accessLock.write {
generation.incrementAndGet()
_enabled.value = enabled
accessAvailable = enabled
}
}
fun invalidate() {
accessLock.write {
generation.incrementAndGet()
accessAvailable = false
}
}
fun resumeAccess() {
accessLock.write {
if (_enabled.value && !accessAvailable) {
generation.incrementAndGet()
accessAvailable = true
}
}
}
fun captureToken(): Long? =
accessLock.read {
val capturedGeneration = generation.get()
capturedGeneration.takeIf {
_enabled.value &&
accessAvailable &&
generation.get() == capturedGeneration
}
}
fun accepts(token: Long): Boolean =
accessLock.read {
_enabled.value && accessAvailable && generation.get() == token
}
fun runIfAllowed(token: Long, action: () -> Unit): Boolean =
accessLock.read {
if (!_enabled.value || !accessAvailable || generation.get() != token) {
false
} else {
action()
true
}
}
}
internal const val DEFAULT_LIVE_LOCATION_ENABLED = false
internal object LiveLocationPrivacyGate {
private val policy = LiveLocationAccessPolicy()
private val revocationListeners = CopyOnWriteArraySet<() -> Unit>()
val enabled: StateFlow<Boolean> = policy.enabled
val isEnabled: Boolean
get() = policy.isEnabled
fun update(enabled: Boolean) {
policy.update(enabled)
notifyRevoked()
}
fun invalidate() {
policy.invalidate()
notifyRevoked()
}
fun captureToken(): Long? = policy.captureToken()
fun resumeAccess() = policy.resumeAccess()
fun accepts(token: Long): Boolean = policy.accepts(token)
fun runIfAllowed(token: Long, action: () -> Unit): Boolean =
policy.runIfAllowed(token, action)
fun addRevocationListener(listener: () -> Unit) {
revocationListeners.add(listener)
}
fun removeRevocationListener(listener: () -> Unit) {
revocationListeners.remove(listener)
}
private fun notifyRevoked() {
revocationListeners.forEach { listener ->
runCatching(listener)
}
}
}

View File

@ -4,16 +4,14 @@ import android.Manifest
import android.content.Context
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.location.Geocoder
import android.location.Location
import android.location.LocationManager
import android.os.Bundle
import android.util.Log
import androidx.core.app.ActivityCompat
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.bitchat.android.nostr.NostrIdentityBridge
import kotlinx.coroutines.*
import java.util.*
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import kotlinx.coroutines.flow.MutableStateFlow
@ -47,13 +45,19 @@ class LocationChannelManager private constructor(private val context: Context) {
AUTHORIZED
}
enum class LocationSelectionSource {
NEARBY,
MANUAL
}
private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private val locationProvider: LocationProvider
private val geocoderProvider: GeocoderProvider = GeocoderFactory.get(context)
private var lastLocation: Location? = null
private var geocodingJob: Job? = null
private val gson = Gson()
private var dataManager: com.bitchat.android.ui.DataManager? = null
private var selectedLocationSource: LocationSelectionSource? = null
private var activeLocationUpdateCallback: ((Location) -> Unit)? = null
private fun checkSystemLocationEnabled(): Boolean {
return try {
@ -70,14 +74,13 @@ class LocationChannelManager private constructor(private val context: Context) {
val isEnabled = checkSystemLocationEnabled()
Log.d(TAG, "System location state changed: $isEnabled")
_systemLocationEnabled.value = isEnabled
if (!isEnabled) {
clearLiveLocationState()
}
}
}
}
private val locationUpdateCallback: (Location) -> Unit = { location ->
onLocationUpdated(location)
}
// Published state for UI bindings (matching iOS @Published properties)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@ -99,8 +102,7 @@ class LocationChannelManager private constructor(private val context: Context) {
private val _isLoadingLocation = MutableStateFlow(false)
val isLoadingLocation: StateFlow<Boolean> = _isLoadingLocation
private val _locationServicesEnabled = MutableStateFlow(false)
val locationServicesEnabled: StateFlow<Boolean> = _locationServicesEnabled
val locationServicesEnabled: StateFlow<Boolean> = LiveLocationPrivacyGate.enabled
private val _systemLocationEnabled = MutableStateFlow(checkSystemLocationEnabled())
val systemLocationEnabled: StateFlow<Boolean> = _systemLocationEnabled
@ -127,12 +129,14 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.i(TAG, "Using SystemLocationProvider (Native LocationManager)")
SystemLocationProvider(context)
}
LiveLocationPrivacyGate.addRevocationListener(::cancelLiveLocationWork)
checkAndSyncPermission()
// Initialize DataManager and load persisted settings
dataManager = com.bitchat.android.ui.DataManager(context)
loadPersistedChannelSelection()
loadLocationServicesState()
syncPermissionState()
if (!_systemLocationEnabled.value) clearLiveLocationState()
loadPersistedChannelSelection()
// Register for system location changes
context.registerReceiver(locationStateReceiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION))
@ -145,17 +149,13 @@ class LocationChannelManager private constructor(private val context: Context) {
* UNIFIED: Only requests location if location services are enabled by user
*/
fun enableLocationChannels() {
if (!_locationServicesEnabled.value || !_systemLocationEnabled.value) {
if (!LiveLocationPrivacyGate.isEnabled || !_systemLocationEnabled.value) {
Log.w(TAG, "Location services disabled (app or system) - not requesting location")
return
}
if (getCurrentPermissionStatus() == PermissionState.AUTHORIZED) {
_permissionState.value = PermissionState.AUTHORIZED
if (syncPermissionState() == PermissionState.AUTHORIZED) {
requestOneShotLocation()
} else {
_permissionState.value = PermissionState.DENIED
}
}
@ -163,7 +163,7 @@ class LocationChannelManager private constructor(private val context: Context) {
* Refresh available channels from current location
*/
fun refreshChannels() {
if (_permissionState.value == PermissionState.AUTHORIZED && isLocationServicesEnabled()) {
if (syncPermissionState() == PermissionState.AUTHORIZED && isLocationServicesEnabled()) {
requestOneShotLocation()
}
}
@ -173,7 +173,7 @@ class LocationChannelManager private constructor(private val context: Context) {
* Uses requestLocationUpdates for continuous updates, plus a one-shot to prime state immediately
*/
fun beginLiveRefresh(interval: Long = 5000L) {
if (_permissionState.value != PermissionState.AUTHORIZED) {
if (syncPermissionState() != PermissionState.AUTHORIZED) {
Log.w(TAG, "Cannot start live refresh - permission not authorized")
return
}
@ -183,12 +183,28 @@ class LocationChannelManager private constructor(private val context: Context) {
return
}
endLiveRefresh()
LiveLocationPrivacyGate.resumeAccess()
val token = LiveLocationPrivacyGate.captureToken() ?: return
val callback: (Location) -> Unit = { location ->
if (canUseLiveLocation(token)) {
onLocationUpdated(location, token)
}
}
activeLocationUpdateCallback = callback
// Register for continuous updates from available provider
locationProvider.requestLocationUpdates(
intervalMs = interval,
minDistanceMeters = 5f,
callback = locationUpdateCallback
)
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
locationProvider.requestLocationUpdates(
intervalMs = interval,
minDistanceMeters = 5f,
callback = callback
)
}
if (!started) {
activeLocationUpdateCallback = null
return
}
// Prime state immediately with last known / current location
requestOneShotLocation()
@ -198,53 +214,56 @@ class LocationChannelManager private constructor(private val context: Context) {
* Stop periodic refreshes when selector UI is dismissed
*/
fun endLiveRefresh() {
locationProvider.removeLocationUpdates(locationUpdateCallback)
activeLocationUpdateCallback?.let(locationProvider::removeLocationUpdates)
activeLocationUpdateCallback = null
}
/**
* Select a channel
* Generic selection is intentionally treated as manual. GPS-derived selections must use
* [selectNearby] so their provenance can be revoked when live location is disabled.
*/
fun select(channel: ChannelID) {
Log.d(TAG, "Selected channel: ${channel.displayName}")
// Use synchronous set to avoid race with background recomputation
_selectedChannel.value = channel
saveChannelSelection(channel)
// Immediately recompute teleported status against the latest known location
lastLocation?.let { location ->
when (channel) {
is ChannelID.Mesh -> {
_teleported.value = false
}
is ChannelID.Location -> {
val currentGeohash = Geohash.encode(
latitude = location.latitude,
longitude = location.longitude,
precision = channel.channel.level.precision
)
val isTeleportedNow = currentGeohash != channel.channel.geohash
_teleported.value = isTeleportedNow
}
}
when (channel) {
ChannelID.Mesh -> selectInternal(ChannelID.Mesh, source = null, teleported = false)
is ChannelID.Location -> selectManual(channel.channel)
}
}
/**
* Set teleported status (for manual geohash teleportation)
*/
fun setTeleported(teleported: Boolean) {
_teleported.value = teleported
fun selectNearby(channel: GeohashChannel): Boolean {
val isCurrentNearbyChannel = _availableChannels.value.contains(channel)
if (!isCurrentNearbyChannel || !isLocationServicesEnabled() ||
syncPermissionState() != PermissionState.AUTHORIZED
) {
Log.w(TAG, "Blocked nearby channel selection without live-location access")
return false
}
selectInternal(
ChannelID.Location(channel),
source = LocationSelectionSource.NEARBY,
teleported = false
)
return true
}
fun selectManual(channel: GeohashChannel, teleported: Boolean = true) {
selectInternal(
ChannelID.Location(channel),
source = LocationSelectionSource.MANUAL,
teleported = teleported || !LiveLocationPrivacyGate.isEnabled
)
}
/**
* Enable location services (user-controlled toggle)
*/
fun enableLocationServices() {
_locationServicesEnabled.value = true
saveLocationServicesState(true)
if (!LiveLocationPrivacyGate.isEnabled) {
LiveLocationPrivacyGate.update(true)
saveLocationServicesState(true)
}
// If we have permission and system location is on, start location operations
if (_permissionState.value == PermissionState.AUTHORIZED && systemLocationEnabled.value) {
if (syncPermissionState() == PermissionState.AUTHORIZED && systemLocationEnabled.value) {
requestOneShotLocation()
}
}
@ -253,20 +272,9 @@ class LocationChannelManager private constructor(private val context: Context) {
* Disable location services (user-controlled toggle)
*/
fun disableLocationServices() {
_locationServicesEnabled.value = false
LiveLocationPrivacyGate.update(false)
saveLocationServicesState(false)
// Stop any ongoing location operations
endLiveRefresh()
// Clear available channels when location is disabled
_availableChannels.value = emptyList()
_locationNames.value = emptyMap()
// If user had a location channel selected, switch back to mesh
if (_selectedChannel.value is ChannelID.Location) {
select(ChannelID.Mesh)
}
clearLiveLocationState(invalidateAccess = false)
}
/**
@ -276,70 +284,163 @@ class LocationChannelManager private constructor(private val context: Context) {
* Check if both the app toggle and system location are enabled
*/
fun isLocationServicesEnabled(): Boolean {
return _locationServicesEnabled.value && _systemLocationEnabled.value
return LiveLocationPrivacyGate.isEnabled && _systemLocationEnabled.value
}
fun canUseSelectedLocationChannel(channel: GeohashChannel): Boolean {
if (_selectedChannel.value != ChannelID.Location(channel)) return false
return selectedLocationSource == LocationSelectionSource.MANUAL ||
LiveLocationPrivacyGate.captureToken() != null
}
fun isSelectedChannelLiveDerived(channel: GeohashChannel): Boolean =
_selectedChannel.value == ChannelID.Location(channel) &&
selectedLocationSource == LocationSelectionSource.NEARBY
fun liveLocationTokenForSelectedChannel(channel: GeohashChannel): Long? {
if (!isSelectedChannelLiveDerived(channel)) return null
return LiveLocationPrivacyGate.captureToken()
}
private fun selectInternal(
channel: ChannelID,
source: LocationSelectionSource?,
teleported: Boolean
) {
selectedLocationSource = source
_teleported.value = when (channel) {
ChannelID.Mesh -> false
is ChannelID.Location -> teleported
}
_selectedChannel.value = channel
saveChannelSelection(channel, source)
}
/**
* Revokes all GPS-derived in-memory state and pending work. This deliberately does not
* change the persisted soft setting when Android's hard permission or system provider is
* temporarily unavailable.
*/
private fun clearLiveLocationState(invalidateAccess: Boolean = true) {
if (invalidateAccess) LiveLocationPrivacyGate.invalidate()
cancelLiveLocationWork()
_isLoadingLocation.value = false
NostrIdentityBridge.clearGeohashIdentityCache(
_availableChannels.value.map { it.geohash }
)
_availableChannels.value = emptyList()
_locationNames.value = emptyMap()
when {
_selectedChannel.value is ChannelID.Location &&
selectedLocationSource == LocationSelectionSource.NEARBY -> {
selectInternal(ChannelID.Mesh, source = null, teleported = false)
}
_selectedChannel.value is ChannelID.Location -> {
// Manual channels remain usable for teleports, bookmarks, and DMs. Never use
// a previously retained GPS fix to classify them while live access is off.
selectedLocationSource = LocationSelectionSource.MANUAL
_teleported.value = true
saveChannelSelection(_selectedChannel.value, selectedLocationSource)
}
}
}
private fun cancelLiveLocationWork() {
locationProvider.cancel()
activeLocationUpdateCallback = null
geocodingJob?.cancel()
geocodingJob = null
}
// MARK: - Location Operations
private fun requestOneShotLocation() {
if (!checkAndSyncPermission()) {
if (!isLocationServicesEnabled() ||
syncPermissionState() != PermissionState.AUTHORIZED
) {
Log.w(TAG, "No location permission for one-shot request")
return
}
// Set loading state initially
LiveLocationPrivacyGate.resumeAccess()
val token = LiveLocationPrivacyGate.captureToken() ?: return
_isLoadingLocation.value = true
locationProvider.getLastKnownLocation { cached ->
// If we have a cached location and it's reasonably recent (e.g. < 5 mins), use it
// For now, we just use it if it exists, similar to previous logic
if (cached != null) {
onLocationUpdated(cached)
} else {
locationProvider.requestFreshLocation { fresh ->
if (fresh != null) {
onLocationUpdated(fresh)
} else {
Log.w(TAG, "Failed to get fresh location")
_isLoadingLocation.value = false
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
locationProvider.getLastKnownLocation { cached ->
if (!canUseLiveLocation(token)) return@getLastKnownLocation
if (cached != null) {
onLocationUpdated(cached, token)
} else {
LiveLocationPrivacyGate.runIfAllowed(token) {
locationProvider.requestFreshLocation { fresh ->
if (!canUseLiveLocation(token)) return@requestFreshLocation
if (fresh != null) {
onLocationUpdated(fresh, token)
} else {
Log.w(TAG, "Failed to get fresh location")
_isLoadingLocation.value = false
}
}
}
}
}
}
if (!started) _isLoadingLocation.value = false
}
private fun onLocationUpdated(location: Location) {
lastLocation = location
_isLoadingLocation.value = false
computeChannels(location)
reverseGeocodeIfNeeded(location)
private fun onLocationUpdated(location: Location, token: Long) {
LiveLocationPrivacyGate.runIfAllowed(token) {
if (!_systemLocationEnabled.value || !hasRuntimeLocationPermission()) return@runIfAllowed
_isLoadingLocation.value = false
computeChannels(location, token)
reverseGeocodeIfNeeded(location, token)
}
}
// MARK: - Helpers
private fun getCurrentPermissionStatus(): PermissionState {
return if (checkAndSyncPermission()) {
private fun hasRuntimeLocationPermission(): Boolean {
return ActivityCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
fun syncPermissionState(): PermissionState {
val newState = if (hasRuntimeLocationPermission()) {
PermissionState.AUTHORIZED
} else {
PermissionState.DENIED
}
}
private fun checkAndSyncPermission(): Boolean {
val hasPermission = ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
val newState = if (hasPermission) PermissionState.AUTHORIZED else PermissionState.DENIED
if (_permissionState.value != newState) {
_permissionState.value = newState
}
return hasPermission
if (newState == PermissionState.DENIED) {
clearLiveLocationState()
}
return newState
}
private fun computeChannels(location: Location) {
private fun canUseLiveLocation(token: Long): Boolean {
return LiveLocationPrivacyGate.accepts(token) &&
_systemLocationEnabled.value &&
hasRuntimeLocationPermission()
}
private fun computeChannels(location: Location, token: Long) {
if (!canUseLiveLocation(token)) return
val levels = GeohashChannelLevel.allCases()
val result = mutableListOf<GeohashChannel>()
@ -351,47 +452,55 @@ class LocationChannelManager private constructor(private val context: Context) {
)
result.add(GeohashChannel(level = level, geohash = geohash))
}
if (!canUseLiveLocation(token)) return
_availableChannels.value = result
// Recompute teleported status based on current location vs selected channel
val selectedChannelValue = _selectedChannel.value
when (selectedChannelValue) {
is ChannelID.Mesh -> {
_teleported.value = false
}
is ChannelID.Location -> {
val currentGeohash = Geohash.encode(
latitude = location.latitude,
longitude = location.longitude,
precision = selectedChannelValue.channel.level.precision
)
val isTeleported = currentGeohash != selectedChannelValue.channel.geohash
_teleported.value = isTeleported
}
if (selectedChannelValue is ChannelID.Location &&
selectedLocationSource == LocationSelectionSource.NEARBY
) {
val currentGeohash = Geohash.encode(
latitude = location.latitude,
longitude = location.longitude,
precision = selectedChannelValue.channel.level.precision
)
_teleported.value = currentGeohash != selectedChannelValue.channel.geohash
} else if (selectedChannelValue is ChannelID.Mesh) {
_teleported.value = false
}
}
private fun reverseGeocodeIfNeeded(location: Location) {
// Cancel any pending geocoding job to avoid race conditions
private fun reverseGeocodeIfNeeded(location: Location, token: Long) {
if (!canUseLiveLocation(token)) return
geocodingJob?.cancel()
geocodingJob = scope.launch(Dispatchers.IO) {
try {
val addresses = geocoderProvider.getFromLocation(location.latitude, location.longitude, 1)
if (!canUseLiveLocation(token)) return@launch
val addresses = geocoderProvider.getFromLocation(
location.latitude,
location.longitude,
1,
liveLocationToken = token
)
if (!isActive) return@launch
if (!isActive || !canUseLiveLocation(token)) return@launch
if (addresses.isNotEmpty()) {
val address = addresses[0]
val names = namesByLevel(address)
_locationNames.value = names
LiveLocationPrivacyGate.runIfAllowed(token) {
if (_systemLocationEnabled.value && hasRuntimeLocationPermission()) {
_locationNames.value = names
}
}
} else {
Log.w(TAG, "No reverse geocoding results")
}
} catch (e: Exception) {
if (e !is CancellationException) {
Log.e(TAG, "Reverse geocoding failed: ${e.message}")
Log.e(TAG, "Reverse geocoding failed")
}
}
}
@ -445,7 +554,10 @@ class LocationChannelManager private constructor(private val context: Context) {
/**
* Save current channel selection to persistent storage
*/
private fun saveChannelSelection(channel: ChannelID) {
private fun saveChannelSelection(
channel: ChannelID,
source: LocationSelectionSource?
) {
try {
val channelData = when (channel) {
is ChannelID.Mesh -> gson.toJson(PersistedChannel(mesh = true))
@ -453,13 +565,14 @@ class LocationChannelManager private constructor(private val context: Context) {
PersistedChannel(
mesh = false,
level = channel.channel.level.name,
geohash = channel.channel.geohash
geohash = channel.channel.geohash,
source = source?.name
)
)
}
dataManager?.saveLastGeohashChannel(channelData)
} catch (e: Exception) {
Log.e(TAG, "Failed to save channel selection: ${e.message}")
Log.e(TAG, "Failed to save channel selection")
}
}
@ -472,27 +585,44 @@ class LocationChannelManager private constructor(private val context: Context) {
if (!channelData.isNullOrBlank()) {
val persisted = gson.fromJson(channelData, PersistedChannel::class.java)
val channel = persisted?.toChannel()
if (channel != null) {
val source = persisted?.selectionSource()
val canRestore = channel !is ChannelID.Location ||
source == LocationSelectionSource.MANUAL ||
(LiveLocationPrivacyGate.isEnabled &&
_systemLocationEnabled.value &&
_permissionState.value == PermissionState.AUTHORIZED)
if (channel != null && canRestore) {
_selectedChannel.value = channel
selectedLocationSource = if (channel is ChannelID.Location) source else null
_teleported.value = channel is ChannelID.Location &&
source == LocationSelectionSource.MANUAL
} else {
_selectedChannel.value = ChannelID.Mesh
selectedLocationSource = null
_teleported.value = false
saveChannelSelection(ChannelID.Mesh, source = null)
}
} else {
_selectedChannel.value = ChannelID.Mesh
selectedLocationSource = null
}
} catch (e: JsonSyntaxException) {
Log.e(TAG, "Failed to parse persisted channel data: ${e.message}")
Log.e(TAG, "Failed to parse persisted channel data")
_selectedChannel.value = ChannelID.Mesh
selectedLocationSource = null
} catch (e: Exception) {
Log.e(TAG, "Failed to load persisted channel: ${e.message}")
Log.e(TAG, "Failed to load persisted channel")
_selectedChannel.value = ChannelID.Mesh
selectedLocationSource = null
}
}
data class PersistedChannel(
val mesh: Boolean,
val level: String? = null,
val geohash: String? = null
val geohash: String? = null,
val source: String? = null
) {
fun toChannel(): ChannelID? {
return if (mesh) {
@ -503,6 +633,13 @@ class LocationChannelManager private constructor(private val context: Context) {
ChannelID.Location.fromPersisted(levelName, gh)
}
}
fun selectionSource(): LocationSelectionSource? {
if (mesh) return null
return source?.let {
runCatching { LocationSelectionSource.valueOf(it) }.getOrNull()
} ?: LocationSelectionSource.NEARBY
}
}
/**
@ -511,6 +648,7 @@ class LocationChannelManager private constructor(private val context: Context) {
fun clearPersistedChannel() {
dataManager?.clearLastGeohashChannel()
_selectedChannel.value = ChannelID.Mesh
selectedLocationSource = null
_teleported.value = false
}
@ -523,7 +661,7 @@ class LocationChannelManager private constructor(private val context: Context) {
try {
dataManager?.saveLocationServicesEnabled(enabled)
} catch (e: Exception) {
Log.e(TAG, "Failed to save location services state: ${e.message}")
Log.e(TAG, "Failed to save location services state")
}
}
@ -533,10 +671,10 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun loadLocationServicesState() {
try {
val enabled = dataManager?.isLocationServicesEnabled() ?: false
_locationServicesEnabled.value = enabled
LiveLocationPrivacyGate.update(enabled)
} catch (e: Exception) {
Log.e(TAG, "Failed to load location services state: ${e.message}")
_locationServicesEnabled.value = false
Log.e(TAG, "Failed to load location services state")
LiveLocationPrivacyGate.update(false)
}
}

View File

@ -6,7 +6,7 @@ import android.location.Location
* Abstraction for location providers to support both
* System (LocationManager) and Google Play Services (FusedLocationProvider).
*/
interface LocationProvider {
internal interface LocationProvider {
/**
* Get the last known location from cache.
* @param callback Called with the location or null if not found/error.

View File

@ -4,53 +4,79 @@ import android.location.Address
import android.util.Log
import com.bitchat.android.net.OkHttpProvider
import com.google.gson.Gson
import java.io.IOException
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
class OpenStreetMapGeocoderProvider : GeocoderProvider {
private val TAG = "OSMGeocoderProvider"
private val gson = Gson()
private val userAgent = "Bitchat-Android/1.0"
override suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address> {
return withContext(Dispatchers.IO) {
override suspend fun getFromLocation(
latitude: Double,
longitude: Double,
maxResults: Int,
liveLocationToken: Long?
): List<Address> {
return suspendCancellableCoroutine { continuation ->
val lang = Locale.getDefault().toLanguageTag()
// Using format=jsonv2 for structured address breakdown
val url = "https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=$latitude&lon=$longitude&zoom=18&addressdetails=1&accept-language=$lang"
val request = Request.Builder()
.url(url)
.header("User-Agent", userAgent)
.build()
val call = OkHttpProvider.httpClient().newCall(request)
try {
val request = Request.Builder()
.url(url)
.header("User-Agent", userAgent)
.build()
val response = OkHttpProvider.httpClient().newCall(request).execute()
if (!response.isSuccessful) {
Log.e(TAG, "OSM Request failed: ${response.code}")
response.close()
return@withContext emptyList<Address>()
continuation.invokeOnCancellation { call.cancel() }
val enqueueRequest = {
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isActive) {
Log.w(TAG, "OSM geocoding request failed")
continuation.resume(emptyList())
}
}
val body = response.body?.string()
response.close()
override fun onResponse(call: Call, response: Response) {
val addresses = response.use {
if (!it.isSuccessful) {
Log.w(TAG, "OSM geocoding request returned ${it.code}")
return@use emptyList()
}
if (body.isNullOrEmpty()) return@withContext emptyList<Address>()
val body = it.body?.string()
if (body.isNullOrEmpty()) return@use emptyList()
try {
val osmResponse = gson.fromJson(body, OsmResponse::class.java)
if (osmResponse?.address == null) return@withContext emptyList<Address>()
val address = mapToAddress(osmResponse, latitude, longitude)
listOf(address)
} catch (e: Exception) {
Log.e(TAG, "OSM Parse failed: ${e.message}")
emptyList<Address>()
runCatching {
val osmResponse = gson.fromJson(body, OsmResponse::class.java)
if (osmResponse?.address == null) emptyList()
else listOf(mapToAddress(osmResponse, latitude, longitude))
}.getOrElse {
Log.w(TAG, "OSM geocoding response could not be parsed")
emptyList()
}
}
if (continuation.isActive) continuation.resume(addresses)
}
} catch (e: Exception) {
Log.e(TAG, "OSM Geocoding failed", e)
emptyList<Address>()
})
}
val started = if (liveLocationToken == null) {
enqueueRequest()
true
} else {
LiveLocationPrivacyGate.runIfAllowed(
liveLocationToken,
enqueueRequest
)
}
if (!started && continuation.isActive) {
continuation.resume(emptyList())
}
}
}

View File

@ -9,10 +9,11 @@ import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal
import android.util.Log
import androidx.core.app.ActivityCompat
class SystemLocationProvider(private val context: Context) : LocationProvider {
internal class SystemLocationProvider(private val context: Context) : LocationProvider {
companion object {
private const val TAG = "SystemLocationProvider"
@ -25,10 +26,13 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
private val activeListeners = mutableMapOf<(Location) -> Unit, LocationListener>()
private val activeOneShotListeners = mutableMapOf<(Location?) -> Unit, LocationListener>()
private val activeOneShotRunnables = mutableMapOf<(Location?) -> Unit, Runnable>()
private val activeOneShotCancellationSignals = mutableMapOf<(Location?) -> Unit, CancellationSignal>()
private fun hasLocationPermission(): Boolean {
return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
return LiveLocationPrivacyGate.isEnabled &&
(ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
)
}
@SuppressLint("MissingPermission")
@ -49,9 +53,9 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
}
}
}
callback(bestLocation)
callback(bestLocation.takeIf { LiveLocationPrivacyGate.isEnabled })
} catch (e: Exception) {
Log.e(TAG, "Error getting last known location: ${e.message}")
Log.e(TAG, "Error getting last-known location")
callback(null)
}
}
@ -76,12 +80,27 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
Log.d(TAG, "Requesting fresh location from $provider")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
locationManager.getCurrentLocation(
provider,
null,
context.mainExecutor
) { location ->
callback(location)
val cancellationSignal = CancellationSignal()
synchronized(activeOneShotCancellationSignals) {
activeOneShotCancellationSignals[callback] = cancellationSignal
}
try {
locationManager.getCurrentLocation(
provider,
cancellationSignal,
context.mainExecutor
) { location ->
synchronized(activeOneShotCancellationSignals) {
activeOneShotCancellationSignals.remove(callback)
}
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
}
} catch (e: Exception) {
synchronized(activeOneShotCancellationSignals) {
activeOneShotCancellationSignals.remove(callback)
}
cancellationSignal.cancel()
throw e
}
} else {
// For older versions, use requestSingleUpdate with timeout mechanism
@ -94,7 +113,7 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
try {
locationManager.removeUpdates(listener)
} catch (e: Exception) {
Log.e(TAG, "Error removing timed out listener: ${e.message}")
Log.e(TAG, "Error removing timed-out listener")
}
}
}
@ -113,9 +132,9 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
try {
locationManager.removeUpdates(this)
} catch (e: Exception) {
Log.e(TAG, "Error removing updates in callback: ${e.message}")
Log.e(TAG, "Error removing updates in callback")
}
callback(location)
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
override fun onProviderEnabled(provider: String) {}
@ -140,7 +159,7 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
callback(null)
}
} catch (e: Exception) {
Log.e(TAG, "Error requesting fresh location: ${e.message}")
Log.e(TAG, "Error requesting fresh location")
callback(null)
}
}
@ -156,7 +175,7 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
try {
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
callback(location)
if (LiveLocationPrivacyGate.isEnabled) callback(location)
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
override fun onProviderEnabled(provider: String) {}
@ -189,7 +208,7 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
}
} catch (e: Exception) {
Log.e(TAG, "Error requesting location updates: ${e.message}")
Log.e(TAG, "Error requesting location updates")
}
}
@ -204,7 +223,7 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
Log.d(TAG, "Removed location updates")
}
} catch (e: Exception) {
Log.e(TAG, "Error removing updates: ${e.message}")
Log.e(TAG, "Error removing updates")
}
}
@ -230,9 +249,13 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
}
activeOneShotRunnables.clear()
}
synchronized(activeOneShotCancellationSignals) {
activeOneShotCancellationSignals.values.forEach { it.cancel() }
activeOneShotCancellationSignals.clear()
}
Log.d(TAG, "Cancelled all system location requests")
} catch (e: Exception) {
Log.e(TAG, "Error cancelling system provider: ${e.message}")
Log.e(TAG, "Error cancelling system provider")
}
}
}

View File

@ -69,7 +69,15 @@ data class BitchatMessage(
val encryptedContent: ByteArray? = null,
val isEncrypted: Boolean = false,
val deliveryStatus: DeliveryStatus? = null,
val powDifficulty: Int? = null
val powDifficulty: Int? = null,
/**
* Full canonical Nostr public key supplied by the local Nostr bridge.
*
* This is local identity metadata, not part of the Bitchat binary wire format. It lets UI
* surfaces color the sender by the same stable key while [senderPeerID] remains available for
* mesh IDs and private-chat routing aliases.
*/
val senderNostrPubkey: String? = null
) : Parcelable {
/**
@ -355,4 +363,3 @@ data class BitchatMessage(
}
}

View File

@ -96,6 +96,7 @@ class GeohashMessageHandler(
isRelay = false,
originalSender = repo.displayNameForNostrPubkey(pubkey),
senderPeerID = "nostr:${pubkey.take(8)}",
senderNostrPubkey = pubkey,
mentions = null,
channel = "#$subscribedGeohash",
powDifficulty = try {

View File

@ -28,25 +28,33 @@ object LocationNotesInitializer {
return@initialize id // Return subscription ID even on error
}
Log.d(TAG, "📍 Location Notes subscribing to geohash: $geohashFromFilter")
val token = com.bitchat.android.geohash.LiveLocationPrivacyGate
.captureToken() ?: return@initialize id
NostrRelayManager.getInstance(context).subscribeForGeohash(
geohash = geohashFromFilter,
filter = filter,
id = id,
handler = handler,
includeDefaults = true,
nRelays = 5
nRelays = 5,
liveLocationToken = token
)
},
unsubscribe = { id ->
NostrRelayManager.getInstance(context).unsubscribe(id)
},
sendEvent = { event, relayUrls ->
sendEvent = { event, relayUrls, token ->
if (relayUrls != null) {
NostrRelayManager.getInstance(context).sendEvent(event, relayUrls)
NostrRelayManager.getInstance(context).sendEvent(
event,
relayUrls,
liveLocationToken = token
)
} else {
NostrRelayManager.getInstance(context).sendEvent(event)
NostrRelayManager.getInstance(context).sendEvent(
event,
liveLocationToken = token
)
}
},
deriveIdentity = { geohash ->

View File

@ -2,10 +2,12 @@ package com.bitchat.android.nostr
import android.util.Log
import androidx.annotation.MainThread
import com.bitchat.android.geohash.LiveLocationPrivacyGate
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.UUID
/**
* Manages location notes (kind=1 text notes with geohash tags)
@ -89,13 +91,18 @@ class LocationNotesManager private constructor() {
private var relayLookup: (() -> NostrRelayManager)? = null
private var subscribeFunc: ((NostrFilter, String, (NostrEvent) -> Unit) -> String)? = null
private var unsubscribeFunc: ((String) -> Unit)? = null
private var sendEventFunc: ((NostrEvent, List<String>?) -> Unit)? = null
private var sendEventFunc: ((NostrEvent, List<String>?, Long) -> Unit)? = null
private var deriveIdentityFunc: ((String) -> NostrIdentity)? = null
// Coroutine scope for background operations
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var liveLocationToken: Long? = null
private var subscribeRetryJob: Job? = null
private var initialLoadJob: Job? = null
init {
LiveLocationPrivacyGate.addRevocationListener(::stop)
}
/**
* Initialize dependencies
@ -104,7 +111,7 @@ class LocationNotesManager private constructor() {
relayManager: () -> NostrRelayManager,
subscribe: (NostrFilter, String, (NostrEvent) -> Unit) -> String,
unsubscribe: (String) -> Unit,
sendEvent: (NostrEvent, List<String>?) -> Unit,
sendEvent: (NostrEvent, List<String>?, Long) -> Unit,
deriveIdentity: (String) -> NostrIdentity
) {
this.relayLookup = relayManager
@ -119,23 +126,28 @@ class LocationNotesManager private constructor() {
* iOS: Validates building-level precision (8 characters)
*/
fun setGeohash(newGeohash: String) {
val token = LiveLocationPrivacyGate.captureToken() ?: run {
stop()
return
}
val normalized = newGeohash.lowercase()
if (_geohash.value == normalized) {
Log.d(TAG, "Geohash unchanged, skipping: $normalized")
if (_geohash.value == normalized &&
liveLocationToken?.let(LiveLocationPrivacyGate::accepts) == true
) {
return
}
// Validate geohash (building-level precision: 8 chars) - matches iOS
if (!isValidBuildingGeohash(normalized)) {
Log.w(TAG, "LocationNotesManager: rejecting invalid geohash '$normalized' (expected 8 valid base32 chars)")
Log.w(TAG, "LocationNotesManager rejected an invalid building geohash")
return
}
Log.d(TAG, "Setting geohash: $normalized")
// Cancel existing subscription
cancel()
if (!LiveLocationPrivacyGate.accepts(token)) return
liveLocationToken = token
// Set loading state before clearing to prevent empty state flicker (iOS pattern)
_state.value = State.LOADING
@ -154,7 +166,7 @@ class LocationNotesManager private constructor() {
subscribedGeohashes = (neighbors + normalized).toSet()
// Start new subscriptions for all cells
subscribeAll()
subscribeAll(token)
}
/**
@ -170,16 +182,20 @@ class LocationNotesManager private constructor() {
* Refresh notes for current geohash
*/
fun refresh() {
val token = LiveLocationPrivacyGate.captureToken() ?: run {
stop()
return
}
val currentGeohash = _geohash.value
if (currentGeohash == null) {
Log.w(TAG, "Cannot refresh - no geohash set")
return
}
Log.d(TAG, "Refreshing notes for geohash: $currentGeohash")
// Cancel and restart subscriptions for current ±1 set
cancel()
if (!LiveLocationPrivacyGate.accepts(token)) return
liveLocationToken = token
_notes.value = emptyList()
noteIDs.clear()
_initialLoadComplete.value = false
@ -188,13 +204,17 @@ class LocationNotesManager private constructor() {
com.bitchat.android.geohash.Geohash.neighborsSamePrecision(currentGeohash)
} catch (_: Exception) { emptySet() }
subscribedGeohashes = (neighbors + currentGeohash).toSet()
subscribeAll()
subscribeAll(token)
}
/**
* Send a new location note
*/
fun send(content: String, nickname: String?) {
val token = LiveLocationPrivacyGate.captureToken() ?: run {
stop()
return
}
val currentGeohash = _geohash.value
if (currentGeohash == null) {
Log.w(TAG, "Cannot send note - no geohash set")
@ -209,16 +229,22 @@ class LocationNotesManager private constructor() {
// CRITICAL FIX: Get geo-specific relays for sending (matching iOS pattern)
// iOS: let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
val relays = try {
com.bitchat.android.nostr.RelayDirectory.closestRelaysForGeohash(currentGeohash, 5)
var relays: List<String> = emptyList()
try {
LiveLocationPrivacyGate.runIfAllowed(token) {
relays = RelayDirectory.closestRelaysForGeohash(currentGeohash, 5)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to lookup relays for geohash $currentGeohash: ${e.message}")
emptyList()
Log.e(TAG, "Failed to look up location-note relays")
}
if (!LiveLocationPrivacyGate.accepts(token)) {
stop()
return
}
// Check if we have relays (iOS pattern: guard !relays.isEmpty())
if (relays.isEmpty()) {
Log.w(TAG, "Send blocked - no geo relays for geohash: $currentGeohash")
Log.w(TAG, "Location-note send blocked because no relays are available")
_state.value = State.NO_RELAYS
_errorMessage.value = "No relays available"
return
@ -231,34 +257,40 @@ class LocationNotesManager private constructor() {
return
}
Log.d(TAG, "Sending note to geohash: $currentGeohash via ${relays.size} geo relays")
scope.launch {
try {
val identity = withContext(Dispatchers.IO) {
deriveIdentity(currentGeohash)
var identity: NostrIdentity? = null
val identityPrepared = withContext(Dispatchers.IO) {
LiveLocationPrivacyGate.runIfAllowed(token) {
identity = deriveIdentity(currentGeohash)
}
}
val event = withContext(Dispatchers.IO) {
val preparedIdentity = identity
if (!identityPrepared || preparedIdentity == null ||
!LiveLocationPrivacyGate.accepts(token)
) return@launch
val preparedEvent = withContext(Dispatchers.IO) {
NostrProtocol.createGeohashTextNote(
content = trimmed,
geohash = currentGeohash,
senderIdentity = identity,
nickname = nickname
)
content = trimmed,
geohash = currentGeohash,
senderIdentity = preparedIdentity,
nickname = nickname
)
}
if (!LiveLocationPrivacyGate.accepts(token)) return@launch
// Optimistic local echo - add note immediately to UI
val localNote = Note(
id = event.id,
pubkey = event.pubkey,
id = preparedEvent.id,
pubkey = preparedEvent.pubkey,
content = trimmed,
createdAt = event.createdAt,
createdAt = preparedEvent.createdAt,
nickname = nickname
)
if (!noteIDs.contains(event.id)) {
noteIDs.add(event.id)
if (!noteIDs.contains(preparedEvent.id)) {
noteIDs.add(preparedEvent.id)
val currentNotes = _notes.value ?: emptyList()
_notes.value = (currentNotes + localNote).sortedByDescending { it.createdAt }
@ -270,11 +302,12 @@ class LocationNotesManager private constructor() {
// CRITICAL FIX: Send to geo-specific relays (matching iOS pattern)
// iOS: dependencies.sendEvent(event, relays)
withContext(Dispatchers.IO) {
sendEventFunc?.invoke(event, relays)
val sent = withContext(Dispatchers.IO) {
LiveLocationPrivacyGate.runIfAllowed(token) {
sendEventFunc?.invoke(preparedEvent, relays, token)
}
}
Log.d(TAG, "✅ Note sent successfully to ${relays.size} geo relays: ${event.id.take(16)}...")
if (!sent) return@launch
// Clear any error messages on successful send
_errorMessage.value = null
@ -290,12 +323,16 @@ class LocationNotesManager private constructor() {
/**
* Subscribe to location notes for current geohash
*/
private fun subscribeAll() {
private fun subscribeAll(token: Long) {
subscribeRetryJob?.cancel()
subscribeRetryJob = null
initialLoadJob?.cancel()
initialLoadJob = null
if (!LiveLocationPrivacyGate.accepts(token)) {
stop()
return
}
val currentGeohash = _geohash.value
if (currentGeohash == null) {
Log.w(TAG, "Cannot subscribe - no geohash set")
@ -310,17 +347,20 @@ class LocationNotesManager private constructor() {
// Retry a few times in case initialization is racing the sheet open
subscribeRetryJob = scope.launch {
var attempts = 0
while (attempts < 10 && subscribeFunc == null) {
while (attempts < 10 &&
subscribeFunc == null &&
LiveLocationPrivacyGate.accepts(token)
) {
delay(300)
attempts++
}
val subNow = subscribeFunc
if (subNow != null) {
if (subNow != null && LiveLocationPrivacyGate.accepts(token)) {
// Try again now that dependencies are ready
subscribeAll()
subscribeAll(token)
} else {
// Give UI a chance to show empty state rather than spinner forever
if (!_initialLoadComplete.value!!) {
if (!_initialLoadComplete.value) {
_initialLoadComplete.value = true
_state.value = State.READY
}
@ -333,28 +373,33 @@ class LocationNotesManager private constructor() {
// Subscribe for each geohash in the ±1 set
subscribedGeohashes.forEach { gh ->
if (!LiveLocationPrivacyGate.accepts(token)) return
val filter = NostrFilter.geohashNotes(
geohash = gh,
since = null,
limit = 200
)
val subId = "location-notes-$gh"
Log.d(TAG, "📡 Subscribing to location notes: $subId")
val subId = "location-notes-${UUID.randomUUID()}"
try {
val id = subscribe(filter, subId) { event -> handleEvent(event) }
subscriptionIDs[gh] = id
var id: String? = null
LiveLocationPrivacyGate.runIfAllowed(token) {
id = subscribe(filter, subId) { event -> handleEvent(event) }
}
id?.let { subscriptionIDs[gh] = it }
} catch (e: Exception) {
Log.e(TAG, "Failed to subscribe for $gh: ${e.message}")
Log.e(TAG, "Failed to subscribe to location notes")
}
}
// Mark initial load complete after brief delay to allow relay responses
initialLoadJob = scope.launch {
delay(2000) // Wait 2 seconds for initial batch
if (_geohash.value == currentGeohash && !_initialLoadComplete.value) {
if (_geohash.value == currentGeohash &&
LiveLocationPrivacyGate.accepts(token) &&
!_initialLoadComplete.value
) {
_initialLoadComplete.value = true
_state.value = State.READY
Log.d(TAG, "Initial load complete for geohash: $currentGeohash (${noteIDs.size} notes)")
}
}
}
@ -363,6 +408,9 @@ class LocationNotesManager private constructor() {
* Handle incoming event from subscription
*/
private fun handleEvent(event: NostrEvent) {
val token = liveLocationToken
if (token == null || !LiveLocationPrivacyGate.accepts(token)) return
// Validate event
if (event.kind != NostrKind.TEXT_NOTE) {
Log.v(TAG, "Ignoring non-text-note event: kind=${event.kind}")
@ -379,7 +427,6 @@ class LocationNotesManager private constructor() {
// Check if matches current geohash
val eventGeohash = geohashTag[1]
if (!subscribedGeohashes.contains(eventGeohash)) {
Log.v(TAG, "Ignoring event for non-subscribed geohash: $eventGeohash")
return
}
@ -406,8 +453,6 @@ class LocationNotesManager private constructor() {
val currentNotes = _notes.value ?: emptyList()
_notes.value = (currentNotes + note).sortedByDescending { it.createdAt }
Log.d(TAG, "Added note from ${note.displayName}")
// Trim if exceeds max
if (noteIDs.size > MAX_NOTES_IN_MEMORY) {
trimOldestNotes()
@ -456,7 +501,6 @@ class LocationNotesManager private constructor() {
if (subscriptionIDs.isNotEmpty()) {
subscriptionIDs.values.forEach { subId ->
try {
Log.d(TAG, "🚫 Canceling subscription: $subId")
unsubscribeFunc?.invoke(subId)
} catch (_: Exception) { }
}
@ -473,9 +517,10 @@ class LocationNotesManager private constructor() {
*/
fun stop() {
cancel()
liveLocationToken = null
_geohash.value = null
_notes.value = emptyList()
noteIDs.clear()
_geohash.value = null
_initialLoadComplete.value = false
_errorMessage.value = null
}

View File

@ -3,8 +3,11 @@ package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.GeohashNostrPrivacyPolicy
import com.bitchat.android.geohash.LiveLocationPrivacyGate
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.mesh.PowerManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -44,6 +47,7 @@ object NostrBackgroundRuntime {
@Volatile private var handlers: Handlers? = null
@Volatile private var initialized = false
@Volatile private var activeGeohash: String? = null
@Volatile private var activeGeohashLiveToken: Long? = null
@Volatile private var conversationGeohash: String? = null
private lateinit var application: Application
private lateinit var subscriptions: NostrSubscriptionManager
@ -83,7 +87,9 @@ object NostrBackgroundRuntime {
// Let CLOSE frames be queued before replacing the deterministic IDs.
delay(100)
subscribeAccountDm()
activeGeohash?.let(::subscribeSelectedGeohash)
activeGeohash?.let { geohash ->
subscribeSelectedGeohash(geohash, activeGeohashLiveToken)
}
}
}
@ -91,7 +97,11 @@ object NostrBackgroundRuntime {
if (!initialized || geohash == activeGeohash || geohash == conversationGeohash) return
conversationGeohash?.let { subscriptions.unsubscribe("geo-dm-conversation-$it") }
conversationGeohash = geohash
subscribeGeohashDm(geohash, "geo-dm-conversation-$geohash")
subscribeGeohashDm(
geohash = geohash,
subscriptionId = "geo-dm-conversation-$geohash",
liveLocationToken = null
)
}
private fun subscribeAccountDm() {
@ -110,42 +120,67 @@ object NostrBackgroundRuntime {
scope.launch {
locationChannels.selectedChannel.collectLatest { channel ->
val next = (channel as? ChannelID.Location)?.channel?.geohash
val nextToken = (channel as? ChannelID.Location)?.let {
locationChannels.liveLocationTokenForSelectedChannel(it.channel)
}
val previous = activeGeohash
if (previous == next) return@collectLatest
if (previous == next && activeGeohashLiveToken == nextToken) {
return@collectLatest
}
previous?.let {
subscriptions.unsubscribe("geohash-$it")
subscriptions.unsubscribe("geo-dm-$it")
}
activeGeohash = next
activeGeohashLiveToken = nextToken
if (conversationGeohash == next) {
subscriptions.unsubscribe("geo-dm-conversation-$next")
conversationGeohash = null
}
next?.let(::subscribeSelectedGeohash)
next?.let { geohash ->
val isLiveDerived = (channel as? ChannelID.Location)?.let {
locationChannels.isSelectedChannelLiveDerived(it.channel)
} == true
if (!isLiveDerived || nextToken != null) {
subscribeSelectedGeohash(geohash, nextToken)
}
}
}
}
}
private fun subscribeSelectedGeohash(geohash: String) {
private fun subscribeSelectedGeohash(
geohash: String,
liveLocationToken: Long?
) {
subscriptions.subscribeGeohashMessages(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 3_600_000L,
limit = 200,
id = "geohash-$geohash",
handler = { event -> dispatch { it.geohashMessage(event, geohash) } }
handler = { event -> dispatch { it.geohashMessage(event, geohash) } },
liveLocationToken = liveLocationToken
)
subscribeGeohashDm(geohash, "geo-dm-$geohash")
subscribeGeohashDm(geohash, "geo-dm-$geohash", liveLocationToken)
}
private fun subscribeGeohashDm(geohash: String, subscriptionId: String) {
private fun subscribeGeohashDm(
geohash: String,
subscriptionId: String,
liveLocationToken: Long?
) {
scope.launch {
if (liveLocationToken != null &&
!LiveLocationPrivacyGate.accepts(liveLocationToken)
) return@launch
val identity = NostrIdentityBridge.deriveIdentity(geohash, application)
subscriptions.subscribeGiftWraps(
pubkey = identity.publicKeyHex,
sinceMs = System.currentTimeMillis() - 172_800_000L,
id = subscriptionId,
handler = { event -> dispatch { it.geohashDm(event, geohash, identity) } }
handler = { event -> dispatch { it.geohashDm(event, geohash, identity) } },
liveLocationToken = liveLocationToken
)
GeohashAliasRegistry.put(
"nostr_${identity.publicKeyHex.take(16)}",
@ -160,11 +195,15 @@ object NostrBackgroundRuntime {
val nostrSchedule = powerManager.profile
.map { it.nostr }
.distinctUntilChanged()
combine(locationChannels.availableChannels, nostrSchedule) { channels, schedule ->
val targets = channels
.filter { it.level.precision <= 5 }
.map { it.geohash }
.distinct()
combine(
locationChannels.availableChannels,
LiveLocationPrivacyGate.enabled,
nostrSchedule
) { channels, liveLocationEnabled, schedule ->
val targets = GeohashNostrPrivacyPolicy.livePresenceTargets(
availableChannels = channels,
liveLocationEnabled = liveLocationEnabled
)
targets to schedule
}.collectLatest { (targets, schedule) ->
if (targets.isEmpty()) return@collectLatest
@ -182,14 +221,35 @@ object NostrBackgroundRuntime {
// Send every target in one wake window; do not spread the batch over seconds.
targets.forEach { geohash ->
try {
val identity = NostrIdentityBridge.deriveIdentity(geohash, application)
val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity)
NostrRelayManager.getInstance(application).sendEventToGeohash(
event,
val token = LiveLocationPrivacyGate.captureToken()
?: return@forEach
if (geohash !in GeohashNostrPrivacyPolicy.livePresenceTargets(
availableChannels = locationChannels.availableChannels.value,
liveLocationEnabled = true
)
) return@forEach
var identity: NostrIdentity? = null
LiveLocationPrivacyGate.runIfAllowed(token) {
identity = NostrIdentityBridge.deriveIdentity(geohash, application)
}
val preparedIdentity = identity ?: return@forEach
if (!LiveLocationPrivacyGate.accepts(token)) return@forEach
val event = NostrProtocol.createGeohashPresenceEvent(
geohash,
includeDefaults = false,
nRelays = 5
preparedIdentity
)
LiveLocationPrivacyGate.runIfAllowed(token) {
NostrRelayManager.getInstance(application).sendEventToGeohash(
event = event,
geohash = geohash,
includeDefaults = false,
nRelays = 5,
liveLocationToken = token
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Presence heartbeat failed for $geohash: ${e.message}")
}

View File

@ -180,7 +180,7 @@ class NostrClient private constructor(private val context: Context) {
relayManager.sendEvent(event)
Log.i(TAG, "📤 Sent geohash message to #$geohash")
Log.i(TAG, "📤 Sent geohash message")
onSuccess?.invoke()
} catch (e: Exception) {
@ -209,7 +209,7 @@ class NostrClient private constructor(private val context: Context) {
}
})
Log.i(TAG, "🌍 Subscribed to geohash channel: #$geohash")
Log.i(TAG, "🌍 Subscribed to geohash channel")
}
/**
@ -217,7 +217,7 @@ class NostrClient private constructor(private val context: Context) {
*/
fun unsubscribeFromGeohash(geohash: String) {
relayManager.unsubscribe("geohash-$geohash")
Log.i(TAG, "Unsubscribed from geohash channel: #$geohash")
Log.i(TAG, "Unsubscribed from geohash channel")
}
/**

View File

@ -148,6 +148,7 @@ class NostrDirectMessageHandler(
isPrivate = true,
recipientNickname = state.getNicknameValue(),
senderPeerID = conversationID,
senderNostrPubkey = senderPubkey,
deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date())
)
@ -201,7 +202,8 @@ class NostrDirectMessageHandler(
isRelay = false,
isPrivate = true,
recipientNickname = state.getNicknameValue(),
senderPeerID = conversationID
senderPeerID = conversationID,
senderNostrPubkey = senderPubkey
)
Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
withContext(Dispatchers.Main) {

View File

@ -6,6 +6,7 @@ import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.identity.SecureIdentityStateManager
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap
/**
* Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
@ -100,7 +101,7 @@ object NostrIdentityBridge {
private const val DEVICE_SEED_KEY = "nostr_device_seed"
// Cache for derived geohash identities to avoid repeated crypto operations
private val geohashIdentityCache = mutableMapOf<String, NostrIdentity>()
private val geohashIdentityCache = ConcurrentHashMap<String, NostrIdentity>()
/**
* Get or create the current Nostr identity
@ -157,7 +158,7 @@ object NostrIdentityBridge {
// Cache the result for future UI responsiveness
geohashIdentityCache[forGeohash] = identity
Log.d(TAG, "Derived geohash identity for $forGeohash (iteration $i)")
Log.d(TAG, "Derived geohash identity")
return identity
}
}
@ -172,7 +173,7 @@ object NostrIdentityBridge {
// Cache the fallback result too
geohashIdentityCache[forGeohash] = fallbackIdentity
Log.d(TAG, "Used fallback identity derivation for $forGeohash")
Log.d(TAG, "Used fallback geohash identity derivation")
return fallbackIdentity
}
@ -219,6 +220,10 @@ object NostrIdentityBridge {
Log.e(TAG, "Failed to clear Nostr data: ${e.message}")
}
}
fun clearGeohashIdentityCache(geohashes: Collection<String>) {
geohashes.forEach(geohashIdentityCache::remove)
}
// MARK: - Private Methods

View File

@ -0,0 +1,13 @@
package com.bitchat.android.nostr
internal object NostrLiveSubscriptionPrivacy {
fun closeTargets(
liveSubscriptionIds: Set<String>,
subscriptionsByRelay: Map<String, Set<String>>,
): Map<String, Set<String>> = buildMap {
subscriptionsByRelay.forEach { (relayUrl, relaySubscriptionIds) ->
val matchingIds = relaySubscriptionIds.intersect(liveSubscriptionIds)
if (matchingIds.isNotEmpty()) put(relayUrl, matchingIds)
}
}
}

View File

@ -1,6 +1,7 @@
package com.bitchat.android.nostr
import android.util.Log
import com.bitchat.android.geohash.LiveLocationPrivacyGate
import com.google.gson.Gson
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -9,6 +10,7 @@ import com.google.gson.JsonArray
import com.google.gson.JsonParser
import kotlinx.coroutines.*
import okhttp3.*
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
@ -104,8 +106,9 @@ class NostrRelayManager private constructor() {
val handler: (NostrEvent) -> Unit,
val targetRelayUrls: Set<String>? = null, // null means all relays
val createdAt: Long = System.currentTimeMillis(),
val originGeohash: String? = null, // used for logging and grouping
val owner: String = OWNER_LEGACY
val originGeohash: String? = null,
val owner: String = OWNER_LEGACY,
val liveLocationToken: Long? = null
)
// Event deduplication system
@ -114,8 +117,10 @@ class NostrRelayManager private constructor() {
// Message queue for reliability
private data class QueuedEvent(
val event: NostrEvent,
val pendingRelayUrls: MutableSet<String>
val pendingRelayUrls: MutableSet<String>,
val liveLocationToken: Long? = null
)
private val messageQueue = mutableListOf<QueuedEvent>()
private val messageQueueLock = Any()
@ -136,27 +141,49 @@ class NostrRelayManager private constructor() {
// Per-geohash relay selection
private val geohashToRelays = ConcurrentHashMap<String, Set<String>>() // geohash -> relay URLs
private val liveGeohashTokens = ConcurrentHashMap<String, Long>()
private val liveLocationRelayTokens = ConcurrentHashMap<String, Long>()
private val nonLiveRelayUrls = ConcurrentHashMap.newKeySet<String>()
private val liveLocationConnectionJobs = ConcurrentHashMap.newKeySet<Job>()
// --- Public API for geohash-specific operation ---
/**
* Compute and connect to relays for a given geohash (nearest + optional defaults), cache the mapping.
*/
fun ensureGeohashRelaysConnected(geohash: String, nRelays: Int = 5, includeDefaults: Boolean = false) {
fun ensureGeohashRelaysConnected(
geohash: String,
nRelays: Int = 5,
includeDefaults: Boolean = false,
liveLocationToken: Long? = null
) {
if (!isNetworkActionAllowed(liveLocationToken)) return
try {
val nearest = RelayDirectory.closestRelaysForGeohash(geohash, nRelays)
val selected = if (includeDefaults) {
(nearest + Companion.defaultRelays()).toSet()
} else nearest.toSet()
if (selected.isEmpty()) {
Log.w(TAG, "No relays selected for geohash=$geohash")
Log.w(TAG, "No relays selected for a geohash")
return
}
geohashToRelays[geohash] = selected
Log.d(TAG, "Geohash $geohash using ${selected.size} relays")
ensureConnectionsFor(selected)
runNetworkAction(liveLocationToken) {
geohashToRelays[geohash] = selected
if (liveLocationToken == null) {
liveGeohashTokens.remove(geohash)
nonLiveRelayUrls.addAll(selected)
} else {
liveGeohashTokens[geohash] = liveLocationToken
selected.forEach { relayUrl ->
if (relayUrl !in nonLiveRelayUrls) {
liveLocationRelayTokens[relayUrl] = liveLocationToken
}
}
}
ensureConnectionsFor(selected, liveLocationToken)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to ensure relays for $geohash: ${e.message}")
Log.e(TAG, "Failed to ensure geohash relays")
}
}
@ -177,20 +204,29 @@ class NostrRelayManager private constructor() {
handler: (NostrEvent) -> Unit,
includeDefaults: Boolean = false,
nRelays: Int = 5,
owner: String = OWNER_LEGACY
owner: String = OWNER_LEGACY,
liveLocationToken: Long? = null
): String {
ensureGeohashRelaysConnected(geohash, nRelays, includeDefaults)
if (!isNetworkActionAllowed(liveLocationToken)) return id
ensureGeohashRelaysConnected(
geohash,
nRelays,
includeDefaults,
liveLocationToken
)
if (!isNetworkActionAllowed(liveLocationToken)) return id
val relayUrls = getRelaysForGeohash(geohash)
return subscribe(
filter = filter,
id = id,
handler = handler,
targetRelayUrls = relayUrls,
owner = owner
).also {
// update origin geohash for this subscription
activeSubscriptions[it]?.let { sub ->
activeSubscriptions[it] = sub.copy(originGeohash = geohash)
owner = owner,
liveLocationToken = liveLocationToken
).also { subscriptionId ->
activeSubscriptions[subscriptionId]?.let { subscription ->
activeSubscriptions[subscriptionId] =
subscription.copy(originGeohash = geohash)
}
}
}
@ -198,20 +234,111 @@ class NostrRelayManager private constructor() {
/**
* Send an event specifically to a geohash's relays (+ optional defaults).
*/
fun sendEventToGeohash(event: NostrEvent, geohash: String, includeDefaults: Boolean = false, nRelays: Int = 5) {
ensureGeohashRelaysConnected(geohash, nRelays, includeDefaults)
fun sendEventToGeohash(
event: NostrEvent,
geohash: String,
includeDefaults: Boolean = false,
nRelays: Int = 5,
liveLocationToken: Long? = null
) {
if (!isNetworkActionAllowed(liveLocationToken)) return
ensureGeohashRelaysConnected(
geohash,
nRelays,
includeDefaults,
liveLocationToken
)
if (!isNetworkActionAllowed(liveLocationToken)) return
val relayUrls = getRelaysForGeohash(geohash)
if (relayUrls.isEmpty()) {
Log.w(TAG, "No target relays to send event for geohash=$geohash; falling back to defaults")
sendEvent(event, Companion.defaultRelays())
Log.w(TAG, "No target relays for geohash event; falling back to defaults")
sendEvent(event, Companion.defaultRelays(), liveLocationToken)
return
}
sendEvent(event, relayUrls)
sendEvent(event, relayUrls, liveLocationToken)
}
// --- Internal helpers ---
private fun ensureConnectionsFor(relayUrls: Set<String>) {
private fun isNetworkActionAllowed(liveLocationToken: Long?): Boolean =
liveLocationToken == null || LiveLocationPrivacyGate.accepts(liveLocationToken)
private fun runNetworkAction(
liveLocationToken: Long?,
action: () -> Unit
): Boolean = if (liveLocationToken == null) {
action()
true
} else {
LiveLocationPrivacyGate.runIfAllowed(liveLocationToken, action)
}
/**
* Privacy teardown is allowed to bypass an already-revoked token solely to stop
* server-side delivery. Live subscription IDs are opaque, so CLOSE carries no
* geohash. If a CLOSE cannot be queued, fail closed by dropping that socket.
*/
private fun closeSubscriptionsOnConnectedRelays(subscriptionIds: Set<String>) {
if (subscriptionIds.isEmpty()) return
val closeTargets = NostrLiveSubscriptionPrivacy.closeTargets(
liveSubscriptionIds = subscriptionIds,
subscriptionsByRelay = subscriptions,
)
closeTargets.forEach { (relayUrl, relaySubscriptionIds) ->
val webSocket = connections[relayUrl] ?: return@forEach
relaySubscriptionIds.forEach { subscriptionId ->
val request = NostrRequest.Close(subscriptionId)
val message = gson.toJson(request, NostrRequest::class.java)
val closeQueued = runCatching { webSocket.send(message) }
.getOrDefault(false)
if (!closeQueued) {
connections.remove(relayUrl, webSocket)
webSocket.cancel()
}
}
}
}
private fun revokeLiveLocationAccess() {
liveLocationConnectionJobs.forEach(Job::cancel)
liveLocationConnectionJobs.clear()
val liveSubscriptionIds = activeSubscriptions.values
.filter { it.liveLocationToken != null }
.mapTo(mutableSetOf()) { it.id }
closeSubscriptionsOnConnectedRelays(liveSubscriptionIds)
liveSubscriptionIds.forEach { id ->
activeSubscriptions.remove(id)
messageHandlers.remove(id)
}
subscriptions.replaceAll { _, ids -> ids - liveSubscriptionIds }
synchronized(messageQueueLock) {
messageQueue.removeAll { it.liveLocationToken != null }
}
liveGeohashTokens.keys.forEach(geohashToRelays::remove)
liveGeohashTokens.clear()
val liveOnlyRelayUrls = liveLocationRelayTokens.keys
.filterNotTo(mutableSetOf()) { it in nonLiveRelayUrls }
liveOnlyRelayUrls.forEach { relayUrl ->
connections.remove(relayUrl)?.cancel()
}
synchronized(relaysList) {
relaysList.removeAll { it.url in liveOnlyRelayUrls }
}
liveLocationRelayTokens.clear()
updateRelaysList()
updateConnectionStatus()
}
private fun ensureConnectionsFor(
relayUrls: Set<String>,
liveLocationToken: Long? = null
) {
if (!isNetworkActionAllowed(liveLocationToken)) return
// Ensure relays are tracked for UI/status
relayUrls.forEach { url ->
if (relaysList.none { it.url == url }) {
@ -221,15 +348,25 @@ class NostrRelayManager private constructor() {
updateRelaysList()
if (!desiredConnected.get()) return
scope.launch {
val job = scope.launch {
if (!desiredConnected.get() ||
!isNetworkActionAllowed(liveLocationToken)
) return@launch
relayUrls.forEach { relayUrl ->
launch {
if (!connections.containsKey(relayUrl)) {
connectToRelay(relayUrl)
if (desiredConnected.get() &&
!connections.containsKey(relayUrl) &&
isNetworkActionAllowed(liveLocationToken)
) {
connectToRelay(relayUrl, liveLocationToken)
}
}
}
}
if (liveLocationToken != null) {
liveLocationConnectionJobs.add(job)
job.invokeOnCompletion { liveLocationConnectionJobs.remove(job) }
}
}
init {
@ -242,8 +379,10 @@ class NostrRelayManager private constructor() {
"wss://nostr21.com"
)
relaysList.addAll(defaultRelayUrls.map { Relay(it) })
nonLiveRelayUrls.addAll(defaultRelayUrls)
_relays.value = relaysList.toList()
updateConnectionStatus()
LiveLocationPrivacyGate.addRevocationListener(::revokeLiveLocationAccess)
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e)
// Initialize with empty list as fallback
@ -258,11 +397,14 @@ class NostrRelayManager private constructor() {
fun connect() {
desiredConnected.set(true)
Log.i(TAG, "Connecting to ${relaysList.size} Nostr relays")
scope.launch {
relaysList.forEach { relay ->
launch {
connectToRelay(relay.url)
val liveToken = liveLocationRelayTokens[relay.url]
?.takeIf { relay.url !in nonLiveRelayUrls }
if (liveToken == null || LiveLocationPrivacyGate.accepts(liveToken)) {
connectToRelay(relay.url, liveToken)
}
}
}
}
@ -277,18 +419,20 @@ class NostrRelayManager private constructor() {
fun disconnect() {
Log.i(TAG, "Disconnecting from all Nostr relays")
desiredConnected.set(false)
// Stop subscription validation
stopSubscriptionValidation()
reconnectJobs.values.forEach { it.cancel() }
reconnectJobs.values.forEach(Job::cancel)
reconnectJobs.clear()
liveLocationConnectionJobs.forEach(Job::cancel)
liveLocationConnectionJobs.clear()
val sockets = connections.values.toList()
connections.clear()
sockets.forEach { webSocket ->
webSocket.close(1000, "Manual disconnect")
}
// Preserve logical subscriptions for controlled resets, but forget per-socket state.
subscriptions.clear()
relaysList.forEach {
@ -302,26 +446,39 @@ class NostrRelayManager private constructor() {
/**
* Send an event to specified relays (or all if none specified)
*/
fun sendEvent(event: NostrEvent, relayUrls: List<String>? = null) {
fun sendEvent(
event: NostrEvent,
relayUrls: List<String>? = null,
liveLocationToken: Long? = null
) {
val targetRelays = relayUrls ?: relaysList.map { it.url }
// Add to queue for reliability
synchronized(messageQueueLock) {
if (messageQueue.size >= MAX_QUEUED_EVENTS) messageQueue.removeAt(0)
messageQueue.add(QueuedEvent(event, targetRelays.toMutableSet()))
}
// Attempt immediate send
scope.launch {
targetRelays.forEach { relayUrl ->
val webSocket = connections[relayUrl]
if (webSocket != null) {
if (sendToRelay(event, webSocket, relayUrl)) {
markQueuedRelayDelivered(event.id, relayUrl)
val queued = runNetworkAction(liveLocationToken) {
synchronized(messageQueueLock) {
if (messageQueue.size >= MAX_QUEUED_EVENTS) {
messageQueue.removeAt(0)
}
messageQueue.add(
QueuedEvent(
event = event,
pendingRelayUrls = targetRelays.toMutableSet(),
liveLocationToken = liveLocationToken
)
)
}
scope.launch {
if (!isNetworkActionAllowed(liveLocationToken)) return@launch
targetRelays.forEach { relayUrl ->
val webSocket = connections[relayUrl]
if (webSocket != null) {
if (sendToRelay(event, webSocket, relayUrl, liveLocationToken)) {
markQueuedRelayDelivered(event.id, relayUrl)
}
}
}
}
}
if (!queued) return
}
/**
@ -333,22 +490,23 @@ class NostrRelayManager private constructor() {
id: String = generateSubscriptionId(),
handler: (NostrEvent) -> Unit,
targetRelayUrls: List<String>? = null,
owner: String = OWNER_LEGACY
owner: String = OWNER_LEGACY,
liveLocationToken: Long? = null
): String {
// Store subscription info for persistent tracking
val subscriptionInfo = SubscriptionInfo(
id = id,
filter = filter,
handler = handler,
targetRelayUrls = targetRelayUrls?.toSet(),
owner = owner
owner = owner,
liveLocationToken = liveLocationToken
)
activeSubscriptions[id] = subscriptionInfo
messageHandlers[id] = handler
// Send subscription to appropriate relays
sendSubscriptionToRelays(subscriptionInfo)
runNetworkAction(liveLocationToken) {
activeSubscriptions[id] = subscriptionInfo
messageHandlers[id] = handler
sendSubscriptionToRelays(subscriptionInfo)
}
return id
}
@ -357,32 +515,38 @@ class NostrRelayManager private constructor() {
* Send a subscription to the appropriate relays
*/
private fun sendSubscriptionToRelays(subscriptionInfo: SubscriptionInfo) {
if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return
val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter))
val message = gson.toJson(request, NostrRequest::class.java)
scope.launch {
if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return@launch
val targetRelays = subscriptionInfo.targetRelayUrls?.toList() ?: connections.keys.toList()
targetRelays.forEach { relayUrl ->
val webSocket = connections[relayUrl]
if (webSocket != null) {
try {
val success = webSocket.send(message)
if (success) {
// Track subscription for this relay
val currentSubs = subscriptions[relayUrl] ?: emptySet()
subscriptions[relayUrl] = currentSubs + subscriptionInfo.id
} else {
Log.w(TAG, "Failed to send subscription to $relayUrl: WebSocket send failed")
var success = false
runNetworkAction(subscriptionInfo.liveLocationToken) {
success = webSocket.send(message)
if (success) {
val currentSubs = subscriptions[relayUrl] ?: emptySet()
subscriptions[relayUrl] =
currentSubs + subscriptionInfo.id
}
}
if (!success) {
Log.w(TAG, "Failed to send subscription: WebSocket send failed")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send subscription to $relayUrl: ${e.message}")
Log.e(TAG, "Failed to send subscription")
}
}
}
if (connections.isEmpty()) {
Log.w(TAG, "No relay connections available for subscription, will retry on reconnection")
Log.w(TAG, "⚠️ No relay connections available for subscription, will retry on reconnection")
}
}
}
@ -396,7 +560,14 @@ class NostrRelayManager private constructor() {
messageHandlers.remove(id)
if (subscriptionInfo == null) {
Log.w(TAG, "Attempted to unsubscribe from unknown subscription: $id")
return
}
if (subscriptionInfo.liveLocationToken != null &&
!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)
) {
closeSubscriptionsOnConnectedRelays(setOf(id))
subscriptions.replaceAll { _, ids -> ids - id }
return
}
@ -404,14 +575,21 @@ class NostrRelayManager private constructor() {
val message = gson.toJson(request, NostrRequest::class.java)
scope.launch {
if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) {
closeSubscriptionsOnConnectedRelays(setOf(id))
subscriptions.replaceAll { _, ids -> ids - id }
return@launch
}
connections.forEach { (relayUrl, webSocket) ->
val currentSubs = subscriptions[relayUrl]
if (currentSubs?.contains(id) == true) {
try {
webSocket.send(message)
runNetworkAction(subscriptionInfo.liveLocationToken) {
webSocket.send(message)
}
subscriptions[relayUrl] = currentSubs - id
} catch (e: Exception) {
Log.e(TAG, "Failed to unsubscribe from $relayUrl: ${e.message}")
Log.e(TAG, "Failed to unsubscribe from relay")
}
}
}
@ -431,6 +609,9 @@ class NostrRelayManager private constructor() {
fun retryConnection(relayUrl: String) {
val relay = relaysList.find { it.url == relayUrl } ?: return
desiredConnected.set(true)
val liveToken = liveLocationRelayTokens[relayUrl]
?.takeIf { relayUrl !in nonLiveRelayUrls }
if (!isNetworkActionAllowed(liveToken)) return
// Reset reconnection attempts
relay.reconnectAttempts = 0
@ -442,7 +623,7 @@ class NostrRelayManager private constructor() {
// Attempt immediate reconnection
scope.launch {
connectToRelay(relayUrl)
connectToRelay(relayUrl, liveToken)
}
}
@ -596,7 +777,7 @@ class NostrRelayManager private constructor() {
try {
val report = validateSubscriptionConsistency()
if (!report.isConsistent && report.connectedRelayCount > 0) {
Log.w(TAG, "Subscription inconsistencies detected: ${report.inconsistencies}")
Log.w(TAG, "Nostr subscription inconsistencies detected")
// Auto-repair: re-establish subscriptions for relays with missing ones
connections.forEach { (relayUrl, webSocket) ->
@ -608,7 +789,7 @@ class NostrRelayManager private constructor() {
val missingSubs = expectedSubs - currentSubs
if (missingSubs.isNotEmpty()) {
Log.i(TAG, "Auto-repairing ${missingSubs.size} missing subscriptions for $relayUrl")
Log.i(TAG, "Auto-repairing ${missingSubs.size} missing subscriptions")
restoreSubscriptionsForRelay(relayUrl, webSocket)
}
}
@ -618,6 +799,7 @@ class NostrRelayManager private constructor() {
}
}
}
}
/**
@ -630,8 +812,14 @@ class NostrRelayManager private constructor() {
// MARK: - Private Methods
private suspend fun connectToRelay(urlString: String) {
private suspend fun connectToRelay(
urlString: String,
liveLocationToken: Long? = null
) {
if (!desiredConnected.get()) return
val connectionToken = liveLocationToken
?.takeIf { urlString !in nonLiveRelayUrls }
if (!isNetworkActionAllowed(connectionToken)) return
// Skip if we already have a connection
if (connections.containsKey(urlString)) {
return
@ -642,27 +830,43 @@ class NostrRelayManager private constructor() {
.url(urlString)
.build()
val webSocket = httpClient.newWebSocket(request, RelayWebSocketListener(urlString))
val existing = connections.putIfAbsent(urlString, webSocket)
if (existing != null) {
webSocket.close(1000, "Duplicate connection")
} else if (!desiredConnected.get()) {
connections.remove(urlString, webSocket)
webSocket.close(1000, "Connection no longer desired")
val started = runNetworkAction(connectionToken) {
val webSocket = httpClient.newWebSocket(
request,
RelayWebSocketListener(urlString, connectionToken)
)
val existing = connections.putIfAbsent(urlString, webSocket)
when {
existing != null -> webSocket.close(1000, "Duplicate connection")
!desiredConnected.get() -> {
connections.remove(urlString, webSocket)
webSocket.close(1000, "Connection no longer desired")
}
}
}
if (!started) return
} catch (e: Exception) {
Log.e(TAG, "Failed to create WebSocket connection to $urlString: ${e.message}")
handleConnectionCreationFailure(urlString, e)
Log.e(TAG, "Failed to create WebSocket connection")
handleConnectionCreationFailure(urlString, e, connectionToken)
}
}
private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String): Boolean {
private fun sendToRelay(
event: NostrEvent,
webSocket: WebSocket,
relayUrl: String,
liveLocationToken: Long? = null
): Boolean {
if (!isNetworkActionAllowed(liveLocationToken)) return false
return try {
val request = NostrRequest.Event(event)
val message = gson.toJson(request, NostrRequest::class.java)
val success = webSocket.send(message)
var success = false
runNetworkAction(liveLocationToken) {
success = webSocket.send(message)
}
if (success) {
// Update relay stats
val relay = relaysList.find { it.url == relayUrl }
@ -670,11 +874,11 @@ class NostrRelayManager private constructor() {
updateRelaysList()
true
} else {
Log.e(TAG, "Failed to send event to $relayUrl: WebSocket send failed")
Log.e(TAG, "Failed to send event: WebSocket send failed")
false
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send event to $relayUrl: ${e.message}")
Log.e(TAG, "Failed to send event")
false
}
}
@ -695,7 +899,7 @@ class NostrRelayManager private constructor() {
try {
val jsonElement = JsonParser.parseString(message)
if (!jsonElement.isJsonArray) {
Log.w(TAG, "Received non-array message from $relayUrl")
Log.w(TAG, "Received non-array message from relay")
return
}
@ -709,7 +913,10 @@ class NostrRelayManager private constructor() {
updateRelaysList()
// CLIENT-SIDE FILTER ENFORCEMENT: Ensure this event matches the subscription's filter
activeSubscriptions[response.subscriptionId]?.let { subInfo ->
val subscriptionInfo = activeSubscriptions[response.subscriptionId]
?: return
if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return
subscriptionInfo.let { subInfo ->
val matches = try { subInfo.filter.matches(response.event) } catch (e: Exception) { true }
if (!matches) {
// Do NOT call deduplicator here to allow the correct subscription to process it later
@ -723,12 +930,15 @@ class NostrRelayManager private constructor() {
val handler = messageHandlers[response.subscriptionId]
if (handler != null) {
scope.launch(Dispatchers.Main) {
handler(event)
if (isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) {
handler(event)
}
}
} else {
Log.d(TAG, "No handler for subscription ${response.subscriptionId}")
Log.w(TAG, "⚠️ No handler for Nostr subscription")
}
}
}
is NostrResponse.EndOfStoredEvents -> {
@ -739,39 +949,57 @@ class NostrRelayManager private constructor() {
val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId)
if (!response.accepted) {
val level = if (wasGiftWrap) Log.WARN else Log.ERROR
Log.println(level, TAG, "Event rejected by relay $relayUrl: ${response.message ?: "no reason"}")
Log.println(level, TAG, "Event rejected by relay: ${response.message ?: "no reason"}")
}
}
is NostrResponse.Notice -> {
Log.d(TAG, "Notice from $relayUrl: ${response.message}")
// No action needed
}
is NostrResponse.Unknown -> {
Log.d(TAG, "Unknown message type from $relayUrl")
// No action needed
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message from $relayUrl: ${e.message}")
Log.e(TAG, "Failed to parse relay message")
}
}
private fun handleDisconnection(relayUrl: String, webSocket: WebSocket, error: Throwable) {
// A close/failure from an intentionally closed or replaced socket is stale. In particular,
// it must not remove a newer socket or schedule a reconnect after manual disconnect.
private fun handleDisconnection(
relayUrl: String,
webSocket: WebSocket,
error: Throwable,
liveLocationToken: Long? = null
) {
// Ignore callbacks from intentionally closed or replaced sockets. They must not remove a
// newer socket or schedule a reconnect after a controlled disconnect/privacy revocation.
if (!connections.remove(relayUrl, webSocket)) return
subscriptions.remove(relayUrl)
handleCurrentDisconnection(relayUrl, error)
handleCurrentDisconnection(relayUrl, error, liveLocationToken)
}
private fun handleConnectionCreationFailure(relayUrl: String, error: Throwable) {
private fun handleConnectionCreationFailure(
relayUrl: String,
error: Throwable,
liveLocationToken: Long?
) {
if (!desiredConnected.get()) return
handleCurrentDisconnection(relayUrl, error)
handleCurrentDisconnection(relayUrl, error, liveLocationToken)
}
private fun handleCurrentDisconnection(relayUrl: String, error: Throwable) {
private fun handleCurrentDisconnection(
relayUrl: String,
error: Throwable,
liveLocationToken: Long?
) {
val connectionToken = liveLocationToken
?.takeIf { relayUrl !in nonLiveRelayUrls }
updateRelayStatus(relayUrl, false, error)
if (!desiredConnected.get()) return
if (!desiredConnected.get() ||
!isNetworkActionAllowed(connectionToken)
) return
// Check if this is a DNS error
val errorMessage = error.message?.lowercase() ?: ""
@ -781,7 +1009,7 @@ class NostrRelayManager private constructor() {
val relay = relaysList.find { it.url == relayUrl }
if (relay?.lastError == null) {
Log.w(TAG, "Nostr relay DNS failure for $relayUrl - not retrying")
Log.w(TAG, "Nostr relay DNS failure; not retrying")
}
return
}
@ -792,7 +1020,7 @@ class NostrRelayManager private constructor() {
// Stop attempting after max attempts
if (relay.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
Log.w(TAG, "Max reconnection attempts ($MAX_RECONNECT_ATTEMPTS) reached for $relayUrl")
Log.w(TAG, "Max Nostr relay reconnection attempts reached")
return
}
@ -804,15 +1032,21 @@ class NostrRelayManager private constructor() {
relay.nextReconnectTime = System.currentTimeMillis() + backoffInterval
Log.d(TAG, "Scheduling reconnection to $relayUrl in ${backoffInterval / 1000}s (attempt ${relay.reconnectAttempts})")
Log.d(TAG, "Scheduling Nostr relay reconnection")
reconnectJobs.remove(relayUrl)?.cancel()
val reconnectJob = scope.launch {
delay(backoffInterval)
if (desiredConnected.get()) connectToRelay(relayUrl)
if (desiredConnected.get() &&
isNetworkActionAllowed(connectionToken)
) {
connectToRelay(relayUrl, connectionToken)
}
}
reconnectJobs[relayUrl] = reconnectJob
reconnectJob.invokeOnCompletion { reconnectJobs.remove(relayUrl, reconnectJob) }
reconnectJob.invokeOnCompletion {
reconnectJobs.remove(relayUrl, reconnectJob)
}
}
private fun updateRelayStatus(url: String, isConnected: Boolean, error: Throwable? = null) {
@ -843,7 +1077,7 @@ class NostrRelayManager private constructor() {
}
private fun generateSubscriptionId(): String {
return "sub-${System.currentTimeMillis()}-${(Math.random() * 1000).toInt()}"
return "sub-${UUID.randomUUID()}"
}
/**
@ -852,30 +1086,34 @@ class NostrRelayManager private constructor() {
private fun restoreSubscriptionsForRelay(relayUrl: String, webSocket: WebSocket) {
val subscriptionsToRestore = activeSubscriptions.values.filter { subscriptionInfo ->
// Include subscription if it targets all relays or specifically targets this relay
subscriptionInfo.targetRelayUrls == null || subscriptionInfo.targetRelayUrls.contains(relayUrl)
isNetworkActionAllowed(subscriptionInfo.liveLocationToken) &&
(subscriptionInfo.targetRelayUrls == null ||
subscriptionInfo.targetRelayUrls.contains(relayUrl))
}
if (subscriptionsToRestore.isEmpty()) {
return
}
Log.d(TAG, "Restoring ${subscriptionsToRestore.size} subscriptions for relay: $relayUrl")
subscriptionsToRestore.forEach { subscriptionInfo ->
try {
val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter))
val message = gson.toJson(request, NostrRequest::class.java)
val success = webSocket.send(message)
if (success) {
// Track subscription for this relay
val currentSubs = subscriptions[relayUrl] ?: emptySet()
subscriptions[relayUrl] = currentSubs + subscriptionInfo.id
} else {
Log.w(TAG, "Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: WebSocket send failed")
var success = false
runNetworkAction(subscriptionInfo.liveLocationToken) {
success = webSocket.send(message)
if (success) {
val currentSubs = subscriptions[relayUrl] ?: emptySet()
subscriptions[relayUrl] =
currentSubs + subscriptionInfo.id
}
}
if (!success) {
Log.w(TAG, "Failed to restore subscription: WebSocket send failed")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: ${e.message}")
Log.e(TAG, "Failed to restore subscription")
}
}
}
@ -883,15 +1121,21 @@ class NostrRelayManager private constructor() {
/**
* WebSocket listener for relay connections
*/
private inner class RelayWebSocketListener(private val relayUrl: String) : WebSocketListener() {
private inner class RelayWebSocketListener(
private val relayUrl: String,
private val liveLocationToken: Long?
) : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
if (!desiredConnected.get() || connections[relayUrl] !== webSocket) {
if (!desiredConnected.get() ||
connections[relayUrl] !== webSocket ||
!isNetworkActionAllowed(liveLocationToken)
) {
connections.remove(relayUrl, webSocket)
webSocket.close(1000, "Stale connection")
return
}
reconnectJobs.remove(relayUrl)?.cancel()
Log.i(TAG, "Connected to Nostr relay: $relayUrl")
updateRelayStatus(relayUrl, true)
// Restore all active subscriptions for this relay
@ -900,11 +1144,14 @@ class NostrRelayManager private constructor() {
// Process only events still pending for this relay, outside the queue lock.
val queuedForRelay = synchronized(messageQueueLock) {
messageQueue
.filter { relayUrl in it.pendingRelayUrls }
.map { it.event }
.filter {
relayUrl in it.pendingRelayUrls &&
isNetworkActionAllowed(it.liveLocationToken)
}
.map { it.event to it.liveLocationToken }
}
queuedForRelay.forEach { event ->
if (sendToRelay(event, webSocket, relayUrl)) {
queuedForRelay.forEach { (event, token) ->
if (sendToRelay(event, webSocket, relayUrl, token)) {
markQueuedRelayDelivered(event.id, relayUrl)
}
}
@ -920,14 +1167,13 @@ class NostrRelayManager private constructor() {
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.i(TAG, "Disconnected from Nostr relay $relayUrl: $code $reason")
val error = Exception("WebSocket closed: $code $reason")
handleDisconnection(relayUrl, webSocket, error)
handleDisconnection(relayUrl, webSocket, error, liveLocationToken)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "WebSocket failure for $relayUrl: ${t.message}")
handleDisconnection(relayUrl, webSocket, t)
Log.e(TAG, "Nostr WebSocket failure")
handleDisconnection(relayUrl, webSocket, t, liveLocationToken)
}
}
}

View File

@ -2,6 +2,7 @@ package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import com.bitchat.android.geohash.LiveLocationPrivacyGate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -21,16 +22,41 @@ class NostrSubscriptionManager(
fun connect() = scope.launch { runCatching { relayManager.connect() }.onFailure { Log.e(TAG, "connect failed: ${it.message}") } }
fun disconnect() = scope.launch { runCatching { relayManager.disconnect() }.onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } }
fun subscribeGiftWraps(pubkey: String, sinceMs: Long, id: String, handler: (NostrEvent) -> Unit) {
fun subscribeGiftWraps(
pubkey: String,
sinceMs: Long,
id: String,
handler: (NostrEvent) -> Unit,
liveLocationToken: Long? = null
) {
scope.launch {
if (liveLocationToken != null &&
!LiveLocationPrivacyGate.accepts(liveLocationToken)
) return@launch
val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs)
relayManager.subscribe(filter, id, handler, owner = owner)
relayManager.subscribe(
filter = filter,
id = id,
handler = handler,
owner = owner,
liveLocationToken = liveLocationToken
)
}
}
/** Subscribe to geohash chat messages only (kind 20000) — low-volume, kept alive in background. */
fun subscribeGeohashMessages(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
fun subscribeGeohashMessages(
geohash: String,
sinceMs: Long,
limit: Int,
id: String,
handler: (NostrEvent) -> Unit,
liveLocationToken: Long? = null
) {
scope.launch {
if (liveLocationToken != null &&
!LiveLocationPrivacyGate.accepts(liveLocationToken)
) return@launch
val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit)
relayManager.subscribeForGeohash(
geohash,
@ -39,14 +65,25 @@ class NostrSubscriptionManager(
handler,
includeDefaults = false,
nRelays = 5,
owner = owner
owner = owner,
liveLocationToken = liveLocationToken
)
}
}
/** Subscribe to geohash presence heartbeats only (kind 20001) — high-volume, paused in background. */
fun subscribeGeohashPresence(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
fun subscribeGeohashPresence(
geohash: String,
sinceMs: Long,
limit: Int,
id: String,
handler: (NostrEvent) -> Unit,
liveLocationToken: Long? = null
) {
scope.launch {
if (liveLocationToken != null &&
!LiveLocationPrivacyGate.accepts(liveLocationToken)
) return@launch
val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit)
relayManager.subscribeForGeohash(
geohash,
@ -55,13 +92,13 @@ class NostrSubscriptionManager(
handler,
includeDefaults = false,
nRelays = 5,
owner = owner
owner = owner,
liveLocationToken = liveLocationToken
)
}
}
fun unsubscribe(id: String) { scope.launch { runCatching { relayManager.unsubscribe(id) } } }
fun unsubscribeAllOwned() {
scope.launch { runCatching { relayManager.unsubscribeOwner(owner) } }
}

View File

@ -92,7 +92,7 @@ object RelayDirectory {
val c = com.bitchat.android.geohash.Geohash.decodeToCenter(geohash)
c
} catch (e: Exception) {
Log.e(TAG, "Failed to decode geohash '$geohash': ${e.message}")
Log.e(TAG, "Failed to decode geohash")
return emptyList()
}
@ -300,4 +300,3 @@ object RelayDirectory {
}
}
}

View File

@ -42,6 +42,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.ui.theme.BitchatFontFamily
import androidx.annotation.DrawableRes
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.layout.RowScope
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.BitChatBrandButton
import com.bitchat.android.net.ArtiTorManager
@ -80,6 +83,15 @@ private val HeaderTapTarget = 44.dp
/** Corner radius for the header's tappable label+icon clusters. */
private val HeaderClusterShape = RoundedCornerShape(8.dp)
/**
* Edge insets for the bar.
*
* Asymmetric because the leading glyph sits in a 44.dp tap target whose padding already supplies
* some optical inset, while the trailing action's does the same on the other side.
*/
internal val HeaderInsetStart = 12.dp
internal val HeaderInsetEnd = 8.dp
/**
* A minimum-48x40 tap target wrapping a small icon.
*
@ -273,39 +285,151 @@ fun NoiseSessionIcon(
// The pre-redesign colours for the first two states were `0x87878700`, i.e. alpha 0x87 with
// an all-but-transparent RGB - the icons were effectively invisible. They now use the
// palette's secondary text colour.
val (icon, color, contentDescription) = when (sessionState) {
val (iconRes, color, contentDescription) = when (sessionState) {
"uninitialized" -> Triple(
Icons.Outlined.NoEncryption,
R.drawable.ic_spec_lock_open,
colorScheme.onSurfaceVariant,
stringResource(R.string.cd_ready_for_handshake)
)
"handshaking" -> Triple(
Icons.Outlined.Sync,
R.drawable.ic_spec_sync,
colorScheme.onSurfaceVariant,
stringResource(R.string.cd_handshake_in_progress)
)
"established" -> Triple(
Icons.Filled.Lock,
palette.accentOrange,
R.drawable.ic_spec_lock,
colorScheme.primary,
stringResource(R.string.cd_encrypted)
)
else -> { // "failed" or any other state
Triple(
Icons.Outlined.Warning,
R.drawable.ic_spec_warning,
colorScheme.error,
stringResource(R.string.cd_handshake_failed)
)
}
}
Icon(
imageVector = icon,
painter = painterResource(iconRes),
contentDescription = contentDescription,
modifier = modifier,
tint = color
)
}
/**
* Reachability glyph for a conversation, drawn from the same spec set the main header uses.
*
* Mirrors the main header's channel button: a globe for anything reached over the internet, the
* range mark for the local mesh, and the more specific transport glyph when we know it.
*/
@DrawableRes
internal fun conversationTransportIcon(
isReachedOverInternet: Boolean,
isWifiAware: Boolean,
isDirect: Boolean
): Int = when {
isReachedOverInternet -> R.drawable.ic_spec_globe
isWifiAware -> R.drawable.ic_spec_wifi
isDirect -> R.drawable.ic_spec_bluetooth
else -> R.drawable.ic_spec_routed
}
/**
* The shared chrome for a conversation header — private chats and channels alike.
*
* Deliberately built from the same tokens as [MainHeader] rather than from `TopAppBar`: identical
* height, identical 12/8.dp edge insets, the leading glyph in a [HeaderTapTarget]-sized slot so it
* lands exactly where the brand mark does, the same -6.dp optical nudge pulling the title toward
* that glyph, and the same [HeaderTextSize]. Anything less and the header visibly shifts as you
* move between the main timeline and a conversation.
*
* Actions are right-aligned and unweighted, so a long title yields space to them rather than
* pushing them off screen.
*/
@Composable
fun ConversationHeader(
@DrawableRes leadingIconRes: Int,
leadingIconTint: Color,
title: String,
modifier: Modifier = Modifier,
onTitleClick: (() -> Unit)? = null,
leadingContentDescription: String? = null,
actions: @Composable RowScope.() -> Unit = {}
) {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = modifier
.fillMaxWidth()
.height(ChatHeaderHeight)
.padding(start = HeaderInsetStart, end = HeaderInsetEnd),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(HeaderTapTarget),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(leadingIconRes),
contentDescription = leadingContentDescription,
modifier = Modifier.size(HeaderIconSize),
tint = leadingIconTint
)
}
// Same optical correction as the main header: the 44.dp tap target leaves more gap
// than the design wants between glyph and label.
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontSize = HeaderTextSize,
fontWeight = FontWeight.Medium,
color = colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.offset(x = (-6).dp)
.then(
if (onTitleClick != null) {
Modifier
.clip(HeaderClusterShape)
.pressScaleClickable(onClick = onTitleClick)
.padding(horizontal = 6.dp, vertical = 4.dp)
} else {
Modifier
}
)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
content = actions
)
}
}
/** An action slot in a [ConversationHeader], matching the main header's icon buttons. */
@Composable
fun ConversationHeaderAction(
onClick: () -> Unit,
contentDescription: String?,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) = HeaderIconButton(
onClick = onClick,
contentDescription = contentDescription,
modifier = modifier,
content = content
)
@Composable
fun NicknameEditor(
value: String,
@ -486,51 +610,27 @@ private fun ChannelHeader(
) {
val colorScheme = MaterialTheme.colorScheme
Box(modifier = Modifier.fillMaxWidth()) {
// Back: a chevron alone is unambiguous here and buys back ~40.dp of title space that
// the old "< back" label consumed.
HeaderIconButton(
// No back affordance: the close action on the right is the way out, exactly as in a private
// chat. Leaving the channel outright lives on its row in the network sheet, so it does not
// need a second, easily-mistaken home next to the exit.
ConversationHeader(
leadingIconRes = R.drawable.ic_spec_chat_bubbles,
leadingIconTint = colorScheme.primary,
leadingContentDescription = null,
title = "#$channel",
onTitleClick = onSidebarClick
) {
ConversationHeaderAction(
onClick = onBackClick,
contentDescription = stringResource(R.string.back),
modifier = Modifier.align(Alignment.CenterStart)
contentDescription = stringResource(R.string.close_plain)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
painter = painterResource(R.drawable.ic_spec_close),
contentDescription = stringResource(R.string.close_plain),
modifier = Modifier.size(HeaderIconSize),
tint = colorScheme.primary
tint = colorScheme.onSurfaceVariant
)
}
// Title - perfectly centered regardless of other elements
Text(
text = "#$channel",
style = MaterialTheme.typography.titleMedium,
fontSize = HeaderTextSize,
color = colorScheme.primary,
modifier = Modifier
.align(Alignment.Center)
.clip(HeaderClusterShape)
.pressScaleClickable(onClick = onSidebarClick)
.heightIn(min = HeaderTapTarget)
.wrapContentHeight(Alignment.CenterVertically)
.padding(horizontal = 10.dp)
)
// Leave button - positioned on the right
Text(
text = stringResource(R.string.chat_leave),
style = MaterialTheme.typography.labelMedium,
fontSize = 15.sp,
color = colorScheme.error,
modifier = Modifier
.align(Alignment.CenterEnd)
.clip(HeaderClusterShape)
.pressScaleClickable(onClick = onLeaveChannel)
.heightIn(min = HeaderTapTarget)
.wrapContentHeight(Alignment.CenterVertically)
.padding(horizontal = 10.dp)
)
}
}
@ -556,7 +656,10 @@ private fun MainHeader(
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.height(ChatHeaderHeight)
.padding(start = HeaderInsetStart, end = HeaderInsetEnd),
verticalAlignment = Alignment.CenterVertically
) {
// MARK: - Identity cluster.
@ -615,7 +718,7 @@ private fun MainHeader(
contentDescription = stringResource(R.string.cd_unread_private_messages)
) {
Icon(
imageVector = Icons.Filled.Email,
painter = painterResource(R.drawable.ic_spec_envelope),
contentDescription = stringResource(R.string.cd_unread_private_messages),
modifier = Modifier.size(HeaderIconSize),
tint = palette.accentOrange

View File

@ -62,6 +62,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
val colorScheme = MaterialTheme.colorScheme
val messages by viewModel.messages.collectAsStateWithLifecycle()
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
@ -197,6 +199,33 @@ fun ChatScreen(viewModel: ChatViewModel) {
}
}
val mentionPeerIdentities = remember(
displayMessages,
currentChannel,
selectedLocationChannel,
connectedPeers,
peerNicknames,
geohashPeople,
) {
val knownPeers = if (
currentChannel == null && selectedLocationChannel is ChannelID.Location
) {
val duplicateNames = duplicateGeohashBaseNames(geohashPeople)
geohashPeople.mapNotNull { person ->
if (isUnannouncedNickname(person.displayName)) return@mapNotNull null
val displayName = disambiguatedGeohashDisplayName(person, duplicateNames)
displayName to PeerIdentity.nostr(person.id)
}
} else {
connectedPeers.mapNotNull { peerID ->
peerNicknames[peerID]?.let { displayName ->
displayName to PeerIdentity.mesh(peerID)
}
}
}
buildMentionPeerIdentityMap(displayMessages, knownPeers)
}
// Determine whether to show media buttons (only hide in geohash location chats)
val showMediaButtons = when {
currentChannel != null -> true
@ -238,6 +267,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
messages = displayMessages,
currentUserNickname = nickname,
meshService = viewModel.meshServiceFacade,
mentionPeerIdentities = mentionPeerIdentities,
modifier = Modifier.fillMaxSize(),
conversationKey = conversationKey,
contentPadding = PaddingValues(
@ -350,6 +380,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
commandSuggestions = commandSuggestions,
showMentionSuggestions = showMentionSuggestions,
mentionSuggestions = mentionSuggestions,
mentionPeerIdentities = mentionPeerIdentities,
onCommandSuggestionClick = { suggestion: CommandSuggestion ->
val commandText = viewModel.selectCommandSuggestion(suggestion)
messageText = TextFieldValue(
@ -375,7 +406,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
// Floating header - positioned absolutely at top, ignores keyboard
ChatFloatingHeader(
headerHeight = headerHeight,
selectedPrivatePeer = null,
currentChannel = currentChannel,
nickname = nickname,
@ -558,6 +588,7 @@ fun ChatInputSection(
commandSuggestions: List<CommandSuggestion>,
showMentionSuggestions: Boolean,
mentionSuggestions: List<String>,
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
onCommandSuggestionClick: (CommandSuggestion) -> Unit,
onMentionSuggestionClick: (String) -> Unit,
selectedPrivatePeer: String?,
@ -624,6 +655,7 @@ fun ChatInputSection(
Column {
MentionSuggestionsBox(
suggestions = displayedMentionSuggestions,
mentionPeerIdentities = mentionPeerIdentities,
onSuggestionClick = onMentionSuggestionClick,
modifier = Modifier.fillMaxWidth()
)
@ -641,6 +673,7 @@ fun ChatInputSection(
currentChannel = currentChannel,
nickname = nickname,
showMediaButtons = showMediaButtons,
mentionPeerIdentities = mentionPeerIdentities,
modifier = Modifier.fillMaxWidth()
)
}
@ -664,7 +697,6 @@ private const val BarBackgroundAlpha = 0.88f
private const val HeaderOpaqueStop = 0.72f
@Composable
private fun ChatFloatingHeader(
headerHeight: Dp,
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
@ -696,38 +728,31 @@ private fun ChatFloatingHeader(
)
.windowInsetsPadding(WindowInsets.statusBars) // Extend into status bar area
) {
// A plain Row rather than M3's TopAppBar. TopAppBar silently injects a 4.dp horizontal
// pad plus a 12.dp title inset and applies its own minimum heights, which made the
// header's spacing impossible to specify exactly.
Row(
modifier = Modifier
.fillMaxWidth()
.height(headerHeight)
.padding(start = 12.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ChatHeaderContent(
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
viewModel = viewModel,
onBackClick = {
when {
selectedPrivatePeer != null -> viewModel.endPrivateChat()
currentChannel != null -> viewModel.switchToChannel(null)
}
},
onSidebarClick = onSidebarToggle,
onTripleClick = onPanicClear,
onShowAppInfo = onShowAppInfo,
onLocationChannelsClick = onLocationChannelsClick,
onLocationNotesClick = {
// Ensure location is loaded before showing sheet
locationManager.refreshChannels()
onLocationNotesClick()
// No TopAppBar: it silently injects a 4.dp horizontal pad plus a 12.dp title inset and
// applies its own minimum heights, which made the header's spacing impossible to specify
// exactly. Height and edge insets belong to each header variant, so that a conversation
// header rendered here and one rendered in a sheet are laid out identically.
ChatHeaderContent(
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
viewModel = viewModel,
onBackClick = {
when {
selectedPrivatePeer != null -> viewModel.endPrivateChat()
currentChannel != null -> viewModel.switchToChannel(null)
}
)
}
},
onSidebarClick = onSidebarToggle,
onTripleClick = onPanicClear,
onShowAppInfo = onShowAppInfo,
onLocationChannelsClick = onLocationChannelsClick,
onLocationNotesClick = {
// Ensure location is loaded before showing sheet
locationManager.refreshChannels()
onLocationNotesClick()
}
)
}
}

View File

@ -10,7 +10,7 @@ import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatPalette
import com.bitchat.android.ui.theme.ChatVisualTokens
import com.bitchat.android.ui.theme.colorForPeerSeed
import com.bitchat.android.ui.theme.colorForPeer
import java.text.SimpleDateFormat
import java.util.*
@ -31,6 +31,15 @@ internal const val MENTION_CHIP_ALPHA = ChatVisualTokens.HighlightAlpha
/** Background opacity for a mention chip referring to you. Slightly stronger to catch the eye. */
internal const val MENTION_CHIP_ALPHA_SELF = ChatVisualTokens.HighlightAlpha
/**
* Mention token grammar shared by rendered messages and the composer.
*
* The optional `#abcd` suffix is part of the mention because it disambiguates peers that use the
* same nickname. Keeping one regex prevents the composer from styling only `@name` while the
* rendered transcript styles the complete token.
*/
internal val MENTION_TOKEN_REGEX = Regex("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)")
/**
* Get RSSI-based color for signal strength visualization
*/
@ -61,7 +70,7 @@ fun formatTextMessageSender(
val senderColor = if (isSelf) {
palette.accentOrange
} else {
colorForPeerSeed(peerColorSeedForMessage(message), palette)
colorForPeer(peerIdentityForMessage(message), palette)
}
val senderWeight = FontWeight.SemiBold
val (baseName, suffix) = splitSuffix(message.sender)
@ -193,6 +202,7 @@ fun formatTextMessageBody(
palette: BitchatPalette,
contentColor: Color,
linkColor: Color,
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()),
includeTimestamp: Boolean = true
): AnnotatedString {
@ -205,6 +215,7 @@ fun formatTextMessageBody(
palette = palette,
contentColor = contentColor,
linkColor = linkColor,
mentionPeerIdentities = mentionPeerIdentities,
)
if (includeTimestamp) {
@ -267,7 +278,7 @@ fun formatMessageHeaderAnnotatedString(
val baseColor = if (isSelf) {
palette.accentOrange
} else {
colorForPeerSeed(peerColorSeedForMessage(message), palette)
colorForPeer(peerIdentityForMessage(message), palette)
}
val (baseName, suffix) = splitSuffix(message.sender)
@ -326,6 +337,71 @@ fun splitSuffix(name: String): Pair<String, String> {
return Pair(name, "")
}
/**
* Build a case-insensitive mention-token lookup from canonical peer identities.
*
* Suffixed names such as `alice#04af` resolve exactly. Their unsuffixed base is only retained when
* it identifies one peer; ambiguous bases are deliberately omitted rather than coloring a mention
* as the wrong person.
*/
internal fun buildMentionPeerIdentityMap(
messages: List<BitchatMessage>,
knownPeers: List<Pair<String, PeerIdentity>> = emptyList(),
): Map<String, PeerIdentity> {
val candidates = linkedMapOf<String, MutableSet<PeerIdentity>>()
fun add(displayName: String, identity: PeerIdentity) {
val normalizedName = displayName.trim().removePrefix("@")
if (normalizedName.isEmpty()) return
val (baseName, suffix) = splitSuffix(normalizedName)
val exactKey = normalizedName.lowercase(Locale.ROOT)
candidates.getOrPut(exactKey) { linkedSetOf() }.add(identity)
if (suffix.isNotEmpty()) {
val baseKey = baseName.lowercase(Locale.ROOT)
candidates.getOrPut(baseKey) { linkedSetOf() }.add(identity)
}
}
messages
.asSequence()
.filterNot { it.sender == "system" }
.forEach { add(it.sender, peerIdentityForMessage(it)) }
knownPeers.forEach { (displayName, identity) -> add(displayName, identity) }
return candidates.mapNotNull { (token, identities) ->
identities.singleOrNull()?.let { token to it }
}.toMap()
}
internal fun resolveMentionPeerIdentity(
mention: String,
mentionPeerIdentities: Map<String, PeerIdentity>,
): PeerIdentity? {
val mentionWithoutAt = mention.trim().removePrefix("@")
val baseName = splitSuffix(mentionWithoutAt).first
return mentionPeerIdentities[mentionWithoutAt.lowercase(Locale.ROOT)]
?: mentionPeerIdentities[baseName.lowercase(Locale.ROOT)]
}
/**
* Resolve the deterministic color for a mention on every surface that displays one.
*
* Exact suffixed tokens win; an unsuffixed nickname is only present in the identity map when it is
* unambiguous. The nickname fallback preserves the legacy behavior for peers with no stable ID.
*/
internal fun colorForMention(
mention: String,
mentionPeerIdentities: Map<String, PeerIdentity>,
palette: BitchatPalette,
): Color {
val mentionWithoutAt = mention.trim().removePrefix("@")
val identity = resolveMentionPeerIdentity(mentionWithoutAt, mentionPeerIdentities)
?: PeerIdentity.nickname(mentionWithoutAt)
return colorForPeer(identity, palette)
}
/**
* A bare `anon` label means the geohash heartbeat has not announced a username yet. The transport
* may append a `#abcd` disambiguator, which does not turn it into an announced name. Names such as
@ -356,13 +432,12 @@ private fun appendIOSFormattedContent(
palette: BitchatPalette,
contentColor: Color,
linkColor: Color,
mentionPeerIdentities: Map<String, PeerIdentity>,
) {
// iOS-style patterns: allow optional '#abcd' suffix in mentions
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex()
val hashtagMatches = hashtagPattern.findAll(content).toList()
val mentionMatches = mentionPattern.findAll(content).toList()
val mentionMatches = MENTION_TOKEN_REGEX.findAll(content).toList()
// Combine and sort matches, but exclude hashtags that overlap with mentions
val mentionRanges = mentionMatches.map { it.range }
@ -462,10 +537,10 @@ private fun appendIOSFormattedContent(
val mentionColor = if (isMentionToMe) {
palette.accentOrange
} else {
// Tint by the *mentioned* peer so a given name looks identical everywhere.
colorForPeerSeed(
PeerColorSeed(mentionWithoutAt.lowercase(Locale.ROOT)),
palette
colorForMention(
mention = mentionWithoutAt,
mentionPeerIdentities = mentionPeerIdentities,
palette = palette,
)
}
val chipAlpha = if (isMentionToMe) MENTION_CHIP_ALPHA_SELF else MENTION_CHIP_ALPHA

View File

@ -355,17 +355,9 @@ class ChatViewModel(
/**
* Ensure Nostr DM subscription for a geohash conversation key if known
* Minimal-change approach: reflectively access GeohashViewModel internals to reuse pipeline
*/
private fun ensureGeohashDMSubscriptionIfNeeded(convKey: String) {
try {
val gh = geohashViewModel.conversationGeohash(convKey)
if (!gh.isNullOrEmpty()) {
com.bitchat.android.nostr.NostrBackgroundRuntime.ensureConversationDm(gh)
}
} catch (e: Exception) {
Log.w(TAG, "ensureGeohashDMSubscriptionIfNeeded failed: ${e.message}")
}
geohashViewModel.ensureGeohashDMSubscriptionForConversation(convKey)
}
// MARK: - Channel Management (delegated)
@ -931,6 +923,11 @@ class ChatViewModel(
fun panicClearAllData() {
Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data")
try {
com.bitchat.android.geohash.LocationChannelManager
.getInstance(getApplication())
.disableLocationServices()
} catch (_: Exception) { }
// A pending one-shot downgrade confirmation must not survive panic or
// become actionable against the fresh post-wipe identity.
@ -1074,8 +1071,14 @@ class ChatViewModel(
/**
* Begin sampling multiple geohashes for participant activity
*/
fun beginGeohashSampling(geohashes: List<String>) {
geohashViewModel.beginGeohashSampling(geohashes)
fun beginGeohashSampling(
liveLocationGeohashes: Collection<String>,
userSelectedGeohashes: Collection<String>,
) {
geohashViewModel.beginGeohashSampling(
liveLocationGeohashes = liveLocationGeohashes,
userSelectedGeohashes = userSelectedGeohashes
)
}
/**
@ -1166,17 +1169,17 @@ class ChatViewModel(
}
}
// MARK: - iOS-Compatible Color System
// MARK: - Canonical peer identities
/**
* Get consistent color for a mesh peer by ID (iOS-compatible)
* Return the stable identity used by every UI surface to color a mesh peer.
*/
fun peerColorSeedForMeshPeer(peerID: String): PeerColorSeed = meshPeerColorSeed(peerID)
fun peerIdentityForMeshPeer(peerID: String): PeerIdentity = PeerIdentity.mesh(peerID)
/**
* Get consistent color for a Nostr pubkey (iOS-compatible)
* Return the stable identity used by every UI surface to color a Nostr peer.
*/
fun peerColorSeedForNostrPubkey(pubkeyHex: String): PeerColorSeed =
geohashViewModel.peerColorSeedForNostrPubkey(pubkeyHex)
fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity =
geohashViewModel.peerIdentityForNostrPubkey(pubkeyHex)
}

View File

@ -457,11 +457,20 @@ class CommandProcessor(
// Location channel: use geohash participants with collision-resistant suffixes
val geohashPeople = viewModel.geohashPeople.value
val currentNickname = state.getNicknameValue()
val duplicateNames = duplicateGeohashBaseNames(geohashPeople)
geohashPeople.mapNotNull { person ->
val displayName = person.displayName
// Exclude self from suggestions
if (displayName.startsWith("${currentNickname}#")) {
val baseName = splitSuffix(person.displayName).first
val hasNicknameCollision =
baseName.lowercase(Locale.ROOT) in duplicateNames
val displayName = disambiguatedGeohashDisplayName(person, duplicateNames)
// A unique local nickname can be excluded directly. If it collides, the
// nickname alone cannot identify which row is self, so keep the suffixed
// rows rather than accidentally hiding the other user.
if (
!hasNicknameCollision &&
baseName.equals(currentNickname, ignoreCase = true)
) {
null
} else {
displayName

View File

@ -3,6 +3,7 @@ package com.bitchat.android.ui
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.bitchat.android.geohash.DEFAULT_LIVE_LOCATION_ENABLED
import com.google.gson.Gson
import kotlin.random.Random
@ -54,7 +55,7 @@ class DataManager(private val context: Context) {
fun saveLastGeohashChannel(channelData: String) {
prefs.edit().putString("last_geohash_channel", channelData).apply()
Log.d(TAG, "Saved last geohash channel: $channelData")
Log.d(TAG, "Saved last geohash channel")
}
fun clearLastGeohashChannel() {
@ -65,12 +66,12 @@ class DataManager(private val context: Context) {
// MARK: - Location Services State
fun saveLocationServicesEnabled(enabled: Boolean) {
prefs.edit().putBoolean("location_services_enabled", enabled).apply()
prefs.edit().putBoolean("location_services_enabled", enabled).commit()
Log.d(TAG, "Saved location services enabled state: $enabled")
}
fun isLocationServicesEnabled(): Boolean {
return prefs.getBoolean("location_services_enabled", true) // Default to enabled
return prefs.getBoolean("location_services_enabled", DEFAULT_LIVE_LOCATION_ENABLED)
}
// MARK: - Channel Data Management

View File

@ -17,7 +17,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.painterResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.colorForPeerSeed
import com.bitchat.android.ui.theme.colorForPeer
import com.bitchat.android.R
import com.bitchat.android.ui.theme.LocalBitchatPalette
import java.util.*
@ -91,13 +91,8 @@ fun GeohashPeopleList(
val teleportedPersonIds = remember(sections.teleportedIn) {
sections.teleportedIn.mapTo(mutableSetOf()) { it.id.lowercase(Locale.ROOT) }
}
val baseNameCounts = remember(displayedPeople) {
buildMap {
displayedPeople.forEach { person ->
val baseName = splitSuffix(person.displayName).first
put(baseName, (get(baseName) ?: 0) + 1)
}
}
val duplicateBaseNames = remember(displayedPeople) {
duplicateGeohashBaseNames(displayedPeople)
}
Column(modifier = modifier) {
@ -141,7 +136,9 @@ fun GeohashPeopleList(
hasUnreadDM = unreadPrivateMessages.contains("nostr_${person.id.take(16)}"),
isTeleported = personIsTeleported,
viewModel = viewModel,
showHashSuffix = (baseNameCounts[splitSuffix(person.displayName).first] ?: 0) > 1,
showHashSuffix = splitSuffix(person.displayName)
.first
.lowercase(Locale.ROOT) in duplicateBaseNames,
onTap = {
if (!isMe) {
viewModel.startGeohashDM(person.id)
@ -175,6 +172,39 @@ internal data class GeohashPeopleSections(
val teleportedIn: List<GeoPerson>
)
/**
* Names that require a short identity suffix, calculated across both people sections.
*
* Matching is case-insensitive to mirror geohash chat's nickname collision handling.
*/
internal fun duplicateGeohashBaseNames(people: List<GeoPerson>): Set<String> =
people
.groupingBy { splitSuffix(it.displayName).first.lowercase(Locale.ROOT) }
.eachCount()
.filterValues { it > 1 }
.keys
/**
* The same `#abcd` disambiguator used by geohash chat.
*
* Presence rows normally carry only a base nickname, so derive the suffix from the full Nostr
* public key when a collision exists. Preserve an already-announced suffix for compatibility.
*/
internal fun geohashIdentitySuffix(person: GeoPerson, showHashSuffix: Boolean): String {
if (!showHashSuffix) return ""
val announcedSuffix = splitSuffix(person.displayName).second
return announcedSuffix.ifEmpty { "#${person.id.takeLast(4)}" }
}
internal fun disambiguatedGeohashDisplayName(
person: GeoPerson,
duplicateBaseNames: Set<String>,
): String {
val baseName = splitSuffix(person.displayName).first
val showSuffix = baseName.lowercase(Locale.ROOT) in duplicateBaseNames
return baseName + geohashIdentitySuffix(person, showSuffix)
}
/**
* Split announced identities by how they entered this geohash. Bare `anon` heartbeat identities
* are omitted, while announced names such as `anon1234` remain ordinary participants. Self is
@ -249,11 +279,11 @@ private fun GeohashPersonItem(
if (isTeleported) R.drawable.ic_spec_teleport
else R.drawable.ic_spec_on_location_person
val (baseNameRaw, suffixRaw) = splitSuffix(person.displayName)
val (baseNameRaw, _) = splitSuffix(person.displayName)
val baseName = truncateNickname(baseNameRaw)
val suffix = if (showHashSuffix) suffixRaw else ""
val assignedColor = colorForPeerSeed(
viewModel.peerColorSeedForNostrPubkey(person.id),
val suffix = geohashIdentitySuffix(person, showHashSuffix)
val assignedColor = colorForPeer(
viewModel.peerIdentityForNostrPubkey(person.id),
palette
)
val baseColor = if (isMe) palette.accentOrange else assignedColor
@ -314,7 +344,7 @@ private fun GeohashPersonItem(
text = suffix,
fontFamily = BitchatFontFamily,
fontSize = 14.sp,
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
fontWeight = FontWeight.Normal,
color = baseColor.copy(alpha = SUFFIX_ALPHA)
)
}

View File

@ -8,10 +8,12 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.viewModelScope
import com.bitchat.android.geohash.GeohashNostrPrivacyPolicy
import com.bitchat.android.geohash.LiveLocationPrivacyGate
import com.bitchat.android.nostr.GeohashMessageHandler
import com.bitchat.android.nostr.GeohashRepository
import com.bitchat.android.nostr.NostrDirectMessageHandler
import com.bitchat.android.nostr.NostrBackgroundRuntime
import com.bitchat.android.nostr.NostrDirectMessageHandler
import com.bitchat.android.nostr.NostrIdentityBridge
import com.bitchat.android.nostr.NostrProtocol
import com.bitchat.android.nostr.NostrRelayManager
@ -25,7 +27,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.util.Date
import kotlinx.coroutines.isActive
import kotlinx.coroutines.Dispatchers
import java.util.UUID
class GeohashViewModel(
application: Application,
@ -69,6 +71,19 @@ class GeohashViewModel(
private var geoTimer: Job? = null
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
private val activeSamplingGeohashes = mutableSetOf<String>()
private val samplingSubscriptionIds = mutableMapOf<String, String>()
private val liveSamplingSubscriptionGeohashes = mutableSetOf<String>()
private var requestedLiveSamplingGeohashes: Set<String> = emptySet()
private var requestedUserSamplingGeohashes: Set<String> = emptySet()
private val liveLocationRevocationListener: () -> Unit = {
val revokedLiveGeohashes = liveSamplingSubscriptionGeohashes.toSet()
revokedLiveGeohashes.forEach { geohash ->
samplingSubscriptionIds.remove(geohash)
activeSamplingGeohashes.remove(geohash)
}
liveSamplingSubscriptionGeohashes.clear()
requestedLiveSamplingGeohashes = emptySet()
}
// Geohash of the currently selected Location channel (null for Mesh/none).
private var activeChannelGeohash: String? = null
@ -77,6 +92,10 @@ class GeohashViewModel(
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
init {
LiveLocationPrivacyGate.addRevocationListener(liveLocationRevocationListener)
}
fun initialize() {
// Observe process lifecycle to manage background sampling
kotlin.runCatching {
@ -86,8 +105,12 @@ class GeohashViewModel(
locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
NostrBackgroundRuntime.attachHandlers(
NostrBackgroundRuntime.Handlers(
accountDm = { event, identity -> dmHandler.onGiftWrap(event, "", identity) },
geohashMessage = { event, geohash -> geohashMessageHandler.onEvent(event, geohash) },
accountDm = { event, identity ->
dmHandler.onGiftWrap(event, "", identity)
},
geohashMessage = { event, geohash ->
geohashMessageHandler.onEvent(event, geohash)
},
geohashDm = { event, geohash, identity ->
dmHandler.onGiftWrap(event, geohash, identity)
}
@ -127,6 +150,17 @@ class GeohashViewModel(
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
viewModelScope.launch {
try {
val canUseChannel = locationChannelManager
?.canUseSelectedLocationChannel(channel) == true
if (!canUseChannel) {
Log.w(TAG, "Blocked message to a stale live-location channel")
return@launch
}
val isLiveDerived = locationChannelManager
?.isSelectedChannelLiveDerived(channel) == true
val liveLocationToken = locationChannelManager
?.liveLocationTokenForSelectedChannel(channel)
if (isLiveDerived && liveLocationToken == null) return@launch
val tempId = "temp_${System.currentTimeMillis()}_${kotlin.random.Random.nextInt(1000)}"
val pow = PoWPreferenceManager.getCurrentSettings()
val localMsg = com.bitchat.android.model.BitchatMessage(
@ -140,40 +174,73 @@ class GeohashViewModel(
powDifficulty = if (pow.enabled) pow.difficulty else null
)
messageManager.addChannelMessage("geo:${channel.geohash}", localMsg)
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
val teleported = state.isTeleported.value
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
val identity = NostrIdentityBridge.deriveIdentity(
forGeohash = channel.geohash,
context = getApplication()
)
val teleported = locationChannelManager?.teleported?.value
?: state.isTeleported.value
val event = NostrProtocol.createEphemeralGeohashEvent(
content,
channel.geohash,
identity,
nickname,
teleported
)
val relayManager = NostrRelayManager.getInstance(getApplication())
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
relayManager.sendEventToGeohash(
event,
channel.geohash,
includeDefaults = false,
nRelays = 5,
liveLocationToken = liveLocationToken
)
} catch (e: Exception) {
Log.e(TAG, "Failed to send geohash message: ${e.message}")
}
}
}
fun beginGeohashSampling(geohashes: List<String>) {
if (geohashes.isEmpty()) {
endGeohashSampling()
return
}
// Diffing logic to avoid redundant REQ and leaks
fun beginGeohashSampling(
liveLocationGeohashes: Collection<String>,
userSelectedGeohashes: Collection<String>,
) {
requestedLiveSamplingGeohashes = liveLocationGeohashes.toSet()
requestedUserSamplingGeohashes = userSelectedGeohashes.toSet()
reconcileSamplingSubscriptions()
}
private fun reconcileSamplingSubscriptions() {
val currentSet = activeSamplingGeohashes.toSet()
val newSet = geohashes.toSet()
val newSet = GeohashNostrPrivacyPolicy.samplingTargets(
liveLocationGeohashes = requestedLiveSamplingGeohashes,
userSelectedGeohashes = requestedUserSamplingGeohashes,
liveLocationEnabled = LiveLocationPrivacyGate.isEnabled
)
val toRemove = currentSet - newSet
val toAdd = newSet - currentSet
val toPromoteToUserSelection = currentSet
.intersect(requestedUserSamplingGeohashes)
.intersect(liveSamplingSubscriptionGeohashes)
if (toAdd.isEmpty() && toRemove.isEmpty()) return
if (toAdd.isEmpty() && toRemove.isEmpty() && toPromoteToUserSelection.isEmpty()) return
Log.d(TAG, "🌍 Updating sampling: +${toAdd.size} new, -${toRemove.size} removed")
// Remove old subscriptions
toRemove.forEach { geohash ->
subscriptionManager.unsubscribe("sampling-$geohash")
unsubscribeSampling(geohash)
activeSamplingGeohashes.remove(geohash)
}
// A bookmark must remain functional after live access is revoked. Replace a
// live-tagged subscription with an untagged manual subscription immediately.
toPromoteToUserSelection.forEach { geohash ->
unsubscribeSampling(geohash)
if (isAppInForeground()) performSubscribeSampling(geohash)
}
// Add new subscriptions
activeSamplingGeohashes.addAll(toAdd)
if (isAppInForeground()) {
@ -184,11 +251,13 @@ class GeohashViewModel(
}
fun endGeohashSampling() {
requestedLiveSamplingGeohashes = emptySet()
requestedUserSamplingGeohashes = emptySet()
if (activeSamplingGeohashes.isEmpty()) return
Log.d(TAG, "🌍 Ending geohash sampling (cleaning up ${activeSamplingGeohashes.size} subs)")
activeSamplingGeohashes.toList().forEach { geohash ->
subscriptionManager.unsubscribe("sampling-$geohash")
unsubscribeSampling(geohash)
}
activeSamplingGeohashes.clear()
}
@ -206,7 +275,7 @@ class GeohashViewModel(
GeohashConversationRegistry.set(convKey, gh)
}
onStartPrivateChat(convKey)
Log.d(TAG, "🗨️ Started geohash DM with ${pubkeyHex} -> ${convKey} (geohash=${gh})")
Log.d(TAG, "🗨️ Started geohash DM")
}
fun startGeohashDMByNickname(nickname: String, onStartPrivateChat: (String) -> Unit) {
@ -259,12 +328,18 @@ class GeohashViewModel(
locationChannelManager?.select(channel) ?: run { Log.w(TAG, "Cannot select location channel - not initialized") }
}
fun ensureGeohashDMSubscriptionForConversation(conversationKey: String) {
val geohash = repo.getConversationGeohash(conversationKey) ?: return
NostrBackgroundRuntime.ensureConversationDm(geohash)
}
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex)
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
fun conversationGeohash(conversationKey: String): String? = repo.getConversationGeohash(conversationKey)
fun conversationGeohash(conversationKey: String): String? =
repo.getConversationGeohash(conversationKey)
fun peerColorSeedForNostrPubkey(pubkeyHex: String): PeerColorSeed =
nostrPeerColorSeed(pubkeyHex)
fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity =
PeerIdentity.nostr(pubkeyHex)
private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
geoTimer?.cancel(); geoTimer = null
@ -280,7 +355,12 @@ class GeohashViewModel(
repo.refreshGeohashPeople()
}
is com.bitchat.android.geohash.ChannelID.Location -> {
Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}")
if (locationChannelManager?.canUseSelectedLocationChannel(channel.channel) != true) {
Log.w(TAG, "Ignoring a stale live-location channel selection")
switchLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh)
return
}
Log.d(TAG, "📍 Switching to geohash channel")
activeChannelGeohash = channel.channel.geohash
repo.setCurrentGeohash(channel.channel.geohash)
repo.refreshGeohashPeople()
@ -291,16 +371,19 @@ class GeohashViewModel(
try {
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
// We don't update participant here anymore; presence loop handles it via Kind 20001
val teleported = state.isTeleported.value
val teleported = locationChannelManager?.teleported?.value
?: state.isTeleported.value
if (teleported) repo.markTeleported(identity.publicKeyHex)
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
startGeoParticipantsTimer()
val liveLocationToken = locationChannelManager
?.liveLocationTokenForSelectedChannel(channel.channel)
// Presence heartbeat firehose (kind 20001) is the high-volume data hog; only
// run it in the foreground. It is restored in onStart() and torn down in onStop().
if (isAppInForeground()) {
subscribeChannelPresence(channel.channel.geohash)
subscribeChannelPresence(channel.channel.geohash, liveLocationToken)
}
}
null -> {
@ -316,14 +399,18 @@ class GeohashViewModel(
* High-volume; only used to refresh the participant list, so it is torn down in
* onStop() and restored in onStart() to cut background mobile data.
*/
private fun subscribeChannelPresence(geohash: String) {
val subId = "geohash-presence-$geohash"; currentGeohashPresenceSubId = subId
private fun subscribeChannelPresence(
geohash: String,
liveLocationToken: Long?
) {
val subId = "geohash-presence-${UUID.randomUUID()}"; currentGeohashPresenceSubId = subId
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 3600000L,
limit = 200,
id = subId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
handler = { event -> geohashMessageHandler.onEvent(event, geohash) },
liveLocationToken = liveLocationToken
)
}
@ -343,16 +430,34 @@ class GeohashViewModel(
fun shutdownUiSubscriptions() {
subscriptionManager.unsubscribeAllOwned()
geoTimer?.cancel()
geoTimer = null
kotlin.runCatching {
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
}
LiveLocationPrivacyGate.removeRevocationListener(liveLocationRevocationListener)
}
override fun onStart(owner: LifecycleOwner) {
Log.d(TAG, "🌍 App foregrounded: resuming Nostr streaming")
// Android permission may have changed while backgrounded. Invalidate the
// process-wide token before restoring any subscription or heartbeat.
locationChannelManager?.syncPermissionState()
// Restore the presence heartbeat firehose for the selected geohash channel.
// (The chat message stream is kept alive in the background, so it is not restored here.)
activeChannelGeohash?.let { subscribeChannelPresence(it) }
val selected = locationChannelManager?.selectedChannel?.value
val selectedLocation = selected as? com.bitchat.android.geohash.ChannelID.Location
if (selectedLocation != null &&
selectedLocation.channel.geohash == activeChannelGeohash &&
locationChannelManager?.canUseSelectedLocationChannel(selectedLocation.channel) == true
) {
subscribeChannelPresence(
selectedLocation.channel.geohash,
locationChannelManager
?.liveLocationTokenForSelectedChannel(selectedLocation.channel)
)
}
// Resume geohash sampling subscriptions
activeSamplingGeohashes.forEach { performSubscribeSampling(it) }
// Resume the participant-refresh polling timer if a geohash is selected
@ -367,22 +472,55 @@ class GeohashViewModel(
// The chat message stream (kind 20000) is intentionally left active so messages still arrive.
currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null }
// Drop geohash sampling subscriptions
activeSamplingGeohashes.forEach { subscriptionManager.unsubscribe("sampling-$it") }
activeSamplingGeohashes.forEach(::unsubscribeSampling)
// Stop participant-refresh polling
geoTimer?.cancel(); geoTimer = null
// Process-owned message and DM subscriptions remain active.
}
private fun performSubscribeSampling(geohash: String) {
val subscriptionId = samplingSubscriptionIds.getOrPut(geohash) {
"sampling-${UUID.randomUUID()}"
}
// Sampling only needs participant counts, never message bodies, so it subscribes to
// presence heartbeats only (kind 20001) to keep the payload small.
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200,
id = "sampling-$geohash",
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
)
val subscribe = {
liveSamplingSubscriptionGeohashes.remove(geohash)
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200,
id = subscriptionId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
)
}
if (geohash in requestedUserSamplingGeohashes) {
subscribe()
} else if (geohash in requestedLiveSamplingGeohashes) {
val isCurrentLiveTarget = locationChannelManager
?.availableChannels
?.value
?.any { it.geohash == geohash } == true
if (!isCurrentLiveTarget) return
val token = LiveLocationPrivacyGate.captureToken() ?: return
LiveLocationPrivacyGate.runIfAllowed(token) {
liveSamplingSubscriptionGeohashes.add(geohash)
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200,
id = subscriptionId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) },
liveLocationToken = token
)
}
}
}
private fun unsubscribeSampling(geohash: String) {
samplingSubscriptionIds.remove(geohash)?.let(subscriptionManager::unsubscribe)
liveSamplingSubscriptionGeohashes.remove(geohash)
}
private fun isAppInForeground(): Boolean {

View File

@ -50,7 +50,6 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
@ -65,8 +64,8 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.withStyle
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatPalette
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.features.voice.normalizeAmplitudeSample
@ -90,38 +89,22 @@ class SlashCommandVisualTransformation(
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val slashCommandRegex = Regex("(/\\w+)(?=\\s|$)")
val annotatedString = buildAnnotatedString {
var lastIndex = 0
slashCommandRegex.findAll(text.text).forEach { match ->
// Add text before the match
if (match.range.first > lastIndex) {
append(text.text.substring(lastIndex, match.range.first))
}
// Add the styled slash command
withStyle(
style = SpanStyle(
color = commandColor,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
background = commandBackground
)
) {
append(match.value)
}
lastIndex = match.range.last + 1
}
// Add remaining text
if (lastIndex < text.text.length) {
append(text.text.substring(lastIndex))
}
val builder = AnnotatedString.Builder(text)
slashCommandRegex.findAll(text.text).forEach { match ->
builder.addStyle(
style = SpanStyle(
color = commandColor,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
background = commandBackground
),
start = match.range.first,
end = match.range.last + 1,
)
}
return TransformedText(
text = annotatedString,
text = builder.toAnnotatedString(),
offsetMapping = OffsetMapping.Identity
)
}
@ -132,45 +115,55 @@ class SlashCommandVisualTransformation(
* while preserving cursor positioning and click handling
*/
class MentionVisualTransformation(
private val mentionColor: Color,
private val mentionBackground: Color,
private val mentionPeerIdentities: Map<String, PeerIdentity>,
private val palette: BitchatPalette,
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val mentionRegex = Regex("@([a-zA-Z0-9_]+)")
val annotatedString = buildAnnotatedString {
var lastIndex = 0
mentionRegex.findAll(text.text).forEach { match ->
// Add text before the match
if (match.range.first > lastIndex) {
append(text.text.substring(lastIndex, match.range.first))
}
// Add the styled mention
withStyle(
val builder = AnnotatedString.Builder(text)
MENTION_TOKEN_REGEX.findAll(text.text).forEach { match ->
val start = match.range.first
val end = match.range.last + 1
val suffixOffset = match.value.lastIndexOf('#').takeIf { it > 0 }
val suffixStart = suffixOffset?.let(start::plus) ?: end
val mentionColor = colorForMention(
mention = match.value,
mentionPeerIdentities = mentionPeerIdentities,
palette = palette,
)
// Keep the whole token on one continuous color-derived chip.
builder.addStyle(
style = SpanStyle(
background = mentionColor.copy(alpha = MENTION_CHIP_ALPHA),
),
start = start,
end = end,
)
builder.addStyle(
style = SpanStyle(
color = mentionColor,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold,
),
start = start,
end = suffixStart,
)
if (suffixStart < end) {
builder.addStyle(
style = SpanStyle(
color = mentionColor,
color = mentionColor.copy(alpha = SUFFIX_ALPHA),
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold,
// Mirrors the mention chip used in rendered messages, so what you type
// looks like what everyone will see.
background = mentionBackground
)
) {
append(match.value)
}
lastIndex = match.range.last + 1
}
// Add remaining text
if (lastIndex < text.text.length) {
append(text.text.substring(lastIndex))
),
start = suffixStart,
end = end,
)
}
}
return TransformedText(
text = annotatedString,
text = builder.toAnnotatedString(),
offsetMapping = OffsetMapping.Identity
)
}
@ -312,6 +305,7 @@ fun MessageInput(
currentChannel: String?,
nickname: String,
showMediaButtons: Boolean,
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
@ -385,7 +379,11 @@ fun MessageInput(
}),
// Cap the growth so a pasted wall of text cannot swallow the message list.
maxLines = 6,
visualTransformation = remember(palette) {
visualTransformation = remember(
palette,
colorScheme.primary,
mentionPeerIdentities,
) {
CombinedVisualTransformation(
listOf(
SlashCommandVisualTransformation(
@ -393,8 +391,8 @@ fun MessageInput(
commandBackground = colorScheme.primary.copy(alpha = 0.14f),
),
MentionVisualTransformation(
mentionColor = palette.accentOrange,
mentionBackground = palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA),
mentionPeerIdentities = mentionPeerIdentities,
palette = palette,
),
)
)
@ -709,10 +707,12 @@ fun CommandSuggestionItem(
@Composable
fun MentionSuggestionsBox(
suggestions: List<String>,
mentionPeerIdentities: Map<String, PeerIdentity>,
onSuggestionClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
LazyColumn(
modifier = modifier
@ -734,6 +734,11 @@ fun MentionSuggestionsBox(
) { suggestion ->
MentionSuggestionItem(
suggestion = suggestion,
userColor = colorForMention(
mention = suggestion,
mentionPeerIdentities = mentionPeerIdentities,
palette = palette,
),
onClick = { onSuggestionClick(suggestion) },
modifier = Modifier.animateItem(
fadeInSpec = tween(
@ -754,6 +759,7 @@ fun MentionSuggestionsBox(
@Composable
fun MentionSuggestionItem(
suggestion: String,
userColor: Color,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
@ -762,7 +768,7 @@ fun MentionSuggestionItem(
val isPressed by interactionSource.collectIsPressedAsState()
val pressedBackground by animateColorAsState(
targetValue = if (isPressed) {
palette.accentOrange.copy(alpha = 0.10f)
userColor.copy(alpha = 0.10f)
} else {
Color.Transparent
},
@ -798,7 +804,7 @@ fun MentionSuggestionItem(
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold
),
color = palette.accentOrange,
color = userColor,
fontSize = (BASE_FONT_SIZE - 2).sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,

View File

@ -118,6 +118,8 @@ fun LocationChannelsSheet(
val notesRevealed by NearbyNotesController.shared.revealed.collectAsStateWithLifecycle()
val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val appLocationEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
val systemLocationEnabled by locationManager.systemLocationEnabled.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle()
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
@ -331,10 +333,12 @@ fun LocationChannelsSheet(
onClick = {
val inRegional =
availableChannels.any { it.geohash == gh }
locationManager.setTeleported(
!inRegional && availableChannels.isNotEmpty()
locationManager.selectManual(
channel = channel,
teleported = !appLocationEnabled ||
availableChannels.isEmpty() ||
!inRegional
)
locationManager.select(ChannelID.Location(channel))
onDismiss()
}
)
@ -428,9 +432,9 @@ fun LocationChannelsSheet(
)
},
onClick = {
locationManager.setTeleported(false)
locationManager.select(ChannelID.Location(channel))
onDismiss()
if (locationManager.selectNearby(channel)) {
onDismiss()
}
}
)
}
@ -473,8 +477,7 @@ fun LocationChannelsSheet(
if (validateGeohash(normalized)) {
val level = levelForLength(normalized.length)
val channel = GeohashChannel(level = level, geohash = normalized)
locationManager.setTeleported(true)
locationManager.select(ChannelID.Location(channel))
locationManager.selectManual(channel)
onDismiss()
} else {
customError = context.getString(R.string.invalid_geohash)
@ -560,14 +563,14 @@ fun LocationChannelsSheet(
item(key = "location_toggle") {
SheetDestructiveButton(
text = if (locationServicesEnabled) {
text = if (appLocationEnabled) {
stringResource(R.string.disable_location_services)
} else {
stringResource(R.string.enable_location_services)
},
isDestructive = locationServicesEnabled,
isDestructive = appLocationEnabled,
onClick = {
if (locationServicesEnabled) {
if (appLocationEnabled) {
locationManager.disableLocationServices()
} else {
locationManager.enableLocationServices()
@ -595,27 +598,43 @@ fun LocationChannelsSheet(
}
}
LifecycleResumeEffect(isPresented, locationServicesEnabled) {
if (isPresented && locationServicesEnabled) {
locationManager.enableLocationChannels()
if (locationManager.permissionState.value ==
LocationChannelManager.PermissionState.AUTHORIZED
LifecycleResumeEffect(isPresented, appLocationEnabled, systemLocationEnabled) {
if (isPresented) {
val currentPermission = locationManager.syncPermissionState()
if (appLocationEnabled &&
systemLocationEnabled &&
currentPermission == LocationChannelManager.PermissionState.AUTHORIZED
) {
// Retain the redesign branch's immediate refresh when the sheet resumes while
// honoring main's independent app/system privacy gates.
locationManager.enableLocationChannels()
locationManager.beginLiveRefresh()
}
}
onPauseOrDispose { locationManager.endLiveRefresh() }
onPauseOrDispose {
locationManager.endLiveRefresh()
}
}
// Sampling management: update sampling when channels/bookmarks change
LaunchedEffect(isPresented, availableChannels, bookmarks, notesRevealed) {
LaunchedEffect(
isPresented,
availableChannels,
bookmarks,
appLocationEnabled,
notesRevealed
) {
if (isPresented) {
val geohashes = geohashesForSampling(
val liveLocationGeohashes = geohashesForSampling(
availableChannels = availableChannels,
bookmarks = bookmarks,
bookmarks = emptyList(),
notesRevealed = notesRevealed,
)
viewModel.beginGeohashSampling(geohashes)
viewModel.beginGeohashSampling(
liveLocationGeohashes = liveLocationGeohashes,
userSelectedGeohashes = bookmarks
)
} else {
viewModel.endGeohashSampling()
}

View File

@ -38,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.favorites.FavoriteRelationship
@ -47,7 +48,7 @@ import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.ui.theme.colorForPeerSeed
import com.bitchat.android.ui.theme.colorForPeer
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.nostr.GeohashConversationRegistry
import com.bitchat.android.services.ContactDirectory
@ -589,8 +590,8 @@ private fun PeerItem(
// Get consistent peer color (iOS-compatible)
val palette = LocalBitchatPalette.current
val assignedColor = colorForPeerSeed(
viewModel.peerColorSeedForMeshPeer(peerID),
val assignedColor = colorForPeer(
viewModel.peerIdentityForMeshPeer(peerID),
palette
)
val baseColor = if (isMe) palette.accentOrange else assignedColor
@ -614,7 +615,7 @@ private fun PeerItem(
)
} else if (hasUnreadDM) {
Icon(
imageVector = Icons.Filled.Email,
painter = painterResource(R.drawable.ic_spec_envelope),
contentDescription = stringResource(R.string.cd_unread_message),
modifier = Modifier.size(PeerRowIconSize),
tint = palette.accentOrange
@ -635,11 +636,13 @@ private fun PeerItem(
)
} else {
Icon(
imageVector = when {
isWifiAware -> Icons.Filled.Wifi
isDirect -> Icons.Outlined.Bluetooth
else -> Icons.Filled.Route
},
painter = painterResource(
conversationTransportIcon(
isReachedOverInternet = false,
isWifiAware = isWifiAware,
isDirect = isDirect
)
),
contentDescription = when {
isWifiAware -> "Direct Wi-Fi Aware"
isDirect -> "Direct Bluetooth"
@ -680,7 +683,7 @@ private fun PeerItem(
if (isVerified) {
Icon(
imageVector = Icons.Filled.Verified,
painter = painterResource(R.drawable.ic_spec_check),
contentDescription = stringResource(R.string.verify_title),
modifier = Modifier.size(16.dp),
tint = colorScheme.primary
@ -861,12 +864,7 @@ fun PrivateChatSheet(
viewModel.isPeerVerified(peerID, verifiedFingerprints)
}
val securityModifier = if (!isNostrPeer && !isNostrReachableFavorite) {
Modifier.clickable { viewModel.showSecurityVerificationSheet() }
} else {
Modifier
}
val palette = LocalBitchatPalette.current
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
@ -880,7 +878,7 @@ fun PrivateChatSheet(
Column(
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.height(64.dp))
Spacer(modifier = Modifier.height(ChatHeaderHeight))
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
@ -948,118 +946,97 @@ fun PrivateChatSheet(
)
}
// TopBar (fixed at top, iOS-style)
BitchatSheetCenterTopBar(
onClose = onDismiss,
// Header. Built from the same tokens as the main chat header rather than a
// TopAppBar, so moving between the timeline and a conversation does not shift the
// bar's height, insets or type.
Surface(
modifier = Modifier.align(Alignment.TopCenter),
navigationIcon = {
IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 16.dp)
.size(32.dp)
color = colorScheme.background
) {
ConversationHeader(
leadingIconRes = conversationTransportIcon(
isReachedOverInternet = isNostrPeer || isNostrReachableFavorite,
isWifiAware = isWifiAware,
isDirect = isDirect
),
leadingIconTint = colorScheme.primary,
leadingContentDescription = when {
isNostrPeer || isNostrReachableFavorite ->
stringResource(R.string.cd_nostr_reachable)
else -> null
},
title = titleText
) {
ConversationHeaderAction(
onClick = { viewModel.toggleFavorite(peerID) },
contentDescription = if (isFavorite) {
stringResource(R.string.cd_remove_favorite)
} else {
stringResource(R.string.cd_add_favorite)
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.chat_back),
tint = colorScheme.onSurface
painter = painterResource(
if (isFavorite) {
R.drawable.ic_spec_star_filled
} else {
R.drawable.ic_spec_star
}
),
contentDescription = null,
modifier = Modifier.size(HeaderIconSize),
tint = if (isFavorite) {
palette.accentOrange
} else {
colorScheme.onSurfaceVariant
}
)
}
},
title = {
// Center content: connection status + name + encryption
Row(
modifier = Modifier.align(Alignment.Center),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
when {
isNostrPeer || isNostrReachableFavorite -> {
Icon(
painter = painterResource(R.drawable.ic_spec_globe),
contentDescription = stringResource(R.string.cd_nostr_reachable),
modifier = Modifier.size(14.dp),
tint = Color(0xFF9C27B0)
)
}
isWifiAware -> {
Icon(
imageVector = Icons.Filled.Wifi,
contentDescription = "Direct Wi-Fi Aware",
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
isDirect -> {
Icon(
imageVector = Icons.Outlined.Bluetooth,
contentDescription = "Direct Bluetooth",
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
isConnected -> {
Icon(
imageVector = Icons.Filled.Route,
contentDescription = "Routed",
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
Text(
text = titleText,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
fontFamily = BitchatFontFamily
),
color = if (isNostrPeer || isNostrReachableFavorite) Color(0xFFFF9500) else colorScheme.onSurface
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.then(securityModifier)
// Encryption state, and the verification badge that qualifies it. Both are
// read-only for Nostr peers, which have no Noise session at all.
if (!isNostrPeer && !isNostrReachableFavorite) {
ConversationHeaderAction(
onClick = { viewModel.showSecurityVerificationSheet() },
contentDescription = stringResource(R.string.verify_title)
) {
if (!isNostrPeer && !isNostrReachableFavorite) {
Box(contentAlignment = Alignment.Center) {
NoiseSessionIcon(
sessionState = sessionState,
modifier = Modifier.size(14.dp)
)
}
if (isVerified) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = stringResource(R.string.verify_title),
modifier = Modifier.size(14.dp),
tint = Color(0xFF32D74B) // iOS Green
modifier = Modifier.size(HeaderIconSize)
)
}
}
}
IconButton(
onClick = { viewModel.toggleFavorite(peerID) },
modifier = Modifier.size(28.dp)
if (isVerified) {
Box(
modifier = Modifier.size(HeaderIconSize),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(
if (isFavorite) {
R.drawable.ic_spec_star_filled
} else {
R.drawable.ic_spec_star
}
),
contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite),
modifier = Modifier.size(16.dp),
tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f)
painter = painterResource(R.drawable.ic_spec_check),
contentDescription = stringResource(R.string.verify_title),
modifier = Modifier.size(HeaderIconSize),
tint = colorScheme.primary
)
}
}
val dismiss = LocalSheetDismiss.current
ConversationHeaderAction(
onClick = { dismiss?.invoke() ?: onDismiss() },
contentDescription = stringResource(R.string.close_plain)
) {
Icon(
painter = painterResource(R.drawable.ic_spec_close),
contentDescription = null,
modifier = Modifier.size(HeaderIconSize),
tint = colorScheme.onSurfaceVariant
)
}
}
)
}
}
}
}

View File

@ -182,6 +182,7 @@ fun MessagesList(
currentUserNickname: String,
meshService: MeshService,
modifier: Modifier = Modifier,
mentionPeerIdentities: Map<String, PeerIdentity>? = null,
/**
* Extra inset on top of the list's own gutters.
*
@ -204,6 +205,10 @@ fun MessagesList(
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
onImageClick: ((String, List<String>, Int) -> Unit)? = null
) {
val resolvedMentionPeerIdentities = remember(messages, mentionPeerIdentities) {
mentionPeerIdentities ?: buildMentionPeerIdentityMap(messages)
}
// A fresh scroll position per conversation. Sharing one state meant a switch inherited the
// previous channel's offset and then had to correct itself, which is what the jump was.
//
@ -331,6 +336,7 @@ fun MessagesList(
messages = messages,
currentUserNickname = currentUserNickname,
meshService = meshService,
mentionPeerIdentities = resolvedMentionPeerIdentities,
showSender = !isGrouped,
topSpacing = MessageGrouping.topSpacingFor(
isGrouped = isGrouped,
@ -363,6 +369,7 @@ fun MessageItem(
currentUserNickname: String,
meshService: MeshService,
messages: List<BitchatMessage> = emptyList(),
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
showSender: Boolean = true,
topSpacing: Dp = 0.dp,
onNicknameClick: ((String) -> Unit)? = null,
@ -394,6 +401,7 @@ fun MessageItem(
messages = messages,
currentUserNickname = currentUserNickname,
meshService = meshService,
mentionPeerIdentities = mentionPeerIdentities,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
@ -432,6 +440,7 @@ fun MessageItem(
messages: List<BitchatMessage>,
currentUserNickname: String,
meshService: MeshService,
mentionPeerIdentities: Map<String, PeerIdentity>,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
showSender: Boolean,
@ -623,6 +632,7 @@ fun MessageItem(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
mentionPeerIdentities = mentionPeerIdentities,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
@ -638,6 +648,7 @@ internal fun TextMessageLayout(
message: BitchatMessage,
currentUserNickname: String,
meshService: MeshService,
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
@ -667,6 +678,7 @@ internal fun TextMessageLayout(
palette,
colorScheme.onSurface,
colorScheme.secondary,
mentionPeerIdentities,
timeFormatter
) {
formatTextMessageBody(
@ -675,6 +687,7 @@ internal fun TextMessageLayout(
palette = palette,
contentColor = colorScheme.onSurface,
linkColor = colorScheme.secondary,
mentionPeerIdentities = mentionPeerIdentities,
timeFormatter = timeFormatter,
)
}

View File

@ -3,7 +3,6 @@ package com.bitchat.android.ui
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.GeohashChannel
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
@ -48,6 +47,5 @@ internal fun channelForGeohash(geohash: String): GeohashChannel {
internal fun navigateToGeohash(context: Context, geohash: String): Boolean =
runCatching {
val locationManager = LocationChannelManager.getInstance(context)
locationManager.setTeleported(true)
locationManager.select(ChannelID.Location(channelForGeohash(geohash)))
locationManager.selectManual(channelForGeohash(geohash))
}.isSuccess

View File

@ -138,7 +138,7 @@ class NotificationManager(
*/
fun setCurrentGeohash(geohash: String?) {
currentGeohash = geohash
Log.d(TAG, "Current geohash changed: $geohash")
Log.d(TAG, "Current geohash changed")
}
/**
@ -446,11 +446,11 @@ class NotificationManager(
val shouldNotify = isAppInBackground || (!isAppInBackground && currentGeohash != geohash)
if (!shouldNotify) {
Log.d(TAG, "Skipping geohash notification - app in foreground and viewing geohash $geohash")
Log.d(TAG, "Skipping geohash notification while viewing the channel")
return
}
Log.d(TAG, "Showing geohash notification for $geohash from $senderNickname (mention: $isMention, first: $isFirstMessage)")
Log.d(TAG, "Showing geohash notification (mention: $isMention, first: $isFirstMessage)")
val notification = GeohashNotification(
geohash = geohash,
@ -652,7 +652,7 @@ class NotificationManager(
showGeohashSummaryNotification()
}
Log.d(TAG, "Cleared notifications for geohash: $geohash")
Log.d(TAG, "Cleared notifications for geohash")
}
/**

View File

@ -0,0 +1,71 @@
package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import java.util.Locale
/**
* Canonical, presentation-neutral identity used to derive a peer hue.
*
* Callers cannot construct arbitrary color seeds. Every identity is normalized and namespaced
* here so the same mesh or Nostr user resolves to the same color on every surface.
*/
@JvmInline
value class PeerIdentity private constructor(internal val stableKey: String) {
companion object {
fun mesh(peerID: String): PeerIdentity =
PeerIdentity("noise:${normalize(peerID)}")
/**
* Preserve the established geohash-chat color mapping while accepting a full public key.
*
* Older chat rendering hashed `nostr:nostr:<first 8 hex characters>`. Keeping that visual
* key here avoids recoloring existing conversations; the important change is that every
* surface now derives it from the same canonical Nostr identity.
*/
fun nostr(pubkeyHex: String): PeerIdentity {
val normalized = normalizeNostrIdentifier(pubkeyHex)
return PeerIdentity("nostr:nostr:${normalized.take(8)}")
}
/**
* Last-resort identity for legacy messages and mentions that carry no stable peer ID.
*/
fun nickname(nickname: String): PeerIdentity =
PeerIdentity(normalize(nickname))
private fun normalize(value: String): String =
value.trim().lowercase(Locale.ROOT)
private fun normalizeNostrIdentifier(value: String): String {
var normalized = normalize(value)
while (normalized.startsWith("nostr:") || normalized.startsWith("nostr_")) {
normalized = normalized.removePrefix("nostr:").removePrefix("nostr_")
}
return normalized
}
}
}
/**
* Resolve the canonical identity attached to a rendered message.
*
* Full Nostr keys take precedence over routing aliases such as `nostr_abcd…`; aliases remain a
* compatibility fallback for messages saved by older app versions.
*/
fun peerIdentityForMessage(message: BitchatMessage): PeerIdentity {
message.senderNostrPubkey?.takeIf { it.isNotBlank() }?.let {
return PeerIdentity.nostr(it)
}
val senderPeerID = message.senderPeerID
return when {
senderPeerID?.startsWith("nostr:", ignoreCase = true) == true ||
senderPeerID?.startsWith("nostr_", ignoreCase = true) == true -> {
PeerIdentity.nostr(senderPeerID)
}
senderPeerID?.length == 16 || senderPeerID?.length == 64 -> {
PeerIdentity.mesh(senderPeerID)
}
else -> PeerIdentity.nickname(message.sender)
}
}

View File

@ -1,18 +1,18 @@
package com.bitchat.android.ui.theme
import androidx.compose.ui.graphics.Color
import com.bitchat.android.ui.PeerColorSeed
import com.bitchat.android.ui.PeerIdentity
import kotlin.math.abs
/**
* Resolve a peer's stable seed using the active Bitchat theme's explicit chroma tokens.
* The single identity-to-color boundary used by chat, people sheets, and mentions.
*
* The djb2 hash and hue adjustment are byte-identical to the iOS implementation. Orange is
* avoided because it is reserved for the current user.
*/
fun colorForPeerSeed(seed: PeerColorSeed, palette: BitchatPalette): Color {
fun colorForPeer(identity: PeerIdentity, palette: BitchatPalette): Color {
var hash = 5381UL
for (byte in seed.value.toByteArray()) {
for (byte in identity.stableKey.toByteArray()) {
hash = ((hash shl 5) + hash) + byte.toUByte().toULong()
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M10,2.75V17.25"/>
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M10,2.75L14.5,7.25L5.5,12.75"/>
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M10,17.25L14.5,12.75L5.5,7.25"/>
</vector>

View File

@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M4.25,10.5L8.25,14.5L15.75,6"/>
</vector>

View File

@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M5.5,5.5L14.5,14.5"/>
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M14.5,5.5L5.5,14.5"/>
</vector>

View File

@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M15.75,15.5H4.25C3.2145,15.5 2.375,14.6605 2.375,13.625V6.375C2.375,5.3395 3.2145,4.5 4.25,4.5H15.75C16.7855,4.5 17.625,5.3395 17.625,6.375V13.625C17.625,14.6605 16.7855,15.5 15.75,15.5Z"/>
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M3.1,5.75L10,10.85L16.9,5.75"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<!-- Body and shackle geometry copied from ic_spec_lock so the two states sit on exactly the
same baseline; only the shackle is swung open, hinged on its right leg. -->
<group android:translateX="-16" android:translateY="-310">
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M32.25,329.375H19.75C18.715,329.375 17.875,328.535 17.875,327.5V321.25C17.875,320.215 18.715,319.375 19.75,319.375H32.25C33.285,319.375 34.125,320.215 34.125,321.25V327.5C34.125,328.535 33.285,329.375 32.25,329.375Z"/>
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M34.125,316.875V315C34.125,312.584 32.1662,310.625 29.75,310.625C27.3338,310.625 25.375,312.584 25.375,315V316.5"/>
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M26,326.25C27.0355,326.25 27.875,325.411 27.875,324.375C27.875,323.339 27.0355,322.5 26,322.5C24.9645,322.5 24.125,323.339 24.125,324.375C24.125,325.411 24.9645,326.25 26,326.25Z"/>
</group>
</vector>

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M6,5.25A1.75,1.75 0 1,1 2.5,5.25A1.75,1.75 0 1,1 6,5.25Z"/>
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M17.5,5.25A1.75,1.75 0 1,1 14,5.25A1.75,1.75 0 1,1 17.5,5.25Z"/>
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M11.75,15.75A1.75,1.75 0 1,1 8.25,15.75A1.75,1.75 0 1,1 11.75,15.75Z"/>
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M5.1,6.9L9.3,14.1"/>
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M14.9,6.9L10.7,14.1"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M3.75,10A6.25,6.25 0 0,1 14.9,6.15"/>
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M11.4,6.6L15.1,6.4L14.9,2.7"/>
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M16.25,10A6.25,6.25 0 0,1 5.1,13.85"/>
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M8.6,13.4L4.9,13.6L5.1,17.3"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M10,3.25L17.75,16.75H2.25L10,3.25Z"/>
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M10,8V11.5"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M10.9,14.25A0.9,0.9 0 1,1 9.1,14.25A0.9,0.9 0 1,1 10.9,14.25Z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M3.5,8.5C6.1,5.7 13.9,5.7 16.5,8.5"/>
<path android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.25" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M6.4,11.6C8.1,9.8 11.9,9.8 13.6,11.6"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M11,15.25A1,1 0 1,1 9,15.25A1,1 0 1,1 11,15.25Z"/>
</vector>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">إضافة إشارة مرجعية</string>
<!-- ترويسة الدردشة وسهولة الوصول -->
<string name="chat_back">رجوع</string>
<string name="chat_leave">مغادرة</string>
<string name="cd_nostr_reachable">يمكن الوصول عبر Nostr</string>
<string name="cd_unread_private_messages">رسائل خاصة غير مقروءة</string>
<string name="cd_teleported">تم الانتقال</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">বুকমার্ক যোগ করুন</string>
<!-- চ্যাট হেডার ও অ্যাক্সেসিবিলিটি -->
<string name="chat_back">ফিরে যান</string>
<string name="chat_leave">ছেড়ে দিন</string>
<string name="cd_nostr_reachable">Nostr এর মাধ্যমে পৌঁছানো যায়</string>
<string name="cd_unread_private_messages">অপঠিত ব্যক্তিগত বার্তা</string>
<string name="cd_teleported">টেলিপোর্ট করা হয়েছে</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">Lesezeichen hinzufügen</string>
<!-- Chat‑Kopf &amp; Barrierefreiheit -->
<string name="chat_back">zurück</string>
<string name="chat_leave">verlassen</string>
<string name="cd_nostr_reachable">Über Nostr erreichbar</string>
<string name="cd_unread_private_messages">Ungelesene private Nachrichten</string>
<string name="cd_teleported">Teleportiert</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">Agregar marcador</string>
<!-- Encabezado de chat y accesibilidad -->
<string name="chat_back">atrás</string>
<string name="chat_leave">salir</string>
<string name="cd_nostr_reachable">Accesible vía Nostr</string>
<string name="cd_unread_private_messages">Mensajes privados sin leer</string>
<string name="cd_teleported">Teletransportado</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">افزودن نشانک</string>
<!-- سربرگ چت و دسترسی‌پذیری -->
<string name="chat_back">بازگشت</string>
<string name="chat_leave">خروج</string>
<string name="cd_nostr_reachable">قابل دسترس از طریق Nostr</string>
<string name="cd_unread_private_messages">پیام‌های خصوصی خوانده‌نشده</string>
<string name="cd_teleported">انتقال مکانی</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">Mag‑bookmark</string>
<!-- Header at accessibility -->
<string name="chat_back">bumalik</string>
<string name="chat_leave">umalis</string>
<string name="cd_nostr_reachable">Maabot sa Nostr</string>
<string name="cd_unread_private_messages">Hindi nabasang pribadong mensahe</string>
<string name="cd_teleported">Naiteleport</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">Ajouter un signet</string>
<!-- En‑tête de chat &amp; accessibilité -->
<string name="chat_back">retour</string>
<string name="chat_leave">quitter</string>
<string name="cd_nostr_reachable">Joignable via Nostr</string>
<string name="cd_unread_private_messages">Messages privés non lus</string>
<string name="cd_teleported">Téléporté</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">बुकमार्क जोड़ें</string>
<!-- चैट हेडर &amp; एक्सेसिबिलिटी -->
<string name="chat_back">वापस</string>
<string name="chat_leave">छोड़ें</string>
<string name="cd_nostr_reachable">Nostr के माध्यम से उपलब्ध</string>
<string name="cd_unread_private_messages">अपठित निजी संदेश</string>
<string name="cd_teleported">टेलीपोर्ट हुआ</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">Tambah bookmark</string>
<!-- Header chat &amp; aksesibilitas -->
<string name="chat_back">kembali</string>
<string name="chat_leave">keluar</string>
<string name="cd_nostr_reachable">Dapat dijangkau melalui Nostr</string>
<string name="cd_unread_private_messages">Pesan pribadi yang belum dibaca</string>
<string name="cd_teleported">Diteleportasi</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">Aggiungi segnalibro</string>
<!-- Intestazione chat e accessibilità -->
<string name="chat_back">indietro</string>
<string name="chat_leave">esci</string>
<string name="cd_nostr_reachable">Raggiungibile via Nostr</string>
<string name="cd_unread_private_messages">Messaggi privati non letti</string>
<string name="cd_teleported">Teletrasportato</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">ブックマークに追加</string>
<!-- チャットヘッダー &amp; アクセシビリティ -->
<string name="chat_back">戻る</string>
<string name="chat_leave">退出</string>
<string name="cd_nostr_reachable">Nostr 経由で到達可能</string>
<string name="cd_unread_private_messages">未読のプライベートメッセージ</string>
<string name="cd_teleported">テレポート済み</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">სანიშნეს დამატება</string>
<!-- ჩათის სათაური &amp; ხელმისაწვდომობა -->
<string name="chat_back">უკან</string>
<string name="chat_leave">გასვლა</string>
<string name="cd_nostr_reachable">მისაწვდომია Nostr-ით</string>
<string name="cd_unread_private_messages">წაუკითხავი პირადი შეტყობინებები</string>
<string name="cd_teleported">ტელეპორტირებული</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">북마크 추가</string>
<!-- 채팅 헤더 &amp; 접근성 -->
<string name="chat_back">뒤로</string>
<string name="chat_leave">나가기</string>
<string name="cd_nostr_reachable">Nostr로 연결 가능</string>
<string name="cd_unread_private_messages">읽지 않은 개인 메시지</string>
<string name="cd_teleported">텔레포트됨</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">Hanampy bookmark</string>
<!-- Lohan\'ny resaka &amp; fahafaha-miditra -->
<string name="chat_back">miverina</string>
<string name="chat_leave">hiala</string>
<string name="cd_nostr_reachable">Azo tratrarina amin\'ny alalan\'ny Nostr</string>
<string name="cd_unread_private_messages">Hafatra manokana tsy voavaky</string>
<string name="cd_teleported">Nafindra toerana</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">बुकमार्क थप्नुहोस्</string>
<!-- च्याट शीर्षक / पहुँचयोग्यता -->
<string name="chat_back">पछाडि</string>
<string name="chat_leave">छोड्नुहोस्</string>
<string name="cd_nostr_reachable">Nostr मार्फत पुग्न सकिने</string>
<string name="cd_unread_private_messages">नपढिएका निजी सन्देश</string>
<string name="cd_teleported">टेलिपोर्ट भयो</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">Bladwijzer toevoegen</string>
<!-- Chatkop &amp; toegankelijkheid -->
<string name="chat_back">terug</string>
<string name="chat_leave">verlaten</string>
<string name="cd_nostr_reachable">Bereikbaar via Nostr</string>
<string name="cd_unread_private_messages">Ongelezen privéberichten</string>
<string name="cd_teleported">Geteleporteerd</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">بُک مارک پاؤ</string>
<!-- چیٹ ہیڈر &amp; رسائی پذیری -->
<string name="chat_back">واپس</string>
<string name="chat_leave">باہر آؤ</string>
<string name="cd_nostr_reachable">Nostr راہین پہنچ ممکن اے</string>
<string name="cd_unread_private_messages">انہ پڑھیا ذاتی پیغام</string>
<string name="cd_teleported">ٹیلپورٹ کیتا گیا</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">Adicionar favorito</string>
<!-- Cabeçalho do chat &amp; acessibilidade -->
<string name="chat_back">voltar</string>
<string name="chat_leave">sair</string>
<string name="cd_nostr_reachable">Acessível via Nostr</string>
<string name="cd_unread_private_messages">Mensagens privadas não lidas</string>
<string name="cd_teleported">Teletransportado</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">Adicionar marcador</string>
<!-- Cabeçalho do chat e acessibilidade -->
<string name="chat_back">voltar</string>
<string name="chat_leave">sair</string>
<string name="cd_nostr_reachable">Alcançável via Nostr</string>
<string name="cd_unread_private_messages">Mensagens privadas não lidas</string>
<string name="cd_teleported">Teletransportado</string>

View File

@ -63,8 +63,6 @@
<string name="cd_remove_favorite">Убрать из избранного</string>
<string name="cd_add_bookmark">Добавить закладку</string>
<string name="chat_back">назад</string>
<string name="chat_leave">выйти</string>
<string name="cd_nostr_reachable">Доступен через Nostr</string>
<string name="cd_unread_private_messages">Непрочитанные личные</string>
<string name="cd_teleported">Телепортировано</string>

View File

@ -63,8 +63,6 @@
<string name="cd_remove_favorite">Ta bort favorit</string>
<string name="cd_add_bookmark">Lägg till bokmärke</string>
<string name="chat_back">tillbaka</string>
<string name="chat_leave">lämna</string>
<string name="cd_nostr_reachable">Nås via Nostr</string>
<string name="cd_unread_private_messages">Olästa privata meddelanden</string>
<string name="cd_teleported">Teleporterad</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">เพิ่มที่คั่นหน้า</string>
<!-- ส่วนหัวแชท &amp; การช่วยสำหรับการเข้าถึง -->
<string name="chat_back">กลับ</string>
<string name="chat_leave">ออก</string>
<string name="cd_nostr_reachable">ติดต่อได้ผ่าน Nostr</string>
<string name="cd_unread_private_messages">ข้อความส่วนตัวยังไม่ได้อ่าน</string>
<string name="cd_teleported">เทเลพอร์ตแล้ว</string>

View File

@ -63,8 +63,6 @@
<string name="cd_remove_favorite">Sık kullanılandan kaldır</string>
<string name="cd_add_bookmark">Yer imi ekle</string>
<string name="chat_back">geri</string>
<string name="chat_leave">ayrıl</string>
<string name="cd_nostr_reachable">Nostr üzerinden ulaşılabilir</string>
<string name="cd_unread_private_messages">Okunmamış özel mesaj</string>
<string name="cd_teleported">Teleport edildi</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">بک مارک شامل کریں</string>
<!-- چیٹ ہیڈر اور رسائی -->
<string name="chat_back">واپس</string>
<string name="chat_leave">چھوڑیں</string>
<string name="cd_nostr_reachable">Nostr کے ذریعے رسائی</string>
<string name="cd_unread_private_messages">غیر پڑھے ہوئے نجی پیغامات</string>
<string name="cd_teleported">ٹیلی پورٹ</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">Thêm bookmark</string>
<!-- Header trò chuyện &amp; khả năng truy cập -->
<string name="chat_back">quay lại</string>
<string name="chat_leave">rời khỏi</string>
<string name="cd_nostr_reachable">Có thể tiếp cận qua Nostr</string>
<string name="cd_unread_private_messages">Tin nhắn riêng chưa đọc</string>
<string name="cd_teleported">Đã dịch chuyển</string>

View File

@ -71,8 +71,6 @@
<string name="cd_add_bookmark">添加书签</string>
<!-- 聊天头部 / 可访问性 -->
<string name="chat_back">返回</string>
<string name="chat_leave">离开</string>
<string name="cd_nostr_reachable">可通过 Nostr 联系</string>
<string name="cd_unread_private_messages">未读私信</string>
<string name="cd_teleported">已传送</string>

View File

@ -72,8 +72,6 @@
<string name="cd_add_bookmark">Add bookmark</string>
<!-- Chat header &amp; accessibility -->
<string name="chat_back">Back</string>
<string name="chat_leave">Leave</string>
<string name="cd_nostr_reachable">Nostr reachable</string>
<string name="cd_unread_private_messages">Unread private messages</string>
<string name="cd_location_notes">Location notes</string>

View File

@ -0,0 +1,46 @@
package com.bitchat.android.geohash
import org.junit.Assert.assertEquals
import org.junit.Test
class GeohashNostrPrivacyPolicyTest {
@Test
fun `disabled live location retains only user-selected sampling targets`() {
val targets = GeohashNostrPrivacyPolicy.samplingTargets(
liveLocationGeohashes = listOf("live-city", "shared"),
userSelectedGeohashes = listOf("bookmark", "shared"),
liveLocationEnabled = false
)
assertEquals(setOf("bookmark", "shared"), targets)
}
@Test
fun `enabled live location combines and deduplicates sampling targets`() {
val targets = GeohashNostrPrivacyPolicy.samplingTargets(
liveLocationGeohashes = listOf("live-city", "shared"),
userSelectedGeohashes = listOf("bookmark", "shared"),
liveLocationEnabled = true
)
assertEquals(setOf("live-city", "bookmark", "shared"), targets)
}
@Test
fun `presence excludes live channels when disabled`() {
val channels = listOf(
GeohashChannel(GeohashChannelLevel.REGION, "region"),
GeohashChannel(GeohashChannelLevel.CITY, "city"),
GeohashChannel(GeohashChannelLevel.NEIGHBORHOOD, "neighborhood")
)
assertEquals(
emptySet<String>(),
GeohashNostrPrivacyPolicy.livePresenceTargets(channels, false)
)
assertEquals(
setOf("region", "city"),
GeohashNostrPrivacyPolicy.livePresenceTargets(channels, true)
)
}
}

View File

@ -0,0 +1,48 @@
package com.bitchat.android.geohash
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class LiveLocationAccessPolicyTest {
@Test
fun `live location is disabled by default`() {
val policy = LiveLocationAccessPolicy()
assertFalse(policy.isEnabled)
assertNull(policy.captureToken())
}
@Test
fun `disable and re-enable never revives old work`() {
val policy = LiveLocationAccessPolicy(initialEnabled = true)
val oldToken = requireNotNull(policy.captureToken())
policy.update(false)
assertFalse(policy.accepts(oldToken))
policy.update(true)
val newToken = requireNotNull(policy.captureToken())
assertFalse(policy.accepts(oldToken))
assertTrue(policy.accepts(newToken))
}
@Test
fun `invalidation prevents a queued action from running`() {
val policy = LiveLocationAccessPolicy(initialEnabled = true)
val token = requireNotNull(policy.captureToken())
var ran = false
policy.invalidate()
val accepted = policy.runIfAllowed(token) { ran = true }
assertFalse(accepted)
assertFalse(ran)
assertNull(policy.captureToken())
policy.resumeAccess()
assertTrue(policy.captureToken() != null)
}
}

View File

@ -0,0 +1,37 @@
package com.bitchat.android.nostr
import org.junit.Assert.assertEquals
import org.junit.Test
class NostrLiveSubscriptionPrivacyTest {
@Test
fun `teardown closes live subscriptions on shared relays`() {
val targets = NostrLiveSubscriptionPrivacy.closeTargets(
liveSubscriptionIds = setOf("live-a", "live-b"),
subscriptionsByRelay = mapOf(
"shared-relay" to setOf("dm", "live-a"),
"live-relay" to setOf("live-a", "live-b"),
"dm-relay" to setOf("dm"),
),
)
assertEquals(
mapOf(
"shared-relay" to setOf("live-a"),
"live-relay" to setOf("live-a", "live-b"),
),
targets,
)
}
@Test
fun `teardown ignores relays without live subscriptions`() {
assertEquals(
emptyMap<String, Set<String>>(),
NostrLiveSubscriptionPrivacy.closeTargets(
liveSubscriptionIds = emptySet(),
subscriptionsByRelay = mapOf("default-relay" to setOf("dm")),
),
)
}
}

View File

@ -1,6 +1,7 @@
package com.bitchat.android.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
@ -13,11 +14,12 @@ import com.bitchat.android.ui.theme.LightBitchatColorScheme
import com.bitchat.android.ui.theme.LightBitchatPalette
import com.bitchat.android.ui.theme.MessageBodyTextStyle
import com.bitchat.android.ui.theme.MessageSenderTextStyle
import com.bitchat.android.ui.theme.colorForPeerSeed
import com.bitchat.android.ui.theme.colorForPeer
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
@ -221,19 +223,90 @@ class ChatUIUtilsTest {
@Test
fun `mention of another user is tinted by that user's own peer color`() {
val pubkey = "0123456789abcdef".repeat(4)
val identity = PeerIdentity.nostr(pubkey)
val mentionPeerIdentities = buildMentionPeerIdentityMap(
messages = listOf(
BitchatMessage(
sender = "carol#04af",
content = "hello",
timestamp = Date(0),
senderPeerID = "nostr:${pubkey.take(8)}",
senderNostrPubkey = pubkey,
)
)
)
val body = formatTextMessageBody(
message = message("cc @carol#04af"),
currentUserNickname = "bob",
palette = palette,
contentColor = colorScheme.onSurface,
linkColor = colorScheme.secondary,
mentionPeerIdentities = mentionPeerIdentities,
timeFormatter = timeFormatter,
includeTimestamp = false,
)
val expected = colorForPeerSeed(PeerColorSeed("carol#04af"), palette)
val expected = colorForPeer(identity, palette)
val chip = mentionChipSpans(body).single()
assertEquals(expected.copy(alpha = MENTION_CHIP_ALPHA), chip.item.background)
assertTrue(body.spanStyles.any { it.item.color == expected })
}
@Test
fun `composer colors nickname and hash suffix from the mentioned peer identity`() {
val pubkey = "0123456789abcdef".repeat(4)
val identity = PeerIdentity.nostr(pubkey)
val token = "@carol#04af"
val input = "ping $token now"
val transformed = MentionVisualTransformation(
mentionPeerIdentities = mapOf("carol#04af" to identity),
palette = palette,
).filter(AnnotatedString(input)).text
val expectedColor = colorForPeer(identity, palette)
val tokenStart = input.indexOf(token)
val suffixStart = input.indexOf("#04af")
val tokenEnd = tokenStart + token.length
assertEquals(input, transformed.text)
assertTrue(transformed.spanStyles.any {
it.start == tokenStart &&
it.end == tokenEnd &&
it.item.background == expectedColor.copy(alpha = MENTION_CHIP_ALPHA)
})
assertTrue(transformed.spanStyles.any {
it.start == tokenStart &&
it.end == suffixStart &&
it.item.color == expectedColor
})
assertTrue(transformed.spanStyles.any {
it.start == suffixStart &&
it.end == tokenEnd &&
it.item.color == expectedColor.copy(alpha = SUFFIX_ALPHA)
})
}
@Test
fun `ambiguous base nickname is not assigned to the wrong peer`() {
val firstIdentity = PeerIdentity.nostr("11111111".repeat(8))
val secondIdentity = PeerIdentity.nostr("22222222".repeat(8))
val identities = buildMentionPeerIdentityMap(
messages = emptyList(),
knownPeers = listOf(
"alice#1111" to firstIdentity,
"alice#2222" to secondIdentity,
)
)
assertEquals(firstIdentity, identities["alice#1111"])
assertEquals(secondIdentity, identities["alice#2222"])
assertFalse(identities.containsKey("alice"))
assertEquals(
firstIdentity,
resolveMentionPeerIdentity("@alice#1111", identities)
)
assertEquals(null, resolveMentionPeerIdentity("@alice", identities))
}
@Test
@ -349,24 +422,33 @@ class ChatUIUtilsTest {
// MARK: - Peer colors
@Test
fun `peer seed factories normalize identities without resolving UI colors`() {
fun `peer identity factories normalize stable IDs without resolving UI colors`() {
assertEquals(
PeerColorSeed("noise:abcdef"),
meshPeerColorSeed("ABCDEF")
PeerIdentity.mesh("abcdef"),
PeerIdentity.mesh("ABCDEF")
)
assertEquals(
PeerColorSeed("nostr:abcdef"),
nostrPeerColorSeed("ABCDEF")
PeerIdentity.nostr("abcdef"),
PeerIdentity.nostr("ABCDEF")
)
assertEquals(
PeerIdentity.nostr("abcdef"),
PeerIdentity.nostr("nostr:nostr_ABCDEF")
)
assertEquals(
"nostr:nostr:abcdef01",
PeerIdentity.nostr("ABCDEF0123456789").stableKey
)
assertEquals("alice#1234", PeerIdentity.nickname("ALICE#1234").stableKey)
}
@Test
fun `peer color hue is stable across light and dark, only chroma differs`() {
// Hue derivation must stay byte-identical to iOS; only saturation/value are tuned for
// the redesigned neutral message body.
val seed = PeerColorSeed("noise:abc")
val dark = colorForPeerSeed(seed, DarkBitchatPalette)
val light = colorForPeerSeed(seed, LightBitchatPalette)
val identity = PeerIdentity.mesh("abc")
val dark = colorForPeer(identity, DarkBitchatPalette)
val light = colorForPeer(identity, LightBitchatPalette)
val darkHsv = FloatArray(3)
val lightHsv = FloatArray(3)
@ -384,8 +466,8 @@ class ChatUIUtilsTest {
fun `peer color avoids the orange hue reserved for self`() {
// Sweep a range of seeds; none may land within the reserved orange band.
repeat(500) { i ->
val color = colorForPeerSeed(
PeerColorSeed("noise:seed$i"),
val color = colorForPeer(
PeerIdentity.mesh("seed$i"),
DarkBitchatPalette
)
val hsv = FloatArray(3)
@ -407,6 +489,59 @@ class ChatUIUtilsTest {
)
}
@Test
fun `geohash chat and people sheet resolve the same full Nostr identity`() {
val pubkey = "ABCDEF0123456789".repeat(4)
val peopleIdentity = PeerIdentity.nostr(pubkey)
val chatIdentity = peerIdentityForMessage(
BitchatMessage(
sender = "alice#1234",
content = "hello",
timestamp = Date(0),
senderPeerID = "nostr:${pubkey.take(8)}",
senderNostrPubkey = pubkey,
)
)
assertEquals(peopleIdentity, chatIdentity)
assertEquals(
colorForPeer(peopleIdentity, palette),
colorForPeer(chatIdentity, palette)
)
}
@Test
fun `mesh chat and people sheet resolve the same peer identity`() {
val peerID = "ABCDEF0123456789"
val peopleIdentity = PeerIdentity.mesh(peerID)
val chatIdentity = peerIdentityForMessage(
BitchatMessage(
sender = "alice#1234",
content = "hello",
timestamp = Date(0),
senderPeerID = peerID,
)
)
assertEquals(peopleIdentity, chatIdentity)
}
@Test
fun `full Nostr identity wins over a truncated routing alias`() {
val pubkey = "0123456789ABCDEF".repeat(4)
val identity = peerIdentityForMessage(
BitchatMessage(
sender = "alice",
content = "hello",
timestamp = Date(0),
senderPeerID = "nostr_${pubkey.take(16)}",
senderNostrPubkey = pubkey,
)
)
assertEquals(PeerIdentity.nostr(pubkey), identity)
}
private fun rgbToHsv(r: Float, g: Float, b: Float, out: FloatArray) {
val max = maxOf(r, g, b)
val min = minOf(r, g, b)

View File

@ -80,4 +80,51 @@ class GeohashPresenceGroupingTest {
assertTrue(sections.onLocation.isEmpty())
assertEquals(listOf("ABCDEF"), sections.teleportedIn.map { it.id })
}
@Test
fun `duplicate nicknames are detected across case and sections`() {
val duplicates = duplicateGeohashBaseNames(
listOf(
person("first", "Alice"),
person("second", "alice"),
person("third", "bob")
)
)
assertEquals(setOf("alice"), duplicates)
}
@Test
fun `duplicate nickname gets the same last-four ID suffix as chat`() {
assertEquals(
"#cdef",
geohashIdentitySuffix(person("0123456789abcdef", "alice"), showHashSuffix = true)
)
assertEquals(
"",
geohashIdentitySuffix(person("0123456789abcdef", "alice"), showHashSuffix = false)
)
}
@Test
fun `existing chat-style suffix is preserved`() {
assertEquals(
"#04af",
geohashIdentitySuffix(person("0123456789abcdef", "alice#04af"), showHashSuffix = true)
)
}
@Test
fun `disambiguated display name matches the mention token used by chat`() {
val alice = person("0123456789abcdef", "alice")
assertEquals(
"alice#cdef",
disambiguatedGeohashDisplayName(alice, duplicateBaseNames = setOf("alice"))
)
assertEquals(
"alice",
disambiguatedGeohashDisplayName(alice, duplicateBaseNames = emptySet())
)
}
}

View File

@ -0,0 +1,301 @@
package com.bitchat.android.contracts
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.FragmentPayload
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.model.PrivateMessagePacket
import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.model.UnknownAnnouncementTLV
import com.bitchat.android.protocol.BinaryProtocol
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Date
/**
* Golden wire vectors for formats that a from-scratch client must reproduce.
*
* These assertions deliberately compare literal bytes rather than relying only
* on encode/decode round trips, which can hide matching bugs in both methods.
*/
class ClientRewriteWireContractTest {
@Test
fun `v1 packet matches canonical unpadded bytes`() {
val packet = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = hex("1011121314151617"),
recipientID = null,
timestamp = 0x0102030405060708uL,
payload = hex("aabbcc"),
signature = null,
ttl = 7u
)
val encoded = BinaryProtocol.encode(packet, padding = false)
assertArrayEquals(
hex("01020701020304050607080000031011121314151617aabbcc"),
encoded
)
assertEquals(packet, BinaryProtocol.decode(encoded!!))
}
@Test
fun `v2 routed signed packet matches canonical section order`() {
val signature = ByteArray(64) { 0x5a }
val packet = BitchatPacket(
version = 2u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hex("0102030405060708"),
recipientID = hex("1112131415161718"),
timestamp = 42uL,
payload = hex("dead"),
signature = signature,
ttl = 5u,
route = listOf(
hex("2122232425262728"),
hex("3132333435363738")
)
)
val encoded = BinaryProtocol.encode(packet, padding = false)!!
val expectedPrefix = hex(
"021105000000000000002a0b00000002" +
"0102030405060708" +
"1112131415161718" +
"02" +
"2122232425262728" +
"3132333435363738" +
"dead"
)
assertArrayEquals(expectedPrefix + signature, encoded)
assertEquals(packet, BinaryProtocol.decode(encoded))
}
@Test
fun `minimal chat message matches canonical binary payload`() {
val message = BitchatMessage(
id = "id",
sender = "bob",
content = "hi",
timestamp = Date(0x0102030405060708L)
)
val encoded = message.toBinaryPayload()
assertArrayEquals(
hex("00010203040506070802696403626f6200026869"),
encoded
)
assertEquals(message, BitchatMessage.fromBinaryPayload(encoded!!))
}
@Test
fun `chat message optional fields use flags and UTF-8 byte lengths`() {
val message = BitchatMessage(
id = "m",
sender = "é",
content = "hello",
timestamp = Date(42L),
isRelay = true,
originalSender = "o",
isPrivate = true,
recipientNickname = "r",
senderPeerID = "p",
mentions = listOf("a", "β"),
channel = "c"
)
val encoded = message.toBinaryPayload()!!
assertArrayEquals(
hex(
"7f000000000000002a" +
"016d" +
"02c3a9" +
"000568656c6c6f" +
"016f" +
"0172" +
"0170" +
"02" +
"0161" +
"02ceb2" +
"0163"
),
encoded
)
assertEquals(message, BitchatMessage.fromBinaryPayload(encoded))
}
@Test
fun `encrypted chat payload carries ciphertext instead of placeholder content`() {
val message = BitchatMessage(
id = "e",
sender = "alice",
content = "must-not-be-on-wire",
timestamp = Date(1L),
encryptedContent = hex("000102ff"),
isEncrypted = true,
isPrivate = true
)
val decoded = BitchatMessage.fromBinaryPayload(message.toBinaryPayload()!!)!!
assertEquals("", decoded.content)
assertArrayEquals(hex("000102ff"), decoded.encryptedContent)
assertTrue(decoded.isEncrypted)
assertTrue(decoded.isPrivate)
assertFalse(message.toBinaryPayload()!!.toString(Charsets.ISO_8859_1).contains("must-not-be-on-wire"))
}
@Test
fun `private message and Noise envelopes match deployed type bytes`() {
val privateMessage = PrivateMessagePacket(messageID = "m1", content = "hi")
val privateMessageBytes = hex("00026d3101026869")
assertArrayEquals(privateMessageBytes, privateMessage.encode())
assertEquals(privateMessage, PrivateMessagePacket.decode(privateMessageBytes))
assertArrayEquals(
hex("0100026d3101026869"),
NoisePayload(NoisePayloadType.PRIVATE_MESSAGE, privateMessageBytes).encode()
)
assertEquals(
NoisePayloadType.FILE_TRANSFER,
NoisePayload.decode(hex("09cafe"))?.type
)
assertArrayEquals(
hex("20cafe"),
NoisePayload.decode(hex("09cafe"))!!.encode()
)
}
@Test
fun `fragment payload matches the thirteen byte iOS header`() {
val fragment = FragmentPayload(
fragmentID = hex("0001020304050607"),
index = 1,
total = 3,
originalType = MessageType.MESSAGE.value,
data = hex("aabb")
)
val wire = hex("00010203040506070001000302aabb")
assertArrayEquals(wire, fragment.encode())
assertEquals(fragment, FragmentPayload.decode(wire))
assertTrue(fragment.isValid())
}
@Test
fun `sync request matches canonical TLV bytes and skips extensions`() {
val request = RequestSyncPacket(
p = 19,
m = 0x01020304L,
data = hex("aabb")
)
val wire = hex("0100011302000401020304030002aabb")
assertArrayEquals(wire, request.encode())
assertSyncRequestEquals(request, RequestSyncPacket.decode(wire))
val withExtension = hex("7f0002cafe") + wire
assertSyncRequestEquals(request, RequestSyncPacket.decode(withExtension))
}
@Test
fun `identity announcement matches canonical TLV order and preserves extensions`() {
val announcement = IdentityAnnouncement(
nickname = "bob",
noisePublicKey = ByteArray(32) { 0x11 },
signingPublicKey = ByteArray(32) { 0x22 },
capabilities = PeerCapabilities.PRIVATE_MEDIA,
unknownTLVs = listOf(UnknownAnnouncementTLV(0x7f, hex("cafe")))
)
val expected =
hex("0103626f620220") +
ByteArray(32) { 0x11 } +
hex("0320") +
ByteArray(32) { 0x22 } +
hex("050200017f02cafe")
val encoded = announcement.encode()
assertArrayEquals(expected, encoded)
assertEquals(announcement, IdentityAnnouncement.decode(encoded!!))
}
@Test
fun `file transfer matches deployed mixed-width TLV vector`() {
val packet = BitchatFilePacket(
fileName = "a",
fileSize = 2,
mimeType = "m",
content = hex("dead")
)
val wire = hex("01000161020004000000020300016d0400000002dead")
assertArrayEquals(wire, packet.encode())
val decoded = BitchatFilePacket.decode(wire)
assertNotNull(decoded)
assertEquals(packet.fileName, decoded!!.fileName)
assertEquals(packet.fileSize, decoded.fileSize)
assertEquals(packet.mimeType, decoded.mimeType)
assertArrayEquals(packet.content, decoded.content)
}
@Test
fun `required message prefixes reject every truncation`() {
val wire = BitchatMessage(
id = "id",
sender = "bob",
content = "hello",
timestamp = Date(1L)
).toBinaryPayload()!!
for (length in 0 until wire.size) {
assertNull(
"Accepted required message prefix of $length/${wire.size} bytes",
BitchatMessage.fromBinaryPayload(wire.copyOf(length))
)
}
assertNotNull(BitchatMessage.fromBinaryPayload(wire))
}
@Test
fun `TLV decoders reject missing required fields and truncated values`() {
assertNull(PrivateMessagePacket.decode(hex("00026d31")))
assertNull(PrivateMessagePacket.decode(hex("00026d3101036869")))
assertNull(RequestSyncPacket.decode(hex("0100011302000401020304")))
assertNull(IdentityAnnouncement.decode(hex("0103626f62022011")))
assertNull(BitchatFilePacket.decode(hex("010001610400000002de")))
assertNull(FragmentPayload.decode(ByteArray(FragmentPayload.HEADER_SIZE - 1)))
}
private fun hex(value: String): ByteArray {
require(value.length % 2 == 0)
return value.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
private fun assertSyncRequestEquals(
expected: RequestSyncPacket,
actual: RequestSyncPacket?
) {
assertNotNull(actual)
assertEquals(expected.p, actual!!.p)
assertEquals(expected.m, actual.m)
assertArrayEquals(expected.data, actual.data)
}
}

View File

@ -0,0 +1,261 @@
package com.bitchat.android.noise
import com.bitchat.android.noise.southernstorm.protocol.CipherState
import com.bitchat.android.noise.southernstorm.protocol.HandshakeState
import com.bitchat.android.noise.southernstorm.protocol.Noise
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
/**
* Cacophony/Noise-C vector for Noise_XX_25519_ChaChaPoly_SHA256.
*
* This exercises the vendored Noise state machine directly, independent of managers, Android
* storage, and generated keys.
*/
class NoiseExternalVectorTest {
private val messages = listOf(
VectorMessage(
"4c756477696720766f6e204d69736573",
"ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c7944" +
"4c756477696720766f6e204d69736573"
),
VectorMessage(
"4d757272617920526f746862617264",
"95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f144808843" +
"81cbad1f276e038c48378ffce2b65285e08d6b68aaa3629a5a8639392490e5b9" +
"bd5269c2f1e4f488ed8831161f19b7815528f8982ffe09be9b5c412f8a0db50f" +
"8814c7194e83f23dbd8d162c9326ad"
),
VectorMessage(
"462e20412e20486179656b",
"c7195ffacac1307ff99046f219750fc47693e23c3cb08b89c2af808b444850a8" +
"0ae475b9df0f169ae80a89be0865b57f58c9fea0d4ec82a286427402f113e4b6" +
"ae769a1d95941d49b25030"
),
VectorMessage(
"4361726c204d656e676572",
"96763ed773f8e47bb3712f0e29b3060ffc956ffc146cee53d5e1df"
),
VectorMessage(
"4a65616e2d426170746973746520536179",
"3e40f15f6f3a46ae446b253bf8b1d9ffb6ed9b174d272328ff91a7e2e5c79c07f5"
),
VectorMessage(
"457567656e2042f6686d20766f6e2042617765726b",
"eb3f3515110702e047a6c9da4478b6ead94873c11c0f2d710ddb3f09fce024b3" +
"a58502ae3f"
)
)
@Test
fun `Noise-C XX transcript matches every handshake and transport byte`() {
val initiator = vectorState(HandshakeState.INITIATOR)
val responder = vectorState(HandshakeState.RESPONDER)
try {
val states = listOf(
initiator to responder,
responder to initiator,
initiator to responder
)
messages.take(3).zip(states).forEach { (message, peers) ->
assertHandshakeMessage(peers.first, peers.second, message)
}
assertEquals(HandshakeState.SPLIT, initiator.action)
assertEquals(HandshakeState.SPLIT, responder.action)
assertArrayEquals(initiator.handshakeHash, responder.handshakeHash)
val initiatorCiphers = initiator.split()
val responderCiphers = responder.split()
assertTransportMessage(
responderCiphers.sender,
initiatorCiphers.receiver,
messages[3]
)
assertTransportMessage(
initiatorCiphers.sender,
responderCiphers.receiver,
messages[4]
)
assertTransportMessage(
responderCiphers.sender,
initiatorCiphers.receiver,
messages[5]
)
initiatorCiphers.sender.destroy()
initiatorCiphers.receiver.destroy()
responderCiphers.sender.destroy()
responderCiphers.receiver.destroy()
} finally {
initiator.destroy()
responder.destroy()
}
}
@Test
fun `Noise state machine rejects invalid actions and a tampered handshake tag`() {
val initiator = vectorState(HandshakeState.INITIATOR)
val responder = vectorState(HandshakeState.RESPONDER)
try {
assertThrows(IllegalStateException::class.java) { initiator.start() }
assertThrows(IllegalStateException::class.java) {
responder.writeMessage(ByteArray(256), 0, null, 0, 0)
}
assertHandshakeMessage(initiator, responder, messages[0])
val message2 = write(responder, messages[1].payload)
val tampered = message2.copyOf()
tampered[tampered.lastIndex] = (tampered.last().toInt() xor 1).toByte()
assertThrows(Exception::class.java) {
initiator.readMessage(tampered, 0, tampered.size, ByteArray(256), 0)
}
assertEquals(HandshakeState.FAILED, initiator.action)
} finally {
initiator.destroy()
responder.destroy()
}
}
@Test
fun `ChaChaPoly authentication binds nonce ciphertext tag and associated data`() {
val key = ByteArray(32) { it.toByte() }
val plaintext = "associated".toByteArray()
val associatedData = "header".toByteArray()
val sender = Noise.createCipher("ChaChaPoly")
val receiver = Noise.createCipher("ChaChaPoly")
val wrongAdReceiver = Noise.createCipher("ChaChaPoly")
try {
sender.initializeKey(key, 0)
receiver.initializeKey(key, 0)
wrongAdReceiver.initializeKey(key, 0)
sender.setNonce(7)
receiver.setNonce(7)
wrongAdReceiver.setNonce(7)
val ciphertext = ByteArray(plaintext.size + sender.macLength)
val length = sender.encryptWithAd(
associatedData,
plaintext,
0,
ciphertext,
0,
plaintext.size
)
assertThrows(Exception::class.java) {
wrongAdReceiver.decryptWithAd(
"wrong".toByteArray(),
ciphertext,
0,
ByteArray(length),
0,
length
)
}
val output = ByteArray(length)
val outputLength = receiver.decryptWithAd(
associatedData,
ciphertext,
0,
output,
0,
length
)
assertArrayEquals(plaintext, output.copyOf(outputLength))
} finally {
sender.destroy()
receiver.destroy()
wrongAdReceiver.destroy()
}
}
private fun vectorState(role: Int): HandshakeState {
val state = HandshakeState(PROTOCOL, role)
val prologue = hex("4a6f686e2047616c74")
state.setPrologue(prologue, 0, prologue.size)
val staticPrivate = if (role == HandshakeState.INITIATOR) {
hex("e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1")
} else {
hex("4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893")
}
val ephemeralPrivate = if (role == HandshakeState.INITIATOR) {
hex("893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a")
} else {
hex("bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b")
}
state.localKeyPair.setPrivateKey(staticPrivate, 0)
state.fixedEphemeralKey.setPrivateKey(ephemeralPrivate, 0)
state.start()
return state
}
private fun assertHandshakeMessage(
writer: HandshakeState,
reader: HandshakeState,
message: VectorMessage
) {
val actualCiphertext = write(writer, message.payload)
assertArrayEquals(message.ciphertext, actualCiphertext)
val plaintext = ByteArray(256)
val length = reader.readMessage(
actualCiphertext,
0,
actualCiphertext.size,
plaintext,
0
)
assertArrayEquals(message.payload, plaintext.copyOf(length))
}
private fun write(state: HandshakeState, payload: ByteArray): ByteArray {
val output = ByteArray(512)
val length = state.writeMessage(output, 0, payload, 0, payload.size)
return output.copyOf(length)
}
private fun assertTransportMessage(
sender: CipherState,
receiver: CipherState,
message: VectorMessage
) {
val encrypted = ByteArray(message.payload.size + sender.macLength)
val encryptedLength = sender.encryptWithAd(
null,
message.payload,
0,
encrypted,
0,
message.payload.size
)
assertArrayEquals(message.ciphertext, encrypted.copyOf(encryptedLength))
val decrypted = ByteArray(encryptedLength)
val decryptedLength = receiver.decryptWithAd(
null,
encrypted,
0,
decrypted,
0,
encryptedLength
)
assertArrayEquals(message.payload, decrypted.copyOf(decryptedLength))
}
private data class VectorMessage(
private val payloadHex: String,
private val ciphertextHex: String
) {
val payload: ByteArray get() = hex(payloadHex)
val ciphertext: ByteArray get() = hex(ciphertextHex)
}
companion object {
private const val PROTOCOL = "Noise_XX_25519_ChaChaPoly_SHA256"
private fun hex(value: String): ByteArray =
value.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
}

View File

@ -330,6 +330,51 @@ class NoiseSessionManagerIdentityBindingTest {
assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID))
}
@Test
fun `simultaneous handshake collision matrix has one deterministic winner`() {
val identities = listOf(
identity("e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1"),
identity("4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893"),
identity("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"),
identity("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb")
)
identities.indices.forEach { leftIndex ->
((leftIndex + 1) until identities.size).forEach { rightIndex ->
val left = identities[leftIndex]
val right = identities[rightIndex]
val leftManager = manager(left)
val rightManager = manager(right)
val leftMessage1 = leftManager.initiateHandshake(right.peerID)!!
val rightMessage1 = rightManager.initiateHandshake(left.peerID)!!
val leftResponse = leftManager.processHandshakeMessage(right.peerID, rightMessage1)
val rightResponse = rightManager.processHandshakeMessage(left.peerID, leftMessage1)
if (left.peerID < right.peerID) {
assertNull(leftResponse)
val message3 = leftManager.processHandshakeMessage(right.peerID, rightResponse!!)!!
assertNull(rightManager.processHandshakeMessage(left.peerID, message3))
} else {
assertNull(rightResponse)
val message3 = rightManager.processHandshakeMessage(left.peerID, leftResponse!!)!!
assertNull(leftManager.processHandshakeMessage(right.peerID, message3))
}
assertTrue(leftManager.hasEstablishedSession(right.peerID))
assertTrue(rightManager.hasEstablishedSession(left.peerID))
val payload = "matrix-$leftIndex-$rightIndex".toByteArray()
assertArrayEquals(
payload,
rightManager.decrypt(
leftManager.encrypt(payload, right.peerID),
left.peerID
)
)
}
}
}
@Test
fun `peer ID derivation rejects malformed keys and non-wire claims`() {
val peer = identity()
@ -381,4 +426,19 @@ class NoiseSessionManagerIdentityBindingTest {
dh.destroy()
}
}
private fun identity(privateKeyHex: String): TestIdentity {
val privateKey = privateKeyHex.chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
val dh = Noise.createDH("25519")
return try {
dh.setPrivateKey(privateKey, 0)
val publicKey = ByteArray(32)
dh.getPublicKey(publicKey, 0)
TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!)
} finally {
dh.destroy()
}
}
}

View File

@ -0,0 +1,51 @@
package com.bitchat.android.nostr
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class NostrRelayManagerLifecycleSmokeTest {
@Test
fun `disconnected manager maintains subscription and empty publish invariants locally`() {
val manager = NostrRelayManager.shared
manager.disconnect()
manager.clearAllSubscriptions()
val id = manager.subscribe(
filter = NostrFilter(kinds = listOf(NostrKind.TEXT_NOTE)),
id = "local-contract",
handler = {},
targetRelayUrls = emptyList()
)
assertEquals("local-contract", id)
assertEquals(1, manager.getActiveSubscriptionCount())
assertTrue(manager.getActiveSubscriptions().containsKey(id))
assertTrue(manager.validateSubscriptionConsistency().isConsistent)
manager.sendEvent(signedEvent(), relayUrls = emptyList())
manager.retryConnection("wss://not-configured.example")
manager.unsubscribe(id)
assertEquals(0, manager.getActiveSubscriptionCount())
assertFalse(manager.isConnected.value)
assertTrue(manager.getRelayStatuses().none { it.isConnected })
manager.disconnect()
assertFalse(manager.isConnected.value)
}
private fun signedEvent(): NostrEvent {
val privateKey = "0".repeat(63) + "1"
return NostrEvent(
pubkey = NostrCrypto.derivePublicKey(privateKey),
createdAt = 1,
kind = NostrKind.TEXT_NOTE,
tags = emptyList(),
content = "local"
).sign(privateKey)
}
}

View File

@ -0,0 +1,87 @@
package com.bitchat.android.onboarding
import android.app.Application
import android.bluetooth.BluetoothManager
import android.content.Context
import android.location.LocationManager
import androidx.activity.ComponentActivity
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35], application = Application::class)
class SystemStateManagerContractTest {
@Test
fun `Bluetooth disabled and enabled states are observable without throwing`() {
val app = ApplicationProvider.getApplicationContext<Application>()
val controller = Robolectric.buildActivity(ComponentActivity::class.java).create()
val adapter = app.getSystemService(BluetoothManager::class.java).adapter
val manager = BluetoothStatusManager(
activity = controller.get(),
context = app,
onBluetoothEnabled = {},
onBluetoothDisabled = {}
)
shadowOf(adapter).setEnabled(false)
assertEquals(BluetoothStatus.DISABLED, manager.checkBluetoothStatus())
shadowOf(adapter).setEnabled(true)
assertEquals(BluetoothStatus.ENABLED, manager.checkBluetoothStatus())
controller.destroy()
}
@Test
fun `location disabled and enabled states are observable and receiver is cleaned up`() {
val app = ApplicationProvider.getApplicationContext<Application>()
val controller = Robolectric.buildActivity(ComponentActivity::class.java).create()
val locationManager = app.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val manager = LocationStatusManager(
activity = controller.get(),
context = app,
onLocationEnabled = {},
onLocationDisabled = {}
)
shadowOf(locationManager).setLocationEnabled(false)
assertEquals(LocationStatus.DISABLED, manager.checkLocationStatus())
shadowOf(locationManager).setLocationEnabled(true)
assertEquals(LocationStatus.ENABLED, manager.checkLocationStatus())
manager.cleanup()
manager.cleanup()
controller.destroy()
}
@Test
fun `location status routing and recovery messages remain exact`() {
val app = ApplicationProvider.getApplicationContext<Application>()
val controller = Robolectric.buildActivity(ComponentActivity::class.java).create()
var enabled = 0
val disabled = mutableListOf<String>()
val manager = LocationStatusManager(
activity = controller.get(),
context = app,
onLocationEnabled = { enabled++ },
onLocationDisabled = disabled::add
)
manager.handleLocationStatus(LocationStatus.ENABLED)
manager.handleLocationStatus(LocationStatus.NOT_AVAILABLE)
assertEquals(1, enabled)
assertEquals(
listOf("Location services are not available on this device."),
disabled
)
assertTrue(manager.getStatusMessage(LocationStatus.DISABLED).contains("Bluetooth scanning"))
manager.cleanup()
controller.destroy()
}
}

View File

@ -0,0 +1,141 @@
package com.bitchat.android.ui
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import java.util.Date
import java.util.concurrent.atomic.AtomicInteger
@OptIn(ExperimentalCoroutinesApi::class)
class MeshDelegateHandlerStateContractTest {
private lateinit var state: ChatState
private lateinit var messages: MessageManager
private lateinit var channels: ChannelManager
private lateinit var privateChats: PrivateChatManager
private lateinit var notifications: NotificationManager
private lateinit var mesh: MeshService
private lateinit var handler: MeshDelegateHandler
private lateinit var haptics: AtomicInteger
@Before
fun setUp() {
val scope = TestScope(UnconfinedTestDispatcher())
state = ChatState(scope)
state.setNickname("Résumé")
messages = MessageManager(state)
channels = mock()
privateChats = mock()
notifications = mock()
mesh = mock()
haptics = AtomicInteger()
handler = MeshDelegateHandler(
state = state,
messageManager = messages,
channelManager = channels,
privateChatManager = privateChats,
notificationManager = notifications,
coroutineScope = scope,
onHapticFeedback = { haptics.incrementAndGet() },
getMyPeerID = { "self" },
getMeshService = { mesh }
)
}
@Test
fun `peer arrival deduplicates list and final departure restores disconnected state`() {
handler.didUpdatePeerList(listOf("peer-a", "peer-a", "peer-b"))
assertEquals(listOf("peer-a", "peer-b"), state.connectedPeers.value)
assertTrue(state.isConnected.value)
verify(notifications).showActiveUserNotification(listOf("peer-a", "peer-b"))
verify(channels).cleanupDisconnectedMembers(listOf("peer-a", "peer-b"), "self")
handler.didUpdatePeerList(emptyList())
assertTrue(state.connectedPeers.value.isEmpty())
assertFalse(state.isConnected.value)
verify(notifications).showActiveUserNotification(emptyList())
}
@Test
fun `delivery and read callbacks advance visible status monotonically`() {
val outgoing = message(
id = "outgoing",
sender = "me",
deliveryStatus = DeliveryStatus.Sending
)
state.setMessages(listOf(outgoing))
handler.didReceiveDeliveryAck("outgoing", "peer-a")
assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Delivered)
handler.didReceiveReadReceipt("outgoing", "peer-a")
assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Read)
handler.didReceiveDeliveryAck("outgoing", "peer-a")
assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Read)
}
@Test
fun `unicode mention notifies once and duplicate transport delivery is suppressed`() {
val incoming = message(
id = "incoming",
sender = "alice",
content = "hello @résumé",
senderPeerID = "peer-a"
)
handler.didReceiveMessage(incoming)
handler.didReceiveMessage(incoming)
assertEquals(1, haptics.get())
verify(notifications, times(1)).showMeshMentionNotification(
senderNickname = eq("alice"),
messageContent = eq("hello @résumé"),
senderPeerID = eq("peer-a")
)
}
@Test
fun `channel inbound increments unread only when conversation is not focused`() {
state.setJoinedChannels(setOf("#room"))
val incoming = message(id = "channel-1", channel = "#room")
handler.didReceiveMessage(incoming)
assertEquals(1, state.unreadChannelMessages.value["#room"])
state.setCurrentChannel("#room")
handler.didReceiveMessage(incoming.copy(id = "channel-2"))
assertEquals(1, state.unreadChannelMessages.value["#room"])
}
private fun message(
id: String,
sender: String = "alice",
content: String = id,
senderPeerID: String? = null,
channel: String? = null,
deliveryStatus: DeliveryStatus? = null
) = BitchatMessage(
id = id,
sender = sender,
content = content,
timestamp = Date(1),
senderPeerID = senderPeerID,
channel = channel,
deliveryStatus = deliveryStatus
)
}

View File

@ -0,0 +1,151 @@
package com.bitchat.android.wifiaware
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.FilterInputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.Socket
import java.util.Collections
class SyncedSocketContractTest {
@Test
fun `write emits big-endian length payload and empty keepalive frames`() {
val output = ByteArrayOutputStream()
val raw = socket(input = ByteArrayInputStream(byteArrayOf()), output = output)
val synced = SyncedSocket(raw, readTimeoutMs = 1_234)
synced.write(byteArrayOf(1, 2, 3))
synced.write(ByteArray(0))
assertArrayEquals(
byteArrayOf(0, 0, 0, 3, 1, 2, 3, 0, 0, 0, 0),
output.toByteArray()
)
verify(raw).soTimeout = 1_234
}
@Test
fun `readFully reconstructs one-byte partial reads and keepalives`() {
val wire = framed(byteArrayOf(1, 2, 3, 4)) + framed(ByteArray(0))
val partial = object : FilterInputStream(ByteArrayInputStream(wire)) {
override fun read(buffer: ByteArray, offset: Int, length: Int): Int =
super.read(buffer, offset, minOf(1, length))
}
val synced = SyncedSocket(socket(partial, ByteArrayOutputStream()))
assertArrayEquals(byteArrayOf(1, 2, 3, 4), synced.read())
assertArrayEquals(ByteArray(0), synced.read())
assertNull(synced.read())
}
@Test
fun `EOF truncated invalid and oversized frames fail closed`() {
val cases = listOf(
ByteArray(0),
byteArrayOf(0, 0),
byteArrayOf(0, 0, 0, 4, 1, 2),
intPrefix(-1),
intPrefix(65_537)
)
cases.forEach { wire ->
val synced = SyncedSocket(
socket(ByteArrayInputStream(wire), ByteArrayOutputStream())
)
assertNull(synced.read())
}
}
@Test
fun `write exceptions propagate and do not create a partial success`() {
val failingOutput = object : OutputStream() {
override fun write(value: Int) {
throw IOException("scripted write failure")
}
}
val synced = SyncedSocket(socket(ByteArrayInputStream(byteArrayOf()), failingOutput))
assertThrows(IOException::class.java) {
synced.write(byteArrayOf(1))
}
}
@Test
fun `concurrent writers produce complete non-interleaved frames`() {
val output = ByteArrayOutputStream()
val synced = SyncedSocket(socket(ByteArrayInputStream(byteArrayOf()), output))
val payloads = (0 until 16).map { index ->
ByteArray(index + 1) { index.toByte() }
}
val failures = Collections.synchronizedList(mutableListOf<Throwable>())
val threads = payloads.map { payload ->
Thread {
runCatching { synced.write(payload) }
.exceptionOrNull()
?.let(failures::add)
}.also(Thread::start)
}
threads.forEach { thread ->
thread.join(2_000)
assertFalse("Writer thread did not complete", thread.isAlive)
}
assertTrue(failures.isEmpty())
val input = DataInputStream(ByteArrayInputStream(output.toByteArray()))
val decoded = mutableListOf<ByteArray>()
while (input.available() > 0) {
val length = input.readInt()
decoded += ByteArray(length).also(input::readFully)
}
assertEquals(
payloads.map(ByteArray::toList).toSet(),
decoded.map(ByteArray::toList).toSet()
)
}
@Test
fun `close and raw socket status are exposed`() {
val raw = socket(ByteArrayInputStream(byteArrayOf()), ByteArrayOutputStream())
org.mockito.kotlin.whenever(raw.isClosed).thenReturn(false, true)
org.mockito.kotlin.whenever(raw.isConnected).thenReturn(true)
val synced = SyncedSocket(raw)
assertFalse(synced.isClosed())
assertTrue(synced.isConnected())
synced.close()
verify(raw).close()
assertTrue(synced.isClosed())
}
private fun socket(input: InputStream, output: OutputStream): Socket = mock<Socket> {
on { getInputStream() } doReturn input
on { getOutputStream() } doReturn output
}
private fun framed(payload: ByteArray): ByteArray =
ByteArrayOutputStream().also { output ->
DataOutputStream(output).use { data ->
data.writeInt(payload.size)
data.write(payload)
}
}.toByteArray()
private fun intPrefix(value: Int): ByteArray =
ByteArrayOutputStream().also { output ->
DataOutputStream(output).use { it.writeInt(value) }
}.toByteArray()
}

View File

@ -10,3 +10,9 @@ tasks.whenTaskAdded {
enabled = false
}
}
tasks.register("clientRewriteContractTest") {
group = "verification"
description = "Runs the complete compatibility gate for a from-scratch client rewrite."
dependsOn(":app:testDebugUnitTest")
}

View File

@ -0,0 +1,63 @@
# Client rewrite compatibility contracts
This document defines the behavior a from-scratch BitChat client must preserve.
The executable source of truth is the JVM test suite under
`app/src/test/**/contracts`, together with the pre-existing protocol, security,
mesh, and state tests.
The remaining implementation work and milestone progress are tracked in
[test-implementation-plan.md](test-implementation-plan.md).
## Required contract layers
| Layer | Compatibility promise | Primary tests |
|---|---|---|
| Outer mesh packet | v1/v2 header widths, big-endian fields, flags, section order, route placement, signature placement, padding, compression, signing bytes | `BinaryProtocolTest`, `ClientRewriteWireContractTest` |
| Chat payload | Flag bits, millisecond timestamp, UTF-8 byte lengths, encrypted-content substitution, optional-field order | `ClientRewriteWireContractTest` |
| Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `FragmentManagerTest` |
| Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest`, `ClientRewritePrimitiveContractTest` |
| Sync/routing | Stable packet IDs, GCS bitstream, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` |
| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals | `ClientRewriteNostrContractTest`, `NostrProtocolTest` |
| Application state | Peer unions, canonical private conversations, chronological history, delivery/read behavior, media migration policy | `AppStateStoreTest`, `PrivateChatManagerTest`, `MediaSendingManagerMigrationTest` |
## Golden-vector policy
Golden vectors compare literal externally visible bytes or hashes. Do not update
them merely because an implementation changed. Update a vector only when the
wire protocol is intentionally versioned and interoperating clients are updated
together.
Round-trip tests remain useful but are not sufficient on their own: an encoder
and decoder can share the same defect. Each critical wire format therefore has
at least one literal vector.
## Rewrite acceptance gate
From a configured Android development environment, run:
```sh
./gradlew clientRewriteContractTest
```
The task runs the new golden vectors and the complete existing unit suite. A
rewrite is compatible only when this gate passes. Tests should be ported
unchanged when package boundaries change; adapter façades are preferable to
weakening assertions.
## Device-only acceptance
Local JVM tests cannot prove Android radio and lifecycle behavior. Before
shipping a rewrite, run the following on at least two physical devices:
1. BLE discovery, connection, disconnect, reconnect, and multi-hop relay.
2. Runtime permission denial/retry for Bluetooth, location, notifications, and
microphone.
3. Foreground-service survival with the screen off and after process recreation.
4. Cross-client Android/iOS exchange for announce, public/private text, delivery
and read receipts, image/audio/file transfer, sync replay, and Nostr fallback.
5. Corrupt, duplicated, reordered, delayed, and partially delivered fragments.
6. Identity rotation, verification continuity, downgrade rejection, and recovery
after stale Noise sessions.
Those scenarios belong in instrumented tests or a two-device interoperability
harness; they must not be represented as passing JVM mocks.

View File

@ -0,0 +1,113 @@
# Physical transport validation matrix
This is the device-only companion to the deterministic Milestone 4 transport
suite. It validates that the fake adapters, Robolectric behavior, and pure
state machines match Android framework behavior. It is also consumed by the
Milestone 10 release gate.
## Required device set
- Two physical Android devices from different manufacturers.
- At least one Android 13+ device for `NEARBY_WIFI_DEVICES`.
- At least one device that supports Wi-Fi Aware.
- Bluetooth LE central and peripheral support on both devices.
- A build from the exact commit under test installed on both devices.
- Clean app data before the first run; retain a second run for restart tests.
Record device models, API levels, build commit, negotiated MTUs, and timestamps
in the release artifact. Do not record user names, device serials, Bluetooth
addresses, IP addresses, peer IDs, message contents, or other identifying
values.
## BLE discovery and recovery
- [ ] Start both clients and confirm each begins scanning and advertising.
- [ ] Stop and restart the foreground service; confirm exactly one scanner and
advertiser generation remains active.
- [ ] Toggle Bluetooth off during scanning, then on; confirm scanning,
advertising, announcements, and peer discovery recover without process
restart.
- [ ] Disable and re-enable the BLE debug transport; confirm the same service
instance can recover without duplicate callbacks.
- [ ] Rotate the observed BLE address by restarting advertising; confirm the
canonical peer remains singular.
- [ ] Trigger a transient scan failure or Android Bluetooth process restart;
confirm bounded retry and watchdog recovery.
- [ ] Confirm permission denial reports unavailable state without a crash,
prompt loop, or active radio work.
## GATT setup and teardown
- [ ] Connect in both directions simultaneously and confirm one canonical link
survives.
- [ ] Record the negotiated MTU and repeat at 23, 247, and 517 where the device
or test peripheral allows it.
- [ ] Remove the service, characteristic, or CCCD in a test peripheral and
confirm setup fails closed.
- [ ] Reject notification registration and descriptor writes; confirm the peer
is never published ready.
- [ ] Disconnect during MTU negotiation, service discovery, subscription,
client write, and server notification; confirm no stale ready callback.
- [ ] Leave setup incomplete for more than 30 seconds; confirm timeout and
resource closure.
- [ ] Connect beyond configured client, server, and total limits; confirm
deterministic oldest-link eviction.
## Packet delivery and fragmentation
- [ ] Send directed and broadcast packets over client and server roles.
- [ ] Saturate each link faster than radio completion callbacks; confirm one
outstanding operation, bounded backpressure, and no reordered frames.
- [ ] Inject a failed `onCharacteristicWrite` and `onNotificationSent`; confirm
queued work is discarded and the failed generation is cleaned up.
- [ ] Transfer payloads immediately below and above the fragmentation boundary.
- [ ] Transfer a maximum admitted private-media payload at negotiated MTU 517.
- [ ] At MTU 247 and 23, confirm oversized frames are rejected rather than
partially sent. Adaptive per-link fragmentation remains tracked as
`TDB-023`.
- [ ] Disconnect and reconnect halfway through a fragmented transfer; confirm
incomplete state expires and a fresh transfer can finish.
- [ ] Cancel a queued transfer and stop the service during another; confirm no
later fragments or progress callbacks.
## Wi-Fi Aware
- [ ] Confirm unsupported hardware and temporarily unavailable radio states are
distinct.
- [ ] Deny and grant `NEARBY_WIFI_DEVICES`; confirm publish/subscribe work only
after grant.
- [ ] Start and stop publish and subscribe sessions repeatedly; confirm no
duplicate discovery callbacks.
- [ ] Authenticate a provisional socket and promote it to the canonical peer.
- [ ] Replace a socket while authentication is in flight; confirm the stale
socket cannot promote or deliver.
- [ ] Toggle Wi-Fi, location, and airplane mode; confirm rediscovery and bounded
reconnect after availability returns.
- [ ] Stop the service with active sockets, server sockets, and network
callbacks; confirm all are closed or unregistered.
## Unified transport and failover
- [ ] Connect the same peer over BLE and Wi-Fi Aware; confirm one peer-list row.
- [ ] Send with both transports active; confirm the preferred transport is used.
- [ ] Drop the preferred transport during a transfer and confirm defined
failover behavior without duplicate application delivery.
- [ ] Relay between transports and confirm TTL decreases once per hop.
- [ ] Reflect a bridged packet back over the other transport; confirm loop and
duplicate suppression.
- [ ] Stop the foreground service; confirm scans, advertisements, sessions,
sockets, operation queues, transfer jobs, and callbacks all terminate.
## Evidence template
| Field | Value |
|---|---|
| Commit | |
| Device/API classes | |
| BLE central/peripheral | Pass / Fail |
| MTU cases | Pass / Fail / Unsupported |
| BLE recovery | Pass / Fail |
| Wi-Fi Aware lifecycle | Pass / Fail / Unsupported |
| Cross-transport failover | Pass / Fail |
| Shutdown leak check | Pass / Fail |
| Bugs filed | |

View File

@ -0,0 +1,244 @@
# Physical-device and cross-client release gate
This runbook turns Milestone 10 into a repeatable release procedure. The gate
uses a host-side CLI and USB/ADB as its control channel, so control traffic
never shares BLE, Wi-Fi Aware, Nostr, or Tor with the system under test.
The gate cannot pass without the required physical devices and counterpart
clients. A pending or blocked result is useful diagnostic evidence, but it is
not release approval.
## Safety and privacy rules
- Use only disposable lab app data, identities, nicknames, messages, and files.
- Never use a personal Nostr account or a production relay.
- Do not put device serials, UDIDs, Bluetooth/MAC/IP addresses, peer IDs,
usernames, email addresses, local home paths, or message contents in a
result, trace, filename, issue, commit, or release artifact.
- Device selectors may be supplied to ADB commands as ephemeral inputs. The
tooling emits only logical aliases such as `android-current`.
- Models, manufacturer classes, Android API levels, negotiated MTU classes,
client versions, commit hashes, aggregate counts, durations, and stable
failure reason codes are allowed.
- Do not archive raw logcat. Convert observations to the structured,
privacy-checked trace format, and keep any raw diagnostic capture local until
it has been reviewed and sanitized.
The validator rejects known identifying fields and values before a passing
bundle can be created.
## Required lab
Prepare:
- At least three physical Android devices for three-hop relay testing.
- At least two Android API levels and two manufacturer classes.
- BLE central and peripheral support on every Android device.
- At least one Android 13+ device with Wi-Fi Aware.
- One physical device running the current iOS client.
- The last supported Android client.
- The release-candidate APK built from one exact full Git commit.
- A local, disposable Nostr relay/Tor fixture with production network access
blocked.
One physical handset may be reused for the legacy-client phase after the
current-client evidence for that slot is complete, but the matrix must keep the
logical aliases and installed client versions unambiguous.
## 1. Verify the deterministic gate
From the repository root:
```sh
./gradlew clientRewriteContractTest checkChangedLineCoverage lintDebug
python3 tools/release_gate/release_gate.py validate-manifest
```
Do not begin device work from a dirty tree or a build whose deterministic gate
does not pass.
## 2. Create the device matrix
Copy `tools/release_gate/device-matrix.example.json` to an ignored working
directory under `release-gate-results/`. Replace every template value and set
both current-Android commit fields to the exact full commit under test.
Probe Android capabilities without storing the ADB selector:
```sh
python3 tools/release_gate/android_lab.py probe \
--serial "$BITCHAT_ADB_SELECTOR" \
--alias android-current
```
Copy only the returned logical metadata into the matrix. Validate it:
```sh
python3 tools/release_gate/release_gate.py validate-matrix \
--matrix release-gate-results/device-matrix.json \
--commit "$BITCHAT_RELEASE_COMMIT"
```
The matrix validator enforces physical devices, three Android participants, two
API levels, two manufacturer classes, Wi-Fi Aware, BLE roles, iOS, and explicit
current/legacy client versions.
## 3. Initialize disposable fixtures
```sh
python3 tools/release_gate/release_gate.py init \
--matrix release-gate-results/device-matrix.json \
--commit "$BITCHAT_RELEASE_COMMIT" \
--run-id rc-lab-01 \
--output release-gate-results/rc-lab-01
```
Initialization pins the scenario and fixture manifests, creates every scenario
as `pending`, and generates deterministic:
- zero-byte and small files;
- a Unicode-named medium file;
- sparse exact-maximum and oversized boundary files.
The fixture manifest records size and SHA-256. The final archive contains the
manifest, not the large fixture bodies.
Clear only the disposable app data on each selected lab device:
```sh
python3 tools/release_gate/android_lab.py prepare \
--serial "$BITCHAT_ADB_SELECTOR" \
--confirm-disposable-app-data
```
This stops the app and runs package-data cleanup. The explicit confirmation is
required because the operation is destructive to that app's local data.
## 4. Execute scenarios
The canonical scenario list is
`tools/release_gate/scenarios.json`. It contains 27 mandatory scenarios:
- the complete physical transport matrix;
- Android API/manufacturer/permission/background coverage;
- 11 Android-to-Android workflows;
- 8 cross-client/backward-compatibility workflows;
- 6 background and endurance workflows.
For each scenario:
1. Confirm the listed participants and capabilities.
2. Perform the corresponding steps in
[device-transport-test-matrix.md](device-transport-test-matrix.md) and the
Milestone 10 checklist.
3. Record connection, lifecycle, transport, receipt, resource, and terminal
state as aggregate evidence.
4. Append at least one structured trace event.
5. Mark the scenario `pass`, `fail`, `blocked`, or `unsupported`.
Record evidence with the exact keys declared by the scenario:
```sh
python3 tools/release_gate/release_gate.py record \
--run release-gate-results/rc-lab-01 \
--scenario A2A-001 \
--status pass \
--evidence connection-transitions=4 \
--evidence packet-correlation-count=6 \
--evidence failure-reasons=none
```
Append a privacy-safe trace event:
```sh
python3 tools/release_gate/release_gate.py trace \
--run release-gate-results/rc-lab-01 \
--scenario A2A-001 \
--source android-current \
--event reconnect-terminal \
--outcome pass \
--metric reconnect-count=1 \
--metric duplicate-delivery-count=0
```
Capture resource snapshots during endurance work:
```sh
python3 tools/release_gate/android_lab.py snapshot \
--serial "$BITCHAT_ADB_SELECTOR" \
--alias android-current \
--run release-gate-results/rc-lab-01 \
--scenario END-003
```
Use run-local sequential correlation labels while observing packets; archive
only aggregate correlation counts. Record failures with a stable reason code,
file a regression issue, and preserve the incomplete artifact.
## 5. Endurance requirements
- `END-001` requires at least 240 minutes.
- `END-002` requires at least 50 large-transfer/cancellation cycles.
- Sample memory, threads, file descriptors, wake locks, connection counts, and
late callbacks at consistent intervals.
- A passing result requires bounded resource behavior and a clean terminal
state; merely completing the time window is insufficient.
The validator rejects shorter durations and cycle counts.
## 6. Inspect progress and validate
During a run:
```sh
python3 tools/release_gate/release_gate.py validate \
--run release-gate-results/rc-lab-01 \
--allow-incomplete
python3 tools/release_gate/release_gate.py summary \
--run release-gate-results/rc-lab-01
```
The release validator, without `--allow-incomplete`, requires:
- every scenario to be `pass`;
- every declared evidence field;
- at least one structured trace per scenario;
- the pinned scenario and fixture manifests;
- the exact client commit and complete device matrix;
- endurance minimums;
- a completion timestamp;
- no detected identifying fields or values.
`unsupported`, `blocked`, and `pending` never satisfy release approval.
## 7. Archive release approval
After the complete validator passes:
```sh
python3 tools/release_gate/release_gate.py bundle \
--run release-gate-results/rc-lab-01 \
--output release-gate-results/rc-lab-01.zip
```
The deterministic archive contains the scenario manifest, device/client matrix,
results, structured trace, fixture manifest, Markdown summary, and
`SHA256SUMS`. Attach it to the release approval record without renaming fields
or adding raw diagnostics.
Finally, clean the disposable app data with the same confirmed `cleanup`
command and stop the local relay/Tor fixture.
## Failure handling
- `fail`: behavior violated a contract. Record a stable reason code, file a bug,
add a deterministic regression where possible, fix it, and rerun the affected
scenario plus dependent scenarios.
- `blocked`: required lab infrastructure or counterpart client was unavailable.
Preserve the artifact and do not approve release.
- `unsupported`: the selected device lacks a capability. Because the defined
matrix requires Wi-Fi Aware, replace the device or matrix; unsupported does
not waive a mandatory scenario.
- A flaky result is a failure until its cause is understood. Never average
retries into a pass.

View File

@ -0,0 +1,779 @@
# Test implementation plan
## Objective
Build enough deterministic, adversarial, integration, and device-level coverage
that the Android client can be rewritten from scratch without silently changing
its wire behavior, security properties, delivery semantics, lifecycle behavior,
or user-visible workflows.
The canonical compatibility requirements are documented in
[client-rewrite-contracts.md](client-rewrite-contracts.md). This plan describes
how to turn those requirements into a complete, continuously enforced test
program.
## Status legend
- **Complete**: acceptance criteria are met and the tests run in the rewrite gate.
- **In progress**: implementation has started but acceptance criteria are not met.
- **Not started**: no implementation work has been completed.
- **Blocked**: progress requires an external dependency, device, or decision.
## Current progress
| Milestone | Status | Progress | Depends on |
|---|---|---:|---|
| 0. Compatibility baseline | Complete | 100% | — |
| 1. Coverage and deterministic test infrastructure | Not started | 0% | 0 |
| 2. Adversarial protocol and parser testing | Not started | 0% | 1 |
| 3. Noise, cryptography, and identity testing | Not started | 0% | 1 |
| 4. BLE, Wi-Fi Aware, and transport lifecycle testing | Not started | 0% | 1, 3 |
| 5. Sync, routing, and store-and-forward testing | Not started | 0% | 1, 4 |
| 6. Nostr and Tor integration testing | Not started | 0% | 1, 3 |
| 7. Android lifecycle and permission testing | Not started | 0% | 1, 4 |
| 8. Persistence, migration, and recovery testing | Not started | 0% | 1, 3 |
| 9. UI, media, and accessibility testing | Not started | 0% | 1, 7, 8 |
| 10. Physical-device and cross-client release gate | Not started | 0% | 2–9 |
Milestone completion is currently **1 of 11 milestones (9%)**. This is
milestone-based progress, not line or branch coverage. Milestone 1 will establish
measured coverage baselines and trends.
## Test levels and execution policy
| Level | Purpose | Expected execution |
|---|---|---|
| Pure JVM unit tests | Protocols, state machines, crypto vectors, parsing, routing, and deterministic utilities | Every pull request |
| Property and fuzz tests | Malformed inputs, boundary exploration, invariants, and crash resistance | Bounded set on every pull request; extended corpus nightly |
| Robolectric tests | Android services, lifecycle, broadcasts, permissions, persistence, and process recreation | Every pull request where stable |
| Instrumented emulator tests | Compose semantics, navigation, database/filesystem integration, and permission flows | Main branch and release candidates |
| Physical-device tests | BLE, Wi-Fi Aware, radios, background execution, and manufacturer-specific behavior | Nightly where devices are available; mandatory release gate |
| Cross-client interoperability | Android/iOS and old/new client wire compatibility | Mandatory release gate |
## Global rules
- [x] Keep literal golden vectors for externally visible bytes and hashes.
- [x] Run all compatibility and regression tests through
`./gradlew clientRewriteContractTest`.
- [ ] Prefer public behavior and stable adapter interfaces over implementation
details.
- [ ] Require deterministic clocks, randomness, dispatchers, storage, and
transports in tests.
- [ ] Never use production relay or internet availability as a test dependency.
- [ ] Every fixed protocol or security defect must receive a regression test.
- [ ] Every decoder must have positive, boundary, malformed, and fuzz coverage.
- [ ] Every asynchronous test must have bounded completion and must not use
arbitrary sleeps.
- [ ] Test failures must preserve seeds, inputs, and traces needed to reproduce
the failure.
- [ ] Golden vectors may change only with an intentional protocol version change
and coordinated interoperability review.
---
## Milestone 0: Compatibility baseline
**Status:** Complete
**Progress:** 100%
### Scope
Establish executable rewrite contracts for the most important deterministic
wire formats and reuse the existing regression suite as a single acceptance
gate.
### Completed checklist
- [x] Create an isolated workspace and
`codex/client-rewrite-contract-tests` branch.
- [x] Add literal v1 and v2 outer packet vectors.
- [x] Add public, private, optional-field, and encrypted message vectors.
- [x] Add private-message, Noise envelope, fragment, sync, identity, and file
transfer vectors.
- [x] Add padding, binary encoding, geohash, gossip, packet ID, GCS, and Noise
peer-ID contracts.
- [x] Add Bech32, secp256k1, NIP-01, NIP-44, and NIP-13 contracts.
- [x] Add required-prefix and representative truncated-input rejection tests.
- [x] Add explicit validation for malformed fragment IDs.
- [x] Add `clientRewriteContractTest` as the complete rewrite acceptance task.
- [x] Verify 32 new tests pass without skips.
- [x] Verify the full gate discovers 254 tests with zero failures or errors.
- [x] Document the remaining device-only acceptance requirements.
### Acceptance criteria
- [x] The original `main` workspace remains unchanged.
- [x] All new golden-vector tests pass.
- [x] The complete unit suite passes through one documented command.
---
## Milestone 1: Coverage and deterministic test infrastructure
**Status:** Not started
**Progress:** 0%
### Goal
Make coverage measurable and provide reusable deterministic seams so later
milestones test behavior without real time, radios, network access, or flaky
scheduling.
### TODO checklist
#### Coverage reporting
- [ ] Add JaCoCo or Kover for JVM unit-test line and branch coverage.
- [ ] Generate XML and HTML reports from the rewrite acceptance task.
- [ ] Record the initial project-wide line and branch coverage baseline.
- [ ] Record package-level baselines for `mesh`, `noise`, `nostr`, `service`,
`services`, `sync`, `model`, `protocol`, `identity`, and `ui`.
- [ ] Publish coverage artifacts in CI.
- [ ] Add a changed-lines coverage check for new production code.
- [ ] Add non-regression thresholds without forcing low-value tests for trivial
generated or platform glue.
- [ ] Exclude generated code, Compose compiler output, Android resource classes,
and vendored cryptographic code from first-party coverage metrics.
#### Deterministic seams
- [ ] Introduce an injectable monotonic clock and wall clock.
- [ ] Introduce injectable secure and non-secure random-byte sources where
deterministic vectors are required.
- [ ] Introduce injectable coroutine dispatchers and test scopes.
- [ ] Introduce an in-memory key/value storage adapter for preferences.
- [ ] Introduce an in-memory file store with controllable I/O failures.
- [ ] Define a fake mesh transport that can connect, disconnect, delay, drop,
duplicate, corrupt, reorder, and fragment packets.
- [ ] Define fake BLE scanner, advertiser, GATT client, and GATT server adapters.
- [ ] Define a fake Wi-Fi Aware session/socket adapter.
- [ ] Define a fake Nostr relay transport or MockWebServer fixture.
- [ ] Provide reusable packet, identity, peer, graph, and message fixture
builders.
- [ ] Provide seed capture and reproduction helpers for randomized tests.
- [ ] Add test naming and directory conventions for unit, property, Robolectric,
instrumented, and interoperability suites.
### Acceptance criteria
- [ ] One command generates a repeatable coverage report.
- [ ] Two consecutive clean runs produce identical deterministic test results.
- [ ] Fake time and transport behavior require no wall-clock sleeps.
- [ ] CI publishes coverage and test-result artifacts.
- [ ] The plan's progress table is updated with measured baseline numbers.
---
## Milestone 2: Adversarial protocol and parser testing
**Status:** Not started
**Progress:** 0%
### Goal
Prove that all wire decoders preserve canonical behavior, reject unsafe input,
and never crash or allocate unreasonable memory for attacker-controlled data.
### TODO checklist
#### Outer packet protocol
- [ ] Test every valid flag combination for v1 and v2.
- [ ] Test exact minimum and maximum payload sizes.
- [ ] Test sender and recipient IDs at 0, 1, 7, 8, 9, and oversized lengths.
- [ ] Test signatures at 0, 1, 63, 64, 65, and oversized lengths.
- [ ] Test route counts at 0, 1, 254, 255, and truncated route entries.
- [ ] Test unknown message type values remain safely representable or are
rejected according to the protocol contract.
- [ ] Test invalid versions, reserved flags, integer overflow, and unsigned
length conversion.
- [ ] Test trailing bytes and concatenated frames explicitly.
- [ ] Test padding boundaries around 256, 512, 1024, and 2048 bytes.
- [ ] Test malformed PKCS#7 tails and ambiguous unpadded frames.
- [ ] Test raw DEFLATE and zlib-header compatibility vectors.
- [ ] Test forged original-size fields, compression bombs, and truncated
compressed streams.
- [ ] Add encode/decode property tests for all valid packet shapes.
- [ ] Add a mutation corpus derived from every golden packet.
#### Inner payloads and TLVs
- [ ] Fuzz `BitchatMessage.fromBinaryPayload`.
- [ ] Fuzz `IdentityAnnouncement.decode`.
- [ ] Fuzz `AuthenticatedPeerState.decode`.
- [ ] Fuzz `PrivateMessagePacket.decode`.
- [ ] Fuzz `NoisePayload.decode`.
- [ ] Fuzz `BitchatFilePacket.decode`.
- [ ] Fuzz `FragmentPayload.decode`.
- [ ] Fuzz `RequestSyncPacket.decode`.
- [ ] Test missing, duplicated, reordered, unknown, and zero-length TLVs.
- [ ] Test truncated headers and values at every byte offset.
- [ ] Test UTF-8 ASCII, multi-byte, combining-mark, emoji, invalid-byte, and
maximum-byte-length cases.
- [ ] Test 255-byte one-byte-length boundaries.
- [ ] Test 65,535-byte two-byte-length boundaries.
- [ ] Test four-byte file content lengths and impossible content declarations.
- [ ] Test fragmented file content using one and multiple content TLVs.
- [ ] Define and test whether non-canonical but tolerated inputs re-encode
canonically.
#### Fuzzing operations
- [ ] Select a JVM-compatible property/fuzz framework.
- [ ] Add bounded pull-request fuzz runs with fixed seeds.
- [ ] Add extended randomized nightly runs.
- [ ] Store minimized failing inputs as regression fixtures.
- [ ] Assert no decoder throws for arbitrary byte arrays.
- [ ] Assert decoder runtime and allocations stay within configured bounds.
### Acceptance criteria
- [ ] Every externally reachable decoder has boundary and malformed-input tests.
- [ ] Every decoder has a bounded arbitrary-byte no-crash property.
- [ ] All discovered crashes or ambiguous contracts have regression fixtures.
- [ ] Extended fuzzing completes nightly and preserves reproduction seeds.
---
## Milestone 3: Noise, cryptography, and identity testing
**Status:** Not started
**Progress:** 0%
### Goal
Prove confidentiality, authenticity, identity binding, replay behavior, session
replacement, rekeying, and recovery across the complete secure-channel
lifecycle.
### TODO checklist
#### Known vectors and primitives
- [ ] Add known Curve25519 key agreement vectors.
- [ ] Add known Ed25519 signing and verification vectors.
- [ ] Add known BIP-340 verification vectors.
- [ ] Add known HKDF and channel-key derivation vectors.
- [ ] Add external Noise XX handshake transcript vectors where compatible.
- [ ] Add deterministic channel encryption vectors with injected nonces.
- [ ] Test constant-time verification APIs where the underlying library exposes
an appropriate contract.
#### Noise session lifecycle
- [ ] Test initiator and responder handshakes without manager wrappers.
- [ ] Test all valid handshake state transitions.
- [ ] Test every invalid message for every handshake state.
- [ ] Test tampered handshake messages and remote static-key substitution.
- [ ] Test post-handshake encryption in both directions.
- [ ] Test empty, small, maximum, and fragmented plaintext.
- [ ] Test tampered ciphertext, nonce, tag, and associated data.
- [ ] Test replayed ciphertext.
- [ ] Test skipped, duplicated, and out-of-order transport messages.
- [ ] Test send and receive nonce progression.
- [ ] Test nonce exhaustion and counter-overflow behavior.
- [ ] Test rekey thresholds, successful rekey, failed rekey, and simultaneous
rekey.
- [ ] Test session reset and destruction zeroize or discard sensitive state as
designed.
- [ ] Test handshake timeouts and stale generation leases with fake time.
- [ ] Test simultaneous initiator tie-breaking across a larger peer matrix.
- [ ] Test process restart with and without persisted identity.
#### Identity and downgrade protection
- [ ] Test peer-ID derivation for valid and malformed static keys.
- [ ] Test signing-key rotation with authorized and unauthorized announcements.
- [ ] Test private-media capability pinning across restart.
- [ ] Test downgrade attempts after a capability has been pinned.
- [ ] Test corrupted, missing, partially written, and legacy identity storage.
- [ ] Test atomic clearing of identity, capability, and peer mappings.
- [ ] Test verification fingerprints remain stable for unchanged identities.
- [ ] Test identity replacement does not expose an established session before
authentication completes.
### Acceptance criteria
- [ ] Known vectors pass independently of Android storage and services.
- [ ] Replay, tampering, downgrade, and identity-substitution tests all fail
closed.
- [ ] All timeouts and rekey tests use fake time.
- [ ] No sensitive test fixtures contain production keys or user data.
---
## Milestone 4: BLE, Wi-Fi Aware, and transport lifecycle testing
**Status:** Not started
**Progress:** 0%
### Goal
Verify connection state machines and packet delivery across unreliable Android
transports without requiring real radios for the majority of cases.
### TODO checklist
#### BLE discovery and connection
- [ ] Test scan start, stop, restart, and failure callbacks.
- [ ] Test advertising start, stop, restart, and failure callbacks.
- [ ] Test Bluetooth-off and Bluetooth-on recovery.
- [ ] Test duplicate scan results and rapidly changing peer addresses.
- [ ] Test connection success, rejection, timeout, and cancellation.
- [ ] Test simultaneous inbound and outbound connection races.
- [ ] Test canonical connection selection and duplicate-link teardown.
- [ ] Test service discovery failure and missing characteristics.
- [ ] Test GATT disconnect during discovery, negotiation, read, and write.
- [ ] Test reconnect backoff with fake time.
- [ ] Test maximum-connection enforcement and eviction policy.
- [ ] Test RSSI thresholds and power-mode transitions.
#### Packet transfer
- [ ] Test MTU negotiation at minimum, normal, and maximum values.
- [ ] Test partial writes and write callbacks delivered out of order.
- [ ] Test notification subscription and notification failure.
- [ ] Test queue backpressure and bounded memory use.
- [ ] Test fragmentation and reassembly across disconnect/reconnect.
- [ ] Test duplicate, missing, reordered, and corrupted fragments.
- [ ] Test cancellation cleans pending queues and transfer state.
- [ ] Test large file/media transfers under constrained MTU.
- [ ] Test broadcast and directed packet delivery.
- [ ] Test packet relay while one link disconnects.
#### Wi-Fi Aware
- [ ] Test feature unavailable and permission-denied behavior.
- [ ] Test publish/subscribe session creation and teardown.
- [ ] Test provisional link authentication and canonical promotion.
- [ ] Test socket replacement and stale-socket rejection.
- [ ] Test partial reads, writes, EOF, exceptions, and cancellation.
- [ ] Test reconnect and rediscovery.
- [ ] Test coexistence with BLE for the same peer.
#### Unified transport behavior
- [ ] Test peer-list union and removal across transports.
- [ ] Test preferred-transport selection.
- [ ] Test transparent failover between BLE and Wi-Fi Aware.
- [ ] Test duplicate packet suppression across transports.
- [ ] Test transport bridge TTL decrement and loop prevention.
- [ ] Test shutdown cancels all jobs, scans, advertisements, sockets, and queues.
### Acceptance criteria
- [ ] Transport state-machine tests run deterministically on the JVM or
Robolectric.
- [ ] Disconnect and cancellation tests leave no queued work or active jobs.
- [ ] Cross-transport duplicate delivery and loops are prevented.
- [ ] A smaller physical-device suite confirms the fake adapters match Android
behavior.
---
## Milestone 5: Sync, routing, and store-and-forward testing
**Status:** Not started
**Progress:** 0%
### Goal
Verify eventual delivery, bounded resource usage, correct routing, and duplicate
suppression during partitions, topology changes, and reconnects.
### TODO checklist
#### Packet identity and sync filters
- [ ] Add more packet-ID vectors for every message type.
- [ ] Prove TTL, route, recipient, and signature mutations do not change sync
identity.
- [ ] Prove payload, sender, timestamp, and type mutations do change identity.
- [ ] Property-test GCS encode/decode membership.
- [ ] Test empty, singleton, maximum-capacity, duplicate, and collision-heavy
filters.
- [ ] Test false-positive behavior statistically against configured tolerances.
- [ ] Test maximum accepted filter bytes and malicious bitstreams.
- [ ] Test sync requests with unknown TLVs and future capability extensions.
#### Store and forward
- [ ] Test caching decisions for public, private, favorite, and offline peers.
- [ ] Test cache capacity and deterministic eviction.
- [ ] Test cache expiry with fake time.
- [ ] Test delivery acknowledgement removal.
- [ ] Test retransmission after reconnect.
- [ ] Test duplicate acknowledgements and late acknowledgements.
- [ ] Test process restart persistence policy.
- [ ] Test shutdown and cleanup under active delivery.
- [ ] Test memory bounds under repeated undeliverable messages.
#### Routing and topology
- [ ] Test shortest paths for disconnected, cyclic, diamond, and changing graphs.
- [ ] Test deterministic tie-breaking for equal-length routes.
- [ ] Test only confirmed edges are used.
- [ ] Test edge expiry and peer disappearance with fake time.
- [ ] Test a route invalidated between planning and send.
- [ ] Test relay TTL exhaustion at every hop.
- [ ] Test source-route loop rejection.
- [ ] Test broadcast storm suppression.
- [ ] Test delivery across mixed BLE and Wi-Fi Aware paths.
- [ ] Test graph updates while sync and relay operations run concurrently.
### Acceptance criteria
- [ ] Partition/reconnect scenarios eventually deliver exactly once at the
application layer.
- [ ] Cache, graph, and filter resource bounds are enforced.
- [ ] No topology or bridge scenario produces an infinite relay loop.
- [ ] All expiry behavior uses fake time.
---
## Milestone 6: Nostr and Tor integration testing
**Status:** Not started
**Progress:** 0%
### Goal
Verify relay communication, subscriptions, event validation, NIP-17 delivery,
and Tor-mode behavior under realistic network failures.
### TODO checklist
#### Relay protocol
- [ ] Add a scripted local WebSocket relay fixture.
- [ ] Test initial connection and clean disconnect.
- [ ] Test DNS, TCP, TLS, WebSocket, and protocol failures.
- [ ] Test reconnect backoff and cancellation with fake time.
- [ ] Test relay notices, acknowledgements, end-of-stored-events, and malformed
messages.
- [ ] Test subscription creation, replacement, unsubscribe, and reconnect
restoration.
- [ ] Test duplicate, delayed, reordered, and conflicting events.
- [ ] Test multi-relay publish success, partial success, and total failure.
- [ ] Test event deduplication across relays.
- [ ] Test relay-list selection and invalid relay URLs.
#### Event security and messaging
- [ ] Add external NIP-01, NIP-13, NIP-17, and NIP-44 vectors.
- [ ] Test invalid event IDs and signatures are rejected before dispatch.
- [ ] Test future timestamps, stale timestamps, and integer boundaries.
- [ ] Test NIP-17 gift-wrap signer/rumor identity mismatches.
- [ ] Test malformed seals, wrong recipients, and tampered ciphertext.
- [ ] Test private-message and acknowledgement embedding/extraction.
- [ ] Test geohash note, presence, and ephemeral-event filters.
- [ ] Test nickname and teleport tags.
- [ ] Test proof-of-work policy at exact difficulty boundaries.
- [ ] Test cancellation and bounded mining iterations.
#### Tor behavior
- [ ] Add a fake Tor-state provider and proxy-selection tests.
- [ ] Test direct, Tor-only, and fallback modes.
- [ ] Test bootstrap delay, bootstrap failure, proxy failure, and shutdown.
- [ ] Verify Tor-only mode never silently uses a direct connection.
- [ ] Verify mode changes rebuild clients and close old connections.
### Acceptance criteria
- [ ] Nostr integration tests require no public relay or internet connection.
- [ ] Invalid or unauthenticated events never reach application state.
- [ ] Reconnect restores intended subscriptions without duplicate delivery.
- [ ] Tor-only policy fails closed.
---
## Milestone 7: Android lifecycle and permission testing
**Status:** Not started
**Progress:** 0%
### Goal
Verify the app behaves correctly under Android process, service, permission,
Bluetooth, battery, and background-execution rules.
### TODO checklist
#### Foreground service
- [ ] Add Robolectric tests for service create, start, bind, unbind, and destroy.
- [ ] Test repeated start commands are idempotent.
- [ ] Test foreground notification creation and channel configuration.
- [ ] Test explicit shutdown clears transport and application state correctly.
- [ ] Test unexpected process/service recreation restores required state.
- [ ] Test task removal behavior.
- [ ] Test boot-completed handling.
- [ ] Test service start restrictions and failure reporting.
- [ ] Test all coroutines and resources are cancelled on destroy.
#### Permissions and system state
- [ ] Test first-run permission explanations.
- [ ] Test denial, permanent denial, and later grant.
- [ ] Test partial Bluetooth permission grants by Android version.
- [ ] Test location-disabled and Bluetooth-disabled states.
- [ ] Test notification permission denial.
- [ ] Test microphone permission denial during voice recording.
- [ ] Test background-location preferences where applicable.
- [ ] Test battery-optimization accepted, declined, and unavailable paths.
- [ ] Test configuration changes during onboarding.
- [ ] Test onboarding restoration after process recreation.
#### Android-version matrix
- [ ] Define minimum, target, and newest-supported API test matrix.
- [ ] Add emulator coverage for behavior changes in permissions and foreground
services.
- [ ] Add at least one low-memory/process-death scenario.
- [ ] Add manufacturer-device coverage for known BLE/background differences.
### Acceptance criteria
- [ ] Critical service and permission flows have Robolectric or instrumented
coverage.
- [ ] No permission denial crashes or leaves onboarding irrecoverable.
- [ ] Service recreation does not duplicate transports or lose required state.
- [ ] Required API-level matrix passes before release.
---
## Milestone 8: Persistence, migration, and recovery testing
**Status:** Not started
**Progress:** 0%
### Goal
Ensure identities, settings, aliases, favorites, bookmarks, messages, and
capability pins survive upgrades and fail safely when storage is incomplete or
corrupt.
### TODO checklist
- [ ] Inventory every persisted key, file, schema, and version marker.
- [ ] Create legacy fixtures for every supported application version.
- [ ] Test clean first launch with no persisted state.
- [ ] Test upgrade from each retained legacy fixture.
- [ ] Test unknown future fields are preserved or ignored safely.
- [ ] Test truncated, malformed, empty, and type-mismatched preference values.
- [ ] Test partial multi-key identity writes.
- [ ] Test storage write failure and rollback.
- [ ] Test concurrent readers and writers.
- [ ] Test alias merging and canonical conversation migration.
- [ ] Test chronological ordering after migration.
- [ ] Test favorite and bookmark preservation.
- [ ] Test message-retention expiry with fake time.
- [ ] Test secure identity clearing removes all linked mappings and pins.
- [ ] Test signing-key and capability rotation is atomic.
- [ ] Test backup/restore policy does not duplicate or expose sensitive identity
material.
- [ ] Test migration idempotence by running each migration twice.
- [ ] Test downgrade behavior when a newer schema has already been written.
### Acceptance criteria
- [ ] Every supported legacy fixture migrates deterministically.
- [ ] Failed migrations leave either the old valid state or the new valid state,
never a partial mixture.
- [ ] Security-sensitive corruption fails closed with a recoverable user path.
- [ ] Migration and retention behavior uses deterministic storage and time.
---
## Milestone 9: UI, media, and accessibility testing
**Status:** Not started
**Progress:** 0%
### Goal
Protect user-visible behavior and media workflows while keeping most assertions
at ViewModel/state boundaries and reserving Compose instrumentation for genuine
interaction and rendering contracts.
### TODO checklist
#### ViewModels and application state
- [ ] Restore or replace the currently skipped command-processor tests.
- [ ] Restore or replace the currently skipped notification tests.
- [ ] Test public/private/channel conversation switching.
- [ ] Test optimistic send, success, failure, retry, and cancellation.
- [ ] Test delivery and read-receipt transitions.
- [ ] Test peer arrival, departure, alias change, and identity rotation.
- [ ] Test state restoration after configuration change and process recreation.
- [ ] Test concurrent inbound messages while changing conversations.
- [ ] Test error messages for permission, transport, storage, and crypto failures.
#### Compose UI
- [ ] Add semantics tests for critical chat actions.
- [ ] Test onboarding navigation and recoverability.
- [ ] Test empty, loading, connected, disconnected, and error states.
- [ ] Test long nicknames, messages, channels, and localized text.
- [ ] Test dynamic font sizes and display scaling.
- [ ] Test light, dark, and supported theme variants.
- [ ] Add screenshot tests only for stable high-value layouts.
- [ ] Test keyboard, focus, back navigation, and bottom-sheet behavior.
- [ ] Test screen-reader labels, traversal order, and minimum touch targets.
- [ ] Test reduced-motion behavior where animations are nonessential.
#### Files, images, and voice
- [ ] Test zero-byte, small, maximum-size, and oversized files.
- [ ] Test unsupported and misleading MIME types.
- [ ] Test missing filenames and Unicode filenames.
- [ ] Test file read/write failures and insufficient storage.
- [ ] Test image decode failures, orientation metadata, and large-image memory
limits.
- [ ] Test voice-recording start, pause/stop, cancellation, and microphone loss.
- [ ] Test corrupt and unsupported audio playback.
- [ ] Test waveform generation boundaries.
- [ ] Test interrupted private-media preparation and commit rollback.
- [ ] Test cleanup of temporary files after success, failure, and cancellation.
### Acceptance criteria
- [ ] Critical user journeys pass through state-level tests.
- [ ] A focused Compose suite protects navigation, semantics, and accessibility.
- [ ] Media failures are visible, recoverable, and leak no temporary resources.
- [ ] Previously skipped UI-related tests are either active or replaced with
equivalent coverage.
---
## Milestone 10: Physical-device and cross-client release gate
**Status:** Not started
**Progress:** 0%
### Goal
Validate the behavior that JVM, Robolectric, and emulator tests cannot prove:
real radios, background limits, device interoperability, and compatibility with
released clients.
### TODO checklist
#### Device matrix and harness
- [ ] Define a minimum physical-device matrix covering at least two Android API
levels and two manufacturers.
- [ ] Include devices supporting BLE only and BLE plus Wi-Fi Aware where
available.
- [ ] Build a test control channel that does not interfere with mesh transport.
- [ ] Capture structured traces, packet IDs, connection transitions, and failure
reasons.
- [ ] Make test accounts, identities, and files disposable and non-personal.
- [ ] Provide deterministic scenario setup and cleanup.
#### Android-to-Android scenarios
- [ ] Discover, connect, exchange announcements, disconnect, and reconnect.
- [ ] Send public and private messages in both directions.
- [ ] Verify delivery and read receipts.
- [ ] Transfer image, audio, and generic files at multiple sizes.
- [ ] Relay across at least three devices.
- [ ] Partition the mesh and verify store-and-forward delivery after reconnect.
- [ ] Disable and re-enable Bluetooth during active transfers.
- [ ] Lock screens and background both apps during active mesh operation.
- [ ] Kill and recreate one process.
- [ ] Exercise simultaneous connections and duplicate-link resolution.
- [ ] Exercise Wi-Fi Aware failover where supported.
#### Cross-client and backward compatibility
- [ ] Test current Android against the current iOS client.
- [ ] Test the rewrite against the last supported Android release.
- [ ] Test legacy announcements without capabilities.
- [ ] Test current capability announcements with an older client.
- [ ] Test canonical private-media type and decode-only prerelease alias.
- [ ] Compare packet, message, identity, fragment, sync, and file golden vectors
across implementations.
- [ ] Verify malformed and unauthenticated inputs are rejected consistently.
- [ ] Verify Nostr fallback messages and receipts across clients.
#### Background and endurance
- [ ] Run a multi-hour discovery/connect/disconnect soak test.
- [ ] Run repeated large-transfer and cancellation cycles.
- [ ] Monitor memory, threads, file descriptors, wake locks, and battery impact.
- [ ] Test foreground-service survival with screens off.
- [ ] Test network and Tor availability changes during Nostr operation.
- [ ] Confirm shutdown releases radios, sockets, jobs, and wake locks.
### Acceptance criteria
- [ ] All mandatory scenarios pass on the defined device matrix.
- [ ] Android/iOS and old/new clients exchange every supported critical payload.
- [ ] No endurance run shows unbounded growth or leaked resources.
- [ ] Failures produce sufficient traces for deterministic reproduction where
possible.
- [ ] Release approval records the client versions, device matrix, and results.
---
## CI rollout
### Pull-request gate
- [ ] Run formatting and static analysis.
- [ ] Run deterministic JVM unit tests.
- [ ] Run bounded property/fuzz tests.
- [ ] Run stable Robolectric tests.
- [ ] Run `clientRewriteContractTest`.
- [ ] Upload JUnit and coverage reports.
- [ ] Reject new failures, errors, or unexpected skips.
- [ ] Reject golden-vector changes without the protocol-change review label.
### Main and nightly gate
- [ ] Run the extended fuzz corpus.
- [ ] Run emulator instrumented tests.
- [ ] Run local relay/Tor integration tests.
- [ ] Run physical-device smoke tests when the lab is available.
- [ ] Track runtime, flakes, coverage, and quarantined tests.
### Release-candidate gate
- [ ] Run the complete device matrix.
- [ ] Run Android/iOS and old/new interoperability.
- [ ] Run endurance and background scenarios.
- [ ] Review all skipped or quarantined tests.
- [ ] Archive coverage, test, trace, and version metadata with the release.
## Progress update procedure
When work lands:
1. Check completed TODOs in the relevant milestone.
2. Update the milestone percentage based on completed checklist items.
3. Change status to **In progress** when its first TODO is complete.
4. Change status to **Complete** only when all acceptance criteria are met.
5. Update the top-level progress table and milestone completion count.
6. Link the implementing pull request or commit next to material completed work
without including personal information.
7. Record intentionally deferred items and their justification; do not mark them
complete.
## Final definition of done
The test program is complete when:
- [ ] Milestones 0–10 meet every acceptance criterion.
- [ ] Project and package-level line/branch coverage no longer regress.
- [ ] All critical parsers have adversarial and fuzz coverage.
- [ ] All security-sensitive state transitions fail closed under tampering,
replay, downgrade, and corruption.
- [ ] Transport, sync, and lifecycle tests cover disconnection, cancellation,
timeout, and restart.
- [ ] Physical-device and cross-client scenarios pass for every release.
- [ ] The full rewrite can replace the existing implementation while preserving
the unchanged compatibility and acceptance tests.

View File

@ -0,0 +1,90 @@
# Testing conventions
## Purpose
These conventions keep the client-rewrite suite deterministic, reproducible,
and portable across implementations.
## Test locations
| Test type | Location | Naming |
|---|---|---|
| JVM unit and contract tests | `app/src/test/` | `*Test.kt` |
| Shared deterministic fakes and fixtures | `app/src/test/**/testsupport/` | Descriptive fixture name |
| Robolectric tests | `app/src/test/` | `*RobolectricTest.kt` |
| Android instrumented tests | `app/src/androidTest/` | `*InstrumentedTest.kt` |
| Coverage-tool tests | `tools/coverage/` | `test_*.py` |
| Interoperability fixtures | `app/src/test/resources/contracts/` | Protocol and version in filename |
## Required behavior
- Tests must not use arbitrary sleeps. Advance a fake clock or coroutine test
scheduler instead.
- Tests must not require public relays, internet access, Bluetooth hardware, or a
user's persisted data.
- Time, randomness, dispatchers, storage, and transports must be injectable in
code exercised by state-machine tests.
- Randomized failures must print a reproduction seed. Use `TEST_SEED` for a
specific replay.
- Mutable byte arrays returned by fixtures and fakes must be defensively copied.
- Negative security tests must assert fail-closed behavior.
- Protocol round trips must be paired with literal golden vectors for critical
externally visible formats.
- Asynchronous tests must have a deterministic completion condition and a
bounded timeout.
- A fixed bug must retain its smallest reproducing input as a regression test.
## Naming
Test names should describe observable behavior:
```kotlin
@Test
fun `replayed ciphertext is rejected without advancing receive state`() {
// ...
}
```
Avoid names tied to private methods or temporary implementation structure.
## Fixtures and seeds
Reusable Kotlin fixtures live under
`com.bitchat.android.testsupport`. `ReproducibleTestSeed` resolves
`TEST_SEED` and provides a reproduction hint:
```sh
TEST_SEED=12345 ./gradlew clientRewriteContractTest
```
Never use production keys, contact information, messages, or other user data in
fixtures.
## Coverage
Run the full report and non-regression floor:
```sh
./gradlew clientRewriteContractTest
```
Reports are written to:
- `app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml`
- `app/build/reports/jacoco/jacocoTestReport/html/`
Check executable production lines changed from the base branch:
```sh
COVERAGE_BASE_REF=origin/main ./gradlew checkChangedLineCoverage
```
Generated resource classes, Compose-generated singleton classes, platform
bridges, and vendored Noise code are excluded from first-party coverage metrics.
## Quarantine and skips
- A flaky test must be fixed, not silently retried.
- A temporary quarantine must include an issue and removal condition.
- Unexpected skips fail review. Existing skips must be restored or replaced by
equivalent coverage.

1
tools/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Repository-local verification tooling."""

View File

@ -0,0 +1 @@
"""Coverage verification helpers."""

View File

@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Enforce JaCoCo coverage for executable Kotlin/Java lines changed from a Git base."""
from __future__ import annotations
import argparse
import pathlib
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass
SOURCE_ROOTS = (
"app/src/main/java/",
"app/src/main/kotlin/",
)
@dataclass(frozen=True)
class CoverageLine:
missed_instructions: int
covered_instructions: int
@property
def covered(self) -> bool:
return self.covered_instructions > 0
def parse_jacoco(xml_path: pathlib.Path) -> dict[tuple[str, int], CoverageLine]:
root = ET.parse(xml_path).getroot()
result: dict[tuple[str, int], CoverageLine] = {}
for package in root.findall("package"):
package_name = package.attrib["name"]
for source_file in package.findall("sourcefile"):
relative_path = f"{package_name}/{source_file.attrib['name']}"
for line in source_file.findall("line"):
result[(relative_path, int(line.attrib["nr"]))] = CoverageLine(
missed_instructions=int(line.attrib.get("mi", "0")),
covered_instructions=int(line.attrib.get("ci", "0")),
)
return result
def parse_changed_lines(diff_text: str) -> dict[str, set[int]]:
changed: dict[str, set[int]] = {}
current_path: str | None = None
for raw_line in diff_text.splitlines():
if raw_line.startswith("+++ b/"):
current_path = raw_line[6:]
continue
if raw_line.startswith("+++ /dev/null"):
current_path = None
continue
if not raw_line.startswith("@@") or current_path is None:
continue
match = re.search(r"\+(\d+)(?:,(\d+))?", raw_line)
if match is None:
continue
start = int(match.group(1))
count = int(match.group(2) or "1")
if count > 0:
changed.setdefault(current_path, set()).update(range(start, start + count))
return changed
def jacoco_relative_path(repository_path: str) -> str | None:
for root in SOURCE_ROOTS:
if repository_path.startswith(root):
return repository_path[len(root) :]
return None
def git_diff_command(base: str) -> list[str]:
return [
"git",
"diff",
# Reformatting an executable line without changing its tokens must not turn an otherwise
# covered change set into a coverage failure.
"--ignore-all-space",
"--unified=0",
"--diff-filter=AM",
base,
"--",
*SOURCE_ROOTS,
]
def git_diff(base: str) -> str:
command = git_diff_command(base)
result = subprocess.run(command, check=True, capture_output=True, text=True)
return result.stdout
def evaluate(
changed: dict[str, set[int]],
coverage: dict[tuple[str, int], CoverageLine],
) -> tuple[int, int, list[str]]:
executable = 0
covered = 0
missed: list[str] = []
for repository_path, line_numbers in sorted(changed.items()):
source_path = jacoco_relative_path(repository_path)
if source_path is None:
continue
for line_number in sorted(line_numbers):
line = coverage.get((source_path, line_number))
if line is None:
continue
executable += 1
if line.covered:
covered += 1
else:
missed.append(f"{repository_path}:{line_number}")
return covered, executable, missed
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--xml", type=pathlib.Path, required=True)
parser.add_argument("--base", required=True)
parser.add_argument("--threshold", type=float, default=0.80)
return parser.parse_args()
def main() -> int:
args = parse_args()
if not 0.0 <= args.threshold <= 1.0:
raise SystemExit("--threshold must be between 0 and 1")
if not args.xml.is_file():
raise SystemExit(f"JaCoCo XML report not found: {args.xml}")
coverage = parse_jacoco(args.xml)
changed = parse_changed_lines(git_diff(args.base))
covered, executable, missed = evaluate(changed, coverage)
ratio = 1.0 if executable == 0 else covered / executable
print(
f"Changed executable line coverage: {covered}/{executable} "
f"({ratio:.1%}), required {args.threshold:.1%}"
)
if ratio >= args.threshold:
return 0
for location in missed[:50]:
print(f"UNCOVERED {location}")
if len(missed) > 50:
print(f"... and {len(missed) - 50} more uncovered executable lines")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,85 @@
import pathlib
import tempfile
import unittest
from tools.coverage.check_changed_coverage import (
CoverageLine,
evaluate,
git_diff_command,
jacoco_relative_path,
parse_changed_lines,
parse_jacoco,
)
class ChangedCoverageToolTest(unittest.TestCase):
def test_parses_added_and_modified_hunks(self) -> None:
diff = """\
diff --git a/app/src/main/java/example/Thing.kt b/app/src/main/java/example/Thing.kt
--- a/app/src/main/java/example/Thing.kt
+++ b/app/src/main/java/example/Thing.kt
@@ -1,0 +2,3 @@
+a
+b
+c
@@ -9 +12 @@
-old
+new
"""
self.assertEqual(
{"app/src/main/java/example/Thing.kt": {2, 3, 4, 12}},
parse_changed_lines(diff),
)
def test_parses_jacoco_source_lines(self) -> None:
xml = """\
<report name="test">
<package name="example">
<sourcefile name="Thing.kt">
<line nr="2" mi="0" ci="3"/>
<line nr="3" mi="2" ci="0"/>
</sourcefile>
</package>
</report>
"""
with tempfile.TemporaryDirectory() as directory:
path = pathlib.Path(directory) / "report.xml"
path.write_text(xml, encoding="utf-8")
parsed = parse_jacoco(path)
self.assertTrue(parsed[("example/Thing.kt", 2)].covered)
self.assertFalse(parsed[("example/Thing.kt", 3)].covered)
def test_evaluates_only_executable_changed_lines(self) -> None:
changed = {"app/src/main/java/example/Thing.kt": {1, 2, 3}}
coverage = {
("example/Thing.kt", 2): CoverageLine(0, 1),
("example/Thing.kt", 3): CoverageLine(1, 0),
}
self.assertEqual(
(1, 2, ["app/src/main/java/example/Thing.kt:3"]),
evaluate(changed, coverage),
)
def test_maps_both_supported_source_roots(self) -> None:
self.assertEqual(
"example/Thing.kt",
jacoco_relative_path("app/src/main/java/example/Thing.kt"),
)
self.assertEqual(
"example/Thing.kt",
jacoco_relative_path("app/src/main/kotlin/example/Thing.kt"),
)
self.assertIsNone(jacoco_relative_path("app/src/test/example/Thing.kt"))
def test_changed_line_diff_ignores_formatting_only_edits(self) -> None:
command = git_diff_command("origin/main")
self.assertIn("--ignore-all-space", command)
self.assertIn("--unified=0", command)
self.assertEqual("origin/main", command[command.index("--diff-filter=AM") + 1])
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1 @@
"""Physical-device and cross-client release-gate tooling."""

View File

@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""USB/ADB control helpers for the physical release gate.
Device selectors are accepted only as ephemeral command inputs. They are never
printed or written to artifacts; all output uses the operator-assigned alias.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Callable
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
if str(REPOSITORY_ROOT) not in sys.path:
sys.path.insert(0, str(REPOSITORY_ROOT))
from tools.release_gate.release_gate import (
GateError,
SAFE_ID_RE,
append_trace_event,
)
APPLICATION_ID = "com.bitchat.droid"
def find_adb() -> str:
direct = shutil.which("adb")
if direct:
return direct
android_home = os.environ.get("ANDROID_HOME")
if android_home:
candidate = Path(android_home) / "platform-tools" / "adb"
if candidate.is_file():
return str(candidate)
raise GateError("adb was not found; set ANDROID_HOME or add adb to PATH")
def run_adb(
serial: str,
arguments: list[str],
*,
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
) -> str:
result = runner(
[find_adb(), "-s", serial, *arguments],
check=False,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
raise GateError("ADB command failed for the selected logical device")
return result.stdout.strip()
def count_connected_devices(
*,
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
) -> int:
result = runner(
[find_adb(), "devices"],
check=False,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
raise GateError("could not enumerate ADB devices")
return sum(
1
for line in result.stdout.splitlines()[1:]
if line.strip().endswith("\tdevice")
)
def probe_device(serial: str, alias: str) -> dict[str, object]:
if not SAFE_ID_RE.fullmatch(alias):
raise GateError("device alias must be a lowercase logical identifier")
api_text = run_adb(serial, ["shell", "getprop", "ro.build.version.sdk"])
if not api_text.isdigit():
raise GateError("selected device returned an invalid API level")
features = run_adb(serial, ["shell", "pm", "list", "features"])
manufacturer = run_adb(
serial, ["shell", "getprop", "ro.product.manufacturer"]
).strip().lower()
model = run_adb(serial, ["shell", "getprop", "ro.product.model"]).strip()
capabilities = ["ble-central", "ble-peripheral"]
if "android.hardware.wifi.aware" in features:
capabilities.append("wifi-aware")
return {
"alias": alias,
"platform": "android",
"model": model,
"manufacturer_class": re.sub(r"[^a-z0-9._-]", "-", manufacturer)[:64],
"api_level": int(api_text),
"physical": True,
"capabilities": capabilities,
}
def prepare_disposable_device(serial: str, confirmed: bool) -> None:
if not confirmed:
raise GateError("prepare requires --confirm-disposable-app-data")
run_adb(serial, ["shell", "am", "force-stop", APPLICATION_ID])
output = run_adb(serial, ["shell", "pm", "clear", APPLICATION_ID])
if "Success" not in output:
raise GateError("could not clear disposable app data")
def collect_resource_snapshot(serial: str) -> dict[str, int | bool]:
pid_text = run_adb(serial, ["shell", "pidof", APPLICATION_ID])
pid = pid_text.split()[0] if pid_text else ""
metrics: dict[str, int | bool] = {"process-running": bool(pid)}
if not pid.isdigit():
return metrics
meminfo = run_adb(serial, ["shell", "dumpsys", "meminfo", APPLICATION_ID])
total_match = re.search(r"TOTAL\s+(\d+)", meminfo)
metrics["total-pss-kb"] = int(total_match.group(1)) if total_match else -1
thread_text = run_adb(
serial,
["shell", "sh", "-c", f"find /proc/{pid}/task -mindepth 1 -maxdepth 1 | wc -l"],
)
fd_text = run_adb(
serial,
["shell", "sh", "-c", f"find /proc/{pid}/fd -mindepth 1 -maxdepth 1 | wc -l"],
)
metrics["thread-count"] = int(thread_text) if thread_text.isdigit() else -1
metrics["fd-count"] = int(fd_text) if fd_text.isdigit() else -1
power = run_adb(serial, ["shell", "dumpsys", "power"])
metrics["app-wakelock-count"] = sum(
1
for line in power.splitlines()
if APPLICATION_ID in line and "WakeLock" in line
)
battery = run_adb(serial, ["shell", "dumpsys", "battery"])
battery_level = re.search(r"^\s*level:\s*(\d+)", battery, re.MULTILINE)
metrics["battery-level-percent"] = (
int(battery_level.group(1)) if battery_level else -1
)
return metrics
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("count")
probe = commands.add_parser("probe")
probe.add_argument("--serial", required=True, help=argparse.SUPPRESS)
probe.add_argument("--alias", required=True)
prepare = commands.add_parser("prepare")
prepare.add_argument("--serial", required=True, help=argparse.SUPPRESS)
prepare.add_argument("--confirm-disposable-app-data", action="store_true")
cleanup = commands.add_parser("cleanup")
cleanup.add_argument("--serial", required=True, help=argparse.SUPPRESS)
cleanup.add_argument("--confirm-disposable-app-data", action="store_true")
snapshot = commands.add_parser("snapshot")
snapshot.add_argument("--serial", required=True, help=argparse.SUPPRESS)
snapshot.add_argument("--alias", required=True)
snapshot.add_argument("--run", type=Path, required=True)
snapshot.add_argument("--scenario", required=True)
snapshot.add_argument("--event", default="resource-snapshot")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.command == "count":
print(json.dumps({"authorized-device-count": count_connected_devices()}))
elif args.command == "probe":
print(json.dumps(probe_device(args.serial, args.alias), sort_keys=True))
elif args.command in {"prepare", "cleanup"}:
prepare_disposable_device(
args.serial, args.confirm_disposable_app_data
)
print(json.dumps({"status": "clean", "application": APPLICATION_ID}))
elif args.command == "snapshot":
metrics = collect_resource_snapshot(args.serial)
append_trace_event(
args.run,
args.scenario,
args.alias,
args.event,
"observed",
None,
metrics,
)
print(json.dumps({"source_alias": args.alias, "metrics": metrics}, sort_keys=True))
return 0
except (GateError, OSError, subprocess.SubprocessError) as error:
print(f"android lab error: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())

Some files were not shown because too many files have changed in this diff Show More