Merge remote-tracking branch 'origin/main' into opus/redesign-proposal

# Conflicts:
#	app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt
#	app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt
This commit is contained in:
callebtc 2026-07-27 17:28:28 +02:00
commit 823d1ab07c
52 changed files with 5791 additions and 530 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

@ -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

@ -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

@ -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 kotlin.math.min
@ -97,14 +99,20 @@ 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 liveLocationToken: Long? = null
)
// Event deduplication system
private val eventDeduplicator = NostrEventDeduplicator.getInstance()
// Message queue for reliability
private val messageQueue = mutableListOf<Pair<NostrEvent, List<String>>>()
private data class QueuedEvent(
val event: NostrEvent,
val targetRelays: List<String>,
val liveLocationToken: Long? = null
)
private val messageQueue = mutableListOf<QueuedEvent>()
private val messageQueueLock = Any()
// Coroutine scope for background operations
@ -122,27 +130,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")
}
}
@ -162,40 +192,135 @@ class NostrRelayManager private constructor() {
id: String = generateSubscriptionId(),
handler: (NostrEvent) -> Unit,
includeDefaults: Boolean = false,
nRelays: Int = 5
nRelays: Int = 5,
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
).also {
// update origin geohash for this subscription
activeSubscriptions[it]?.let { sub ->
activeSubscriptions[it] = sub.copy(originGeohash = geohash)
}
}
targetRelayUrls = relayUrls,
liveLocationToken = liveLocationToken
)
}
/**
* 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 }) {
@ -204,15 +329,22 @@ class NostrRelayManager private constructor() {
}
updateRelaysList()
scope.launch {
val job = scope.launch {
if (!isNetworkActionAllowed(liveLocationToken)) return@launch
relayUrls.forEach { relayUrl ->
launch {
if (!connections.containsKey(relayUrl)) {
connectToRelay(relayUrl)
if (!connections.containsKey(relayUrl) &&
isNetworkActionAllowed(liveLocationToken)
) {
connectToRelay(relayUrl, liveLocationToken)
}
}
}
}
if (liveLocationToken != null) {
liveLocationConnectionJobs.add(job)
job.invokeOnCompletion { liveLocationConnectionJobs.remove(job) }
}
}
init {
@ -225,8 +357,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
@ -239,12 +373,14 @@ class NostrRelayManager private constructor() {
* Connect to all configured relays
*/
fun connect() {
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)
}
}
}
}
@ -257,8 +393,6 @@ class NostrRelayManager private constructor() {
* Disconnect from all relays
*/
fun disconnect() {
Log.i(TAG, "Disconnecting from all Nostr relays")
// Stop subscription validation
stopSubscriptionValidation()
@ -276,23 +410,28 @@ 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) {
messageQueue.add(Pair(event, targetRelays))
}
// Attempt immediate send
scope.launch {
targetRelays.forEach { relayUrl ->
val webSocket = connections[relayUrl]
if (webSocket != null) {
sendToRelay(event, webSocket, relayUrl)
val queued = runNetworkAction(liveLocationToken) {
synchronized(messageQueueLock) {
messageQueue.add(QueuedEvent(event, targetRelays, liveLocationToken))
}
scope.launch {
if (!isNetworkActionAllowed(liveLocationToken)) return@launch
targetRelays.forEach { relayUrl ->
val webSocket = connections[relayUrl]
if (webSocket != null) {
sendToRelay(event, webSocket, relayUrl, liveLocationToken)
}
}
}
}
if (!queued) return
}
/**
@ -303,21 +442,22 @@ class NostrRelayManager private constructor() {
filter: NostrFilter,
id: String = generateSubscriptionId(),
handler: (NostrEvent) -> Unit,
targetRelayUrls: List<String>? = null
targetRelayUrls: List<String>? = null,
liveLocationToken: Long? = null
): String {
// Store subscription info for persistent tracking
val subscriptionInfo = SubscriptionInfo(
id = id,
filter = filter,
handler = handler,
targetRelayUrls = targetRelayUrls?.toSet()
targetRelayUrls = targetRelayUrls?.toSet(),
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
}
@ -326,32 +466,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")
}
}
}
@ -365,7 +511,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
}
@ -373,14 +526,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")
}
}
}
@ -392,6 +552,9 @@ class NostrRelayManager private constructor() {
*/
fun retryConnection(relayUrl: String) {
val relay = relaysList.find { it.url == relayUrl } ?: return
val liveToken = liveLocationRelayTokens[relayUrl]
?.takeIf { relayUrl !in nonLiveRelayUrls }
if (!isNetworkActionAllowed(liveToken)) return
// Reset reconnection attempts
relay.reconnectAttempts = 0
@ -403,7 +566,7 @@ class NostrRelayManager private constructor() {
// Attempt immediate reconnection
scope.launch {
connectToRelay(relayUrl)
connectToRelay(relayUrl, liveToken)
}
}
@ -552,7 +715,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) ->
@ -564,7 +727,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)
}
}
@ -574,6 +737,7 @@ class NostrRelayManager private constructor() {
}
}
}
}
/**
@ -586,7 +750,13 @@ class NostrRelayManager private constructor() {
// MARK: - Private Methods
private suspend fun connectToRelay(urlString: String) {
private suspend fun connectToRelay(
urlString: String,
liveLocationToken: Long? = null
) {
val connectionToken = liveLocationToken
?.takeIf { urlString !in nonLiveRelayUrls }
if (!isNetworkActionAllowed(connectionToken)) return
// Skip if we already have a connection
if (connections.containsKey(urlString)) {
return
@ -597,31 +767,45 @@ class NostrRelayManager private constructor() {
.url(urlString)
.build()
val webSocket = httpClient.newWebSocket(request, RelayWebSocketListener(urlString))
connections[urlString] = webSocket
runNetworkAction(connectionToken) {
val webSocket = httpClient.newWebSocket(
request,
RelayWebSocketListener(urlString, connectionToken)
)
connections[urlString] = webSocket
}
} catch (e: Exception) {
Log.e(TAG, "Failed to create WebSocket connection to $urlString: ${e.message}")
handleDisconnection(urlString, e)
Log.e(TAG, "Failed to create WebSocket connection")
handleDisconnection(urlString, e, liveLocationToken)
}
}
private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String) {
private fun sendToRelay(
event: NostrEvent,
webSocket: WebSocket,
relayUrl: String,
liveLocationToken: Long? = null
) {
if (!isNetworkActionAllowed(liveLocationToken)) 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 }
relay?.messagesSent = (relay?.messagesSent ?: 0) + 1
updateRelaysList()
} else {
Log.e(TAG, "Failed to send event to $relayUrl: WebSocket send failed")
Log.e(TAG, "Failed to send event: WebSocket send failed")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send event to $relayUrl: ${e.message}")
Log.e(TAG, "Failed to send event")
}
}
@ -629,7 +813,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
}
@ -643,7 +827,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
@ -657,12 +844,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 -> {
@ -673,29 +863,36 @@ 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, error: Throwable) {
private fun handleDisconnection(
relayUrl: String,
error: Throwable,
liveLocationToken: Long? = null
) {
val connectionToken = liveLocationToken
?.takeIf { relayUrl !in nonLiveRelayUrls }
connections.remove(relayUrl)
// NOTE: Don't remove subscriptions here - keep them for restoration on reconnection
// subscriptions.remove(relayUrl) // REMOVED - this was causing subscription loss
updateRelayStatus(relayUrl, false, error)
if (!isNetworkActionAllowed(connectionToken)) return
// Check if this is a DNS error
val errorMessage = error.message?.lowercase() ?: ""
@ -705,7 +902,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
}
@ -716,7 +913,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
}
@ -728,12 +925,14 @@ 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")
// Schedule reconnection
scope.launch {
delay(backoffInterval)
connectToRelay(relayUrl)
if (isNetworkActionAllowed(connectionToken)) {
connectToRelay(relayUrl, connectionToken)
}
}
}
@ -765,7 +964,7 @@ class NostrRelayManager private constructor() {
}
private fun generateSubscriptionId(): String {
return "sub-${System.currentTimeMillis()}-${(Math.random() * 1000).toInt()}"
return "sub-${UUID.randomUUID()}"
}
/**
@ -774,30 +973,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")
}
}
}
@ -805,10 +1008,17 @@ 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) {
Log.i(TAG, "Connected to Nostr relay: $relayUrl")
if (!isNetworkActionAllowed(liveLocationToken)) {
connections.remove(relayUrl)
webSocket.cancel()
return
}
updateRelayStatus(relayUrl, true)
// Restore all active subscriptions for this relay
@ -818,9 +1028,16 @@ class NostrRelayManager private constructor() {
synchronized(messageQueueLock) {
val iterator = messageQueue.iterator()
while (iterator.hasNext()) {
val (event, targetRelays) = iterator.next()
if (relayUrl in targetRelays) {
sendToRelay(event, webSocket, relayUrl)
val queued = iterator.next()
if (relayUrl in queued.targetRelays &&
isNetworkActionAllowed(queued.liveLocationToken)
) {
sendToRelay(
queued.event,
webSocket,
relayUrl,
queued.liveLocationToken
)
}
}
}
@ -835,14 +1052,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, error)
handleDisconnection(relayUrl, error, liveLocationToken)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "WebSocket failure for $relayUrl: ${t.message}")
handleDisconnection(relayUrl, t)
Log.e(TAG, "Nostr WebSocket failure")
handleDisconnection(relayUrl, 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
@ -28,18 +29,54 @@ class NostrSubscriptionManager(
}
/** 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, filter, id, handler, includeDefaults = false, nRelays = 5)
relayManager.subscribeForGeohash(
geohash,
filter,
id,
handler,
includeDefaults = false,
nRelays = 5,
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, filter, id, handler, includeDefaults = false, nRelays = 5)
relayManager.subscribeForGeohash(
geohash,
filter,
id,
handler,
includeDefaults = false,
nRelays = 5,
liveLocationToken = liveLocationToken
)
}
}

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

@ -354,42 +354,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 repoField = GeohashViewModel::class.java.getDeclaredField("repo")
repoField.isAccessible = true
val repo = repoField.get(geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
val gh = repo.getConversationGeohash(convKey)
if (!gh.isNullOrEmpty()) {
val subMgrField = GeohashViewModel::class.java.getDeclaredField("subscriptionManager")
subMgrField.isAccessible = true
val subMgr = subMgrField.get(geohashViewModel) as com.bitchat.android.nostr.NostrSubscriptionManager
val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(gh, getApplication())
val subId = "geo-dm-$gh"
val currentDmSubField = GeohashViewModel::class.java.getDeclaredField("currentDmSubId")
currentDmSubField.isAccessible = true
val currentId = currentDmSubField.get(geohashViewModel) as String?
if (currentId != subId) {
(currentId)?.let { subMgr.unsubscribe(it) }
currentDmSubField.set(geohashViewModel, subId)
subMgr.subscribeGiftWraps(
pubkey = identity.publicKeyHex,
sinceMs = System.currentTimeMillis() - 172800000L,
id = subId,
handler = { event ->
val dmHandlerField = GeohashViewModel::class.java.getDeclaredField("dmHandler")
dmHandlerField.isAccessible = true
val dmHandler = dmHandlerField.get(geohashViewModel) as com.bitchat.android.nostr.NostrDirectMessageHandler
dmHandler.onGiftWrap(event, gh, identity)
}
)
}
}
} catch (e: Exception) {
Log.w(TAG, "ensureGeohashDMSubscriptionIfNeeded failed: ${e.message}")
}
geohashViewModel.ensureGeohashDMSubscriptionForConversation(convKey)
}
// MARK: - Channel Management (delegated)
@ -955,6 +922,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.
@ -1098,8 +1070,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
)
}
/**

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

@ -8,6 +8,8 @@ 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
@ -18,15 +20,18 @@ import com.bitchat.android.nostr.NostrSubscriptionManager
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.nostr.GeohashConversationRegistry
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import java.util.Date
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.isActive
import kotlinx.coroutines.Dispatchers
import java.security.SecureRandom
import java.util.UUID
import kotlin.random.asKotlinRandom
class GeohashViewModel(
@ -69,10 +74,24 @@ class GeohashViewModel(
// Presence heartbeat firehose (kind 20001). High-volume; paused while backgrounded.
private var currentGeohashPresenceSubId: String? = null
private var currentDmSubId: String? = null
private var currentDmGeohash: String? = null
private var geoTimer: Job? = null
private var globalPresenceJob: 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
@ -81,6 +100,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() {
subscriptionManager.connect()
// Observe process lifecycle to manage background sampling
@ -124,10 +147,13 @@ class GeohashViewModel(
private fun startGlobalPresenceHeartbeat() {
globalPresenceJob?.cancel()
globalPresenceJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
// Reactively restart heartbeat whenever available channels change
locationChannelManager?.availableChannels?.collectLatest { channels ->
// Filter for REGION (2), PROVINCE (4), CITY (5) - precision <= 5
val targetGeohashes = channels.filter { it.level.precision <= 5 }.map { it.geohash }
val manager = locationChannelManager ?: return@launch
combine(
manager.availableChannels,
LiveLocationPrivacyGate.enabled
) { channels, enabled ->
GeohashNostrPrivacyPolicy.livePresenceTargets(channels, enabled)
}.collectLatest { targetGeohashes ->
if (targetGeohashes.isNotEmpty()) {
// Enter heartbeat loop for this set of channels
@ -145,8 +171,10 @@ class GeohashViewModel(
delay(stepDelay)
timeSpent += stepDelay
broadcastPresence(geohash)
broadcastLiveLocationPresence(geohash)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Global presence heartbeat error: ${e.message}")
}
@ -172,6 +200,7 @@ class GeohashViewModel(
currentGeohashMsgSubId = null
currentGeohashPresenceSubId = null
currentDmSubId = null
currentDmGeohash = null
activeChannelGeohash = null
geoTimer?.cancel()
geoTimer = null
@ -181,22 +210,52 @@ class GeohashViewModel(
initialize()
}
private suspend fun broadcastPresence(geohash: String) {
private suspend fun broadcastLiveLocationPresence(geohash: String) {
val manager = locationChannelManager ?: return
val token = LiveLocationPrivacyGate.captureToken() ?: return
val isCurrentLiveTarget = GeohashNostrPrivacyPolicy.livePresenceTargets(
manager.availableChannels.value,
liveLocationEnabled = true
).contains(geohash)
if (!isCurrentLiveTarget || !LiveLocationPrivacyGate.accepts(token)) return
try {
val identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity)
val relayManager = NostrRelayManager.getInstance(getApplication())
// Presence is lightweight, send to geohash relays
relayManager.sendEventToGeohash(event, geohash, includeDefaults = false, nRelays = 5)
Log.v(TAG, "💓 Sent presence heartbeat for $geohash")
var identity: com.bitchat.android.nostr.NostrIdentity? = null
LiveLocationPrivacyGate.runIfAllowed(token) {
identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
}
val preparedIdentity = identity ?: return
if (!LiveLocationPrivacyGate.accepts(token)) return
val event = NostrProtocol.createGeohashPresenceEvent(geohash, preparedIdentity)
LiveLocationPrivacyGate.runIfAllowed(token) {
val relayManager = NostrRelayManager.getInstance(getApplication())
relayManager.sendEventToGeohash(
event,
geohash,
includeDefaults = false,
nRelays = 5,
liveLocationToken = token
)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to send presence for $geohash: ${e.message}")
Log.w(TAG, "Failed to send live-location presence")
}
}
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(
@ -210,40 +269,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()) {
@ -254,11 +346,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()
}
@ -276,7 +370,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) {
@ -329,6 +423,16 @@ 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
if (currentDmGeohash == geohash && currentDmSubId != null) return
currentDmSubId?.let(subscriptionManager::unsubscribe)
currentDmSubId = null
currentDmGeohash = null
subscribeChannelDM(geohash)
}
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex)
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
@ -340,6 +444,7 @@ class GeohashViewModel(
currentGeohashMsgSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashMsgSubId = null }
currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null }
currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null }
currentDmGeohash = null
when (channel) {
is com.bitchat.android.geohash.ChannelID.Mesh -> {
@ -351,7 +456,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()
@ -362,19 +472,22 @@ 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)
// Chat message stream (kind 20000) is low-volume; keep it alive even when
// backgrounded so geohash messages still arrive.
subscribeChannelMessages(channel.channel.geohash)
subscribeChannelMessages(channel.channel.geohash, liveLocationToken)
// 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)
}
// Gift-wrap DM subscription is lightweight (filtered to our pubkey) and is
// kept alive in the background so geohash DMs still arrive.
@ -392,14 +505,18 @@ class GeohashViewModel(
* Subscribe to the chat message stream (kind 20000) for a geohash channel.
* Low-volume; kept alive in the background so messages keep arriving.
*/
private fun subscribeChannelMessages(geohash: String) {
val subId = "geohash-$geohash"; currentGeohashMsgSubId = subId
private fun subscribeChannelMessages(
geohash: String,
liveLocationToken: Long?
) {
val subId = "geohash-${UUID.randomUUID()}"; currentGeohashMsgSubId = subId
subscriptionManager.subscribeGeohashMessages(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 3600000L,
limit = 200,
id = subId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
handler = { event -> geohashMessageHandler.onEvent(event, geohash) },
liveLocationToken = liveLocationToken
)
}
@ -408,14 +525,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
)
}
@ -424,18 +545,18 @@ class GeohashViewModel(
* Lightweight (filtered to our pubkey); kept alive in the background.
*/
private fun subscribeChannelDM(geohash: String) {
viewModelScope.launch {
val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId
subscriptionManager.subscribeGiftWraps(
pubkey = dmIdentity.publicKeyHex,
sinceMs = System.currentTimeMillis() - 172800000L,
id = dmSubId,
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
)
// Also register alias in global registry for routing convenience
GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
}
val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
val dmSubId = "geo-dm-${UUID.randomUUID()}"
currentDmSubId = dmSubId
currentDmGeohash = geohash
subscriptionManager.subscribeGiftWraps(
pubkey = dmIdentity.publicKeyHex,
sinceMs = System.currentTimeMillis() - 172800000L,
id = dmSubId,
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
)
// Also register alias in global registry for routing convenience
GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
}
private fun startGeoParticipantsTimer() {
@ -452,13 +573,29 @@ class GeohashViewModel(
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
@ -477,7 +614,7 @@ 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 broadcasting presence heartbeats
globalPresenceJob?.cancel(); globalPresenceJob = null
// Stop participant-refresh polling
@ -487,15 +624,48 @@ class GeohashViewModel(
}
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

@ -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

@ -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,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

@ -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% | 29 |
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 010 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())

View File

@ -0,0 +1,72 @@
{
"schema_version": 1,
"commit": "0000000000000000000000000000000000000000",
"clients": {
"android-current": {
"version": "replace-with-release-candidate",
"commit": "0000000000000000000000000000000000000000"
},
"android-legacy": {
"version": "replace-with-last-supported-release"
},
"ios-current": {
"version": "replace-with-current-ios-release"
}
},
"lab_capabilities": ["local-relay", "tor"],
"devices": [
{
"alias": "android-low",
"platform": "android",
"model": "replace-with-model",
"manufacturer_class": "manufacturer-a",
"api_level": 28,
"physical": true,
"capabilities": ["ble-central", "ble-peripheral"]
},
{
"alias": "android-current",
"platform": "android",
"model": "replace-with-model",
"manufacturer_class": "manufacturer-b",
"api_level": 35,
"physical": true,
"capabilities": ["ble-central", "ble-peripheral", "wifi-aware"]
},
{
"alias": "android-relay",
"platform": "android",
"model": "replace-with-model",
"manufacturer_class": "manufacturer-c",
"api_level": 33,
"physical": true,
"capabilities": ["ble-central", "ble-peripheral"]
},
{
"alias": "android-aware",
"platform": "android",
"model": "replace-with-model",
"manufacturer_class": "manufacturer-b",
"api_level": 35,
"physical": true,
"capabilities": ["ble-central", "ble-peripheral", "wifi-aware"]
},
{
"alias": "android-legacy",
"platform": "android",
"model": "replace-with-model",
"manufacturer_class": "manufacturer-a",
"api_level": 28,
"physical": true,
"capabilities": ["ble-central", "ble-peripheral"]
},
{
"alias": "ios-current",
"platform": "ios",
"model": "replace-with-model",
"manufacturer_class": "apple",
"physical": true,
"capabilities": ["ble-central", "ble-peripheral"]
}
]
}

View File

@ -0,0 +1,744 @@
#!/usr/bin/env python3
"""Create, record, validate, and archive the physical release gate.
The host-side CLI is the control channel. It coordinates operators over USB or
local files and never sends control traffic through the mesh under test.
Artifacts intentionally contain logical device aliases and aggregate evidence,
not device identifiers, addresses, peer IDs, or message contents.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import re
import sys
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
SCHEMA_VERSION = 1
RESULT_STATUSES = {"pending", "pass", "fail", "blocked", "unsupported"}
COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
DISALLOWED_KEYS = {
"serial",
"serial_number",
"udid",
"imei",
"bluetooth_address",
"mac_address",
"ip_address",
"peer_id",
"username",
"user_name",
"email",
"account",
"device_name",
}
HASH_VALUE_KEYS = {
"commit",
"sha256",
"digest",
"scenario_manifest_sha256",
"fixture_sha256",
"vector_manifest_digest",
"corpus_digest",
}
SENSITIVE_PATTERNS = (
re.compile(r"(?:^|[\s/])Users/[^/\s]+"),
re.compile(r"(?:^|[\s/])home/[^/\s]+"),
re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b"),
re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"),
re.compile(r"\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b"),
re.compile(r"\b[0-9A-Fa-f]{16,}\b"),
)
MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024
class GateError(ValueError):
"""A release-gate artifact violated an executable contract."""
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def load_json(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise GateError(f"could not read JSON {path.name}: {error}") from error
if not isinstance(value, dict):
raise GateError(f"{path.name} must contain a JSON object")
return value
def write_json(path: Path, value: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
encoding="utf-8",
)
temporary.replace(path)
def sha256_bytes(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def canonical_json_digest(value: dict[str, Any]) -> str:
encoded = json.dumps(
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
).encode("utf-8")
return sha256_bytes(encoded)
def validate_privacy(value: Any, path: tuple[str, ...] = ()) -> None:
if isinstance(value, dict):
for key, child in value.items():
normalized = str(key).lower()
if normalized in DISALLOWED_KEYS:
raise GateError(f"disallowed identifying field: {'.'.join(path + (str(key),))}")
validate_privacy(child, path + (str(key),))
return
if isinstance(value, list):
for index, child in enumerate(value):
validate_privacy(child, path + (str(index),))
return
if not isinstance(value, str):
return
final_key = path[-1].lower().replace("-", "_") if path else ""
for index, pattern in enumerate(SENSITIVE_PATTERNS):
if index == len(SENSITIVE_PATTERNS) - 1 and (
final_key in HASH_VALUE_KEYS
or final_key.endswith("_digest")
or final_key.endswith("_sha256")
):
continue
if pattern.search(value):
raise GateError(f"potential identifying value at {'.'.join(path)}")
def validate_manifest(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
if manifest.get("schema_version") != SCHEMA_VERSION:
raise GateError("unsupported scenario schema_version")
scenarios = manifest.get("scenarios")
if not isinstance(scenarios, list) or not scenarios:
raise GateError("scenario manifest must contain scenarios")
by_id: dict[str, dict[str, Any]] = {}
for scenario in scenarios:
if not isinstance(scenario, dict):
raise GateError("each scenario must be an object")
scenario_id = scenario.get("id")
if not isinstance(scenario_id, str) or not re.fullmatch(
r"[A-Z0-9]{3}-\d{3}", scenario_id
):
raise GateError(f"invalid scenario id: {scenario_id!r}")
if scenario_id in by_id:
raise GateError(f"duplicate scenario id: {scenario_id}")
if scenario.get("category") not in {
"transport",
"android-platform",
"android-to-android",
"cross-client",
"endurance",
}:
raise GateError(f"invalid category for {scenario_id}")
if scenario.get("required") is not True:
raise GateError(f"release scenario {scenario_id} must be required")
if not scenario.get("participants") or not scenario.get("evidence"):
raise GateError(f"scenario {scenario_id} lacks participants or evidence")
by_id[scenario_id] = scenario
validate_privacy(manifest)
return by_id
def validate_matrix(
matrix: dict[str, Any],
expected_commit: str | None = None,
*,
allow_placeholders: bool = False,
) -> dict[str, dict[str, Any]]:
if matrix.get("schema_version") != SCHEMA_VERSION:
raise GateError("unsupported matrix schema_version")
commit = matrix.get("commit")
if not isinstance(commit, str) or not COMMIT_RE.fullmatch(commit):
raise GateError("matrix commit must be a lowercase full Git commit")
if expected_commit is not None and commit != expected_commit:
raise GateError("matrix commit does not match the release candidate")
clients = matrix.get("clients")
required_clients = {"android-current", "android-legacy", "ios-current"}
if not isinstance(clients, dict) or not required_clients.issubset(clients):
raise GateError("matrix must version current Android, legacy Android, and current iOS")
for client_name in required_clients:
client = clients.get(client_name)
if not isinstance(client, dict) or not isinstance(client.get("version"), str):
raise GateError(f"{client_name} must declare a version")
if not allow_placeholders and client["version"].startswith("replace-with-"):
raise GateError(f"{client_name} still contains a template version")
current_commit = clients["android-current"].get("commit")
if current_commit != commit and not (allow_placeholders and commit == "0" * 40):
raise GateError("current Android client commit must match the matrix commit")
lab_capabilities = matrix.get("lab_capabilities")
if not isinstance(lab_capabilities, list) or any(
not isinstance(capability, str) or not SAFE_ID_RE.fullmatch(capability)
for capability in lab_capabilities
):
raise GateError("matrix must declare logical lab_capabilities")
devices = matrix.get("devices")
if not isinstance(devices, list):
raise GateError("matrix devices must be a list")
by_alias: dict[str, dict[str, Any]] = {}
for device in devices:
if not isinstance(device, dict):
raise GateError("each device must be an object")
alias = device.get("alias")
if not isinstance(alias, str) or not SAFE_ID_RE.fullmatch(alias):
raise GateError(f"invalid logical device alias: {alias!r}")
if alias in by_alias:
raise GateError(f"duplicate device alias: {alias}")
if device.get("physical") is not True:
raise GateError(f"{alias} is not a physical device")
if device.get("platform") not in {"android", "ios"}:
raise GateError(f"{alias} has an unsupported platform")
if not isinstance(device.get("capabilities"), list):
raise GateError(f"{alias} must declare capabilities")
if not allow_placeholders and (
not isinstance(device.get("model"), str)
or device["model"].startswith("replace-with-")
):
raise GateError(f"{alias} still contains template values")
by_alias[alias] = device
android = [device for device in devices if device.get("platform") == "android"]
if len(android) < 3:
raise GateError("three physical Android devices are required for relay scenarios")
api_levels = {device.get("api_level") for device in android}
manufacturers = {device.get("manufacturer_class") for device in android}
if len(api_levels) < 2 or not all(isinstance(level, int) for level in api_levels):
raise GateError("Android matrix must cover at least two API levels")
if len(manufacturers) < 2 or None in manufacturers:
raise GateError("Android matrix must cover at least two manufacturer classes")
for device in android:
capabilities = set(device["capabilities"])
if not {"ble-central", "ble-peripheral"}.issubset(capabilities):
raise GateError(f"{device['alias']} lacks required BLE roles")
if not any("wifi-aware" in device["capabilities"] for device in android):
raise GateError("at least one Android device must support Wi-Fi Aware")
if not any(device.get("platform") == "ios" for device in devices):
raise GateError("a physical iOS device is required")
validate_privacy(matrix)
return by_alias
def _fixture_bytes(seed: bytes, size: int) -> bytes:
output = bytearray()
counter = 0
while len(output) < size:
output.extend(hashlib.sha256(seed + counter.to_bytes(4, "big")).digest())
counter += 1
return bytes(output[:size])
def create_fixtures(directory: Path, run_id: str) -> dict[str, Any]:
fixture_directory = directory / "fixtures"
fixture_directory.mkdir()
definitions = (
("empty.bin", 0, False),
("small.bin", 4 * 1024, False),
("lab-résumé-秘密.bin", 256 * 1024, False),
("maximum.bin", MAX_FILE_SIZE_BYTES, True),
("oversized.bin", MAX_FILE_SIZE_BYTES + 1, True),
)
fixtures: list[dict[str, Any]] = []
for name, size, sparse in definitions:
path = fixture_directory / name
if sparse:
with path.open("wb") as stream:
stream.truncate(size)
else:
path.write_bytes(_fixture_bytes(run_id.encode("utf-8"), size))
fixtures.append(
{
"name": name,
"size_bytes": size,
"sparse": sparse,
"fixture_sha256": sha256_file(path),
}
)
manifest = {"schema_version": SCHEMA_VERSION, "fixtures": fixtures}
write_json(fixture_directory / "manifest.json", manifest)
return manifest
def initialize_run(
manifest: dict[str, Any],
matrix: dict[str, Any],
output: Path,
commit: str,
run_id: str,
*,
started_at: str | None = None,
) -> dict[str, Any]:
scenarios = validate_manifest(manifest)
devices = validate_matrix(matrix, commit)
if not SAFE_ID_RE.fullmatch(run_id):
raise GateError("run id must be a non-identifying lowercase logical id")
missing_aliases = {
participant
for scenario in scenarios.values()
for participant in scenario["participants"]
if participant not in devices
}
if missing_aliases:
raise GateError(f"matrix lacks scenario aliases: {sorted(missing_aliases)}")
for scenario_id, scenario in scenarios.items():
available = set(matrix["lab_capabilities"])
for participant in scenario["participants"]:
available.update(devices[participant]["capabilities"])
missing_capabilities = set(scenario["capabilities"]) - available
if missing_capabilities:
raise GateError(
f"{scenario_id} lacks capabilities: {sorted(missing_capabilities)}"
)
output.mkdir(parents=True, exist_ok=False)
write_json(output / "manifest.json", manifest)
write_json(output / "matrix.json", matrix)
fixtures = create_fixtures(output, run_id)
results = {
"schema_version": SCHEMA_VERSION,
"run_id": run_id,
"commit": commit,
"started_at": started_at or utc_now(),
"completed_at": None,
"scenario_manifest_sha256": canonical_json_digest(manifest),
"fixture_manifest_sha256": canonical_json_digest(fixtures),
"scenario_results": {
scenario_id: {
"status": "pending",
"participants": scenario["participants"],
"evidence": {},
"reason_code": None,
"updated_at": None,
"history": [],
}
for scenario_id, scenario in scenarios.items()
},
}
write_json(output / "results.json", results)
(output / "trace.jsonl").write_text("", encoding="utf-8")
return results
def _parse_scalar(value: str) -> Any:
lowered = value.lower()
if lowered in {"true", "false"}:
return lowered == "true"
try:
return int(value)
except ValueError:
try:
return float(value)
except ValueError:
return value
def parse_evidence(values: Iterable[str]) -> dict[str, Any]:
evidence: dict[str, Any] = {}
for value in values:
if "=" not in value:
raise GateError("evidence must use key=value")
key, raw = value.split("=", 1)
if not SAFE_ID_RE.fullmatch(key):
raise GateError(f"invalid evidence key: {key!r}")
evidence[key] = _parse_scalar(raw)
validate_privacy(evidence)
return evidence
def record_result(
run_directory: Path,
scenario_id: str,
status: str,
evidence: dict[str, Any],
reason_code: str | None,
*,
updated_at: str | None = None,
) -> dict[str, Any]:
manifest = load_json(run_directory / "manifest.json")
scenarios = validate_manifest(manifest)
if scenario_id not in scenarios:
raise GateError(f"unknown scenario: {scenario_id}")
if status not in RESULT_STATUSES - {"pending"}:
raise GateError(f"invalid terminal status: {status}")
if reason_code is not None and not SAFE_ID_RE.fullmatch(reason_code):
raise GateError("reason code must be a non-identifying stable code")
if status == "pass" and not evidence:
raise GateError("passing a scenario requires structured evidence")
if status == "pass" and reason_code is not None:
raise GateError("passing a scenario cannot have a failure reason code")
if status in {"fail", "blocked", "unsupported"} and reason_code is None:
raise GateError(f"{status} requires a stable reason code")
if any(not SAFE_ID_RE.fullmatch(str(key)) for key in evidence):
raise GateError("evidence keys must be stable logical identifiers")
validate_privacy(evidence)
results = load_json(run_directory / "results.json")
result = results["scenario_results"][scenario_id]
terminal_update = {
"status": status,
"evidence": evidence,
"reason_code": reason_code,
"updated_at": updated_at or utc_now(),
}
result.setdefault("history", []).append(terminal_update.copy())
result.update(
terminal_update
)
results["completed_at"] = (
result["updated_at"]
if all(
item.get("status") == "pass"
for item in results["scenario_results"].values()
)
else None
)
write_json(run_directory / "results.json", results)
return results
def append_trace_event(
run_directory: Path,
scenario_id: str,
source_alias: str,
event: str,
outcome: str,
reason_code: str | None,
metrics: dict[str, Any],
*,
timestamp: str | None = None,
) -> dict[str, Any]:
manifest = load_json(run_directory / "manifest.json")
matrix = load_json(run_directory / "matrix.json")
scenarios = validate_manifest(manifest)
devices = validate_matrix(matrix, allow_placeholders=False)
if scenario_id not in scenarios:
raise GateError(f"unknown scenario: {scenario_id}")
if source_alias not in devices:
raise GateError(f"unknown source alias: {source_alias}")
for label, value in (("event", event), ("outcome", outcome)):
if not SAFE_ID_RE.fullmatch(value):
raise GateError(f"invalid {label}")
if reason_code is not None and not SAFE_ID_RE.fullmatch(reason_code):
raise GateError("invalid reason code")
if any(not SAFE_ID_RE.fullmatch(str(key)) for key in metrics):
raise GateError("trace metric keys must be stable logical identifiers")
if any(
not isinstance(value, (int, float, bool))
or isinstance(value, float) and not math.isfinite(value)
for value in metrics.values()
):
raise GateError("trace metrics must be numeric or boolean aggregates")
trace = {
"timestamp": timestamp or utc_now(),
"scenario_id": scenario_id,
"source_alias": source_alias,
"event": event,
"outcome": outcome,
"reason_code": reason_code,
"metrics": metrics,
}
validate_privacy(trace)
with (run_directory / "trace.jsonl").open("a", encoding="utf-8") as stream:
stream.write(json.dumps(trace, sort_keys=True, ensure_ascii=False) + "\n")
return trace
def _validate_trace(
run_directory: Path,
scenarios: dict[str, dict[str, Any]],
devices: dict[str, dict[str, Any]],
) -> tuple[int, set[str]]:
path = run_directory / "trace.jsonl"
if not path.exists():
raise GateError("trace.jsonl is missing")
count = 0
traced_scenarios: set[str] = set()
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
try:
event = json.loads(line)
except json.JSONDecodeError as error:
raise GateError(f"invalid trace line {line_number}") from error
if not isinstance(event, dict):
raise GateError(f"trace line {line_number} must be an object")
if event.get("scenario_id") not in scenarios:
raise GateError(f"trace line {line_number} has an unknown scenario")
if event.get("source_alias") not in devices:
raise GateError(f"trace line {line_number} has an unknown source")
if set(event) != {
"timestamp",
"scenario_id",
"source_alias",
"event",
"outcome",
"reason_code",
"metrics",
}:
raise GateError(f"trace line {line_number} has unexpected fields")
if not isinstance(event.get("metrics"), dict) or any(
not isinstance(value, (int, float, bool))
for value in event["metrics"].values()
):
raise GateError(f"trace line {line_number} has invalid metrics")
validate_privacy(event)
count += 1
traced_scenarios.add(event["scenario_id"])
return count, traced_scenarios
def validate_run(run_directory: Path, *, allow_incomplete: bool = False) -> dict[str, Any]:
manifest = load_json(run_directory / "manifest.json")
matrix = load_json(run_directory / "matrix.json")
results = load_json(run_directory / "results.json")
scenarios = validate_manifest(manifest)
devices = validate_matrix(matrix, results.get("commit"))
if results.get("schema_version") != SCHEMA_VERSION:
raise GateError("unsupported results schema_version")
if results.get("scenario_manifest_sha256") != canonical_json_digest(manifest):
raise GateError("scenario manifest changed after run initialization")
fixture_manifest = load_json(run_directory / "fixtures" / "manifest.json")
if results.get("fixture_manifest_sha256") != canonical_json_digest(fixture_manifest):
raise GateError("fixture manifest changed after run initialization")
actual = results.get("scenario_results")
if not isinstance(actual, dict) or set(actual) != set(scenarios):
raise GateError("results must contain exactly every declared scenario")
validate_privacy(results)
for scenario_id, scenario in scenarios.items():
result = actual[scenario_id]
if result.get("participants") != scenario["participants"]:
raise GateError(f"{scenario_id} participants changed")
if any(alias not in devices for alias in result["participants"]):
raise GateError(f"{scenario_id} references an unknown device")
available = set(matrix["lab_capabilities"])
for participant in result["participants"]:
available.update(devices[participant]["capabilities"])
missing_capabilities = set(scenario["capabilities"]) - available
if missing_capabilities:
raise GateError(
f"{scenario_id} lacks capabilities: {sorted(missing_capabilities)}"
)
status = result.get("status")
if status not in RESULT_STATUSES:
raise GateError(f"{scenario_id} has invalid status")
if not allow_incomplete and status != "pass":
raise GateError(f"{scenario_id} is not passing: {status}")
if status == "pass":
evidence = result.get("evidence")
missing = set(scenario["evidence"]) - set(evidence or {})
if missing:
raise GateError(f"{scenario_id} lacks evidence: {sorted(missing)}")
if scenario.get("minimum_duration_minutes") is not None and (
evidence.get("duration-minutes", 0)
< scenario["minimum_duration_minutes"]
):
raise GateError(f"{scenario_id} did not meet minimum duration")
if scenario.get("minimum_cycles") is not None and (
evidence.get("cycle-count", 0) < scenario["minimum_cycles"]
):
raise GateError(f"{scenario_id} did not meet minimum cycles")
trace_events, traced_scenarios = _validate_trace(run_directory, scenarios, devices)
if not allow_incomplete:
missing_traces = set(scenarios) - traced_scenarios
if missing_traces:
raise GateError(
f"passing scenarios lack structured traces: {sorted(missing_traces)}"
)
if not results.get("completed_at"):
raise GateError("complete results must record completed_at")
summary = {
status: sum(
1 for result in actual.values() if result.get("status") == status
)
for status in sorted(RESULT_STATUSES)
}
summary["trace_events"] = trace_events
summary["complete"] = all(
result.get("status") == "pass" for result in actual.values()
)
return summary
def render_summary(run_directory: Path) -> str:
results = load_json(run_directory / "results.json")
summary = validate_run(run_directory, allow_incomplete=True)
rows = [
"# Physical release-gate result",
"",
f"- Run: `{results['run_id']}`",
f"- Commit: `{results['commit']}`",
f"- Complete: `{str(summary['complete']).lower()}`",
f"- Trace events: {summary['trace_events']}",
"",
"| Status | Count |",
"|---|---:|",
]
rows.extend(
f"| {status} | {summary[status]} |" for status in sorted(RESULT_STATUSES)
)
return "\n".join(rows) + "\n"
def create_bundle(run_directory: Path, output: Path) -> None:
validate_run(run_directory)
if output.exists():
raise GateError("refusing to overwrite an existing release-gate bundle")
members = [
Path("manifest.json"),
Path("matrix.json"),
Path("results.json"),
Path("trace.jsonl"),
Path("fixtures/manifest.json"),
]
generated = {"summary.md": render_summary(run_directory).encode("utf-8")}
checksums: list[str] = []
payloads: dict[str, bytes] = {}
for member in members:
payload = (run_directory / member).read_bytes()
payloads[member.as_posix()] = payload
payloads.update(generated)
for name in sorted(payloads):
checksums.append(f"{sha256_bytes(payloads[name])} {name}")
payloads["SHA256SUMS"] = ("\n".join(checksums) + "\n").encode("utf-8")
output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for name in sorted(payloads):
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o100644 << 16
archive.writestr(info, payloads[name])
def _default_manifest() -> Path:
return Path(__file__).with_name("scenarios.json")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
manifest = subparsers.add_parser("validate-manifest")
manifest.add_argument("--manifest", type=Path, default=_default_manifest())
matrix = subparsers.add_parser("validate-matrix")
matrix.add_argument("--matrix", type=Path, required=True)
matrix.add_argument("--commit")
matrix.add_argument("--allow-template", action="store_true")
initialize = subparsers.add_parser("init")
initialize.add_argument("--manifest", type=Path, default=_default_manifest())
initialize.add_argument("--matrix", type=Path, required=True)
initialize.add_argument("--output", type=Path, required=True)
initialize.add_argument("--commit", required=True)
initialize.add_argument("--run-id", required=True)
record = subparsers.add_parser("record")
record.add_argument("--run", type=Path, required=True)
record.add_argument("--scenario", required=True)
record.add_argument("--status", choices=sorted(RESULT_STATUSES - {"pending"}), required=True)
record.add_argument("--evidence", action="append", default=[])
record.add_argument("--reason-code")
trace = subparsers.add_parser("trace")
trace.add_argument("--run", type=Path, required=True)
trace.add_argument("--scenario", required=True)
trace.add_argument("--source", required=True)
trace.add_argument("--event", required=True)
trace.add_argument("--outcome", required=True)
trace.add_argument("--reason-code")
trace.add_argument("--metric", action="append", default=[])
validate = subparsers.add_parser("validate")
validate.add_argument("--run", type=Path, required=True)
validate.add_argument("--allow-incomplete", action="store_true")
summary = subparsers.add_parser("summary")
summary.add_argument("--run", type=Path, required=True)
bundle = subparsers.add_parser("bundle")
bundle.add_argument("--run", type=Path, required=True)
bundle.add_argument("--output", type=Path, required=True)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.command == "validate-manifest":
scenarios = validate_manifest(load_json(args.manifest))
print(f"valid scenarios: {len(scenarios)}")
elif args.command == "validate-matrix":
devices = validate_matrix(
load_json(args.matrix),
args.commit,
allow_placeholders=args.allow_template,
)
print(f"valid devices: {len(devices)}")
elif args.command == "init":
initialize_run(
load_json(args.manifest),
load_json(args.matrix),
args.output,
args.commit,
args.run_id,
)
print(args.output)
elif args.command == "record":
record_result(
args.run,
args.scenario,
args.status,
parse_evidence(args.evidence),
args.reason_code,
)
elif args.command == "trace":
append_trace_event(
args.run,
args.scenario,
args.source,
args.event,
args.outcome,
args.reason_code,
parse_evidence(args.metric),
)
elif args.command == "validate":
print(
json.dumps(
validate_run(args.run, allow_incomplete=args.allow_incomplete),
sort_keys=True,
)
)
elif args.command == "summary":
print(render_summary(args.run), end="")
elif args.command == "bundle":
create_bundle(args.run, args.output)
print(args.output)
return 0
except GateError as error:
print(f"release gate error: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,251 @@
{
"schema_version": 1,
"scenario_version": "1.0",
"scenarios": [
{
"id": "TRN-001",
"category": "transport",
"title": "Complete the physical BLE, GATT, MTU, Wi-Fi Aware, and failover matrix",
"participants": ["android-low", "android-current", "android-aware"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral", "wifi-aware"],
"evidence": ["matrix-check-count", "mtu-case-count", "failure-injection-count", "shutdown-check-count"]
},
{
"id": "AND-001",
"category": "android-platform",
"title": "Complete API-level, manufacturer, permission-revocation, and background gates",
"participants": ["android-low", "android-current", "android-aware"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral", "wifi-aware"],
"evidence": ["api-level-count", "manufacturer-count", "permission-revocation-count", "background-case-count"]
},
{
"id": "A2A-001",
"category": "android-to-android",
"title": "Discover, connect, announce, disconnect, and reconnect",
"participants": ["android-low", "android-current"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["connection-transitions", "packet-correlation-count", "failure-reasons"]
},
{
"id": "A2A-002",
"category": "android-to-android",
"title": "Exchange public and private messages in both directions",
"participants": ["android-low", "android-current"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["message-counts", "packet-correlation-count"]
},
{
"id": "A2A-003",
"category": "android-to-android",
"title": "Advance delivery and read receipts",
"participants": ["android-low", "android-current"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["receipt-transitions"]
},
{
"id": "A2A-004",
"category": "android-to-android",
"title": "Transfer image, audio, and generic files at multiple sizes",
"participants": ["android-low", "android-current"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["fixture-digests", "transfer-progress", "delivery-digests"]
},
{
"id": "A2A-005",
"category": "android-to-android",
"title": "Relay across three Android devices",
"participants": ["android-low", "android-current", "android-relay"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["route-transitions", "ttl-values", "packet-correlation-count"]
},
{
"id": "A2A-006",
"category": "android-to-android",
"title": "Partition and recover store-and-forward delivery",
"participants": ["android-low", "android-current", "android-relay"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["partition-window", "queue-counts", "delivery-counts"]
},
{
"id": "A2A-007",
"category": "android-to-android",
"title": "Toggle Bluetooth during active transfers",
"participants": ["android-low", "android-current"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["radio-transitions", "transfer-terminal-state"]
},
{
"id": "A2A-008",
"category": "android-to-android",
"title": "Lock screens and background both apps during mesh operation",
"participants": ["android-low", "android-current"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["lifecycle-transitions", "service-state", "delivery-counts"]
},
{
"id": "A2A-009",
"category": "android-to-android",
"title": "Kill and recreate one process",
"participants": ["android-low", "android-current"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["process-generation", "restored-state", "reconnect-count"]
},
{
"id": "A2A-010",
"category": "android-to-android",
"title": "Resolve simultaneous and duplicate links",
"participants": ["android-low", "android-current"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["candidate-links", "canonical-link-count"]
},
{
"id": "A2A-011",
"category": "android-to-android",
"title": "Fail over through Wi-Fi Aware",
"participants": ["android-current", "android-aware"],
"required": true,
"capabilities": ["wifi-aware"],
"evidence": ["transport-selection", "failover-window", "delivery-counts"]
},
{
"id": "XCL-001",
"category": "cross-client",
"title": "Current Android interoperates with current iOS",
"participants": ["android-current", "ios-current"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["client-versions", "payload-counts"]
},
{
"id": "XCL-002",
"category": "cross-client",
"title": "Current rewrite contracts interoperate with the last supported Android",
"participants": ["android-current", "android-legacy"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["client-versions", "payload-counts"]
},
{
"id": "XCL-003",
"category": "cross-client",
"title": "Legacy announcements without capabilities remain compatible",
"participants": ["android-current", "android-legacy"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["announcement-version", "peer-state"]
},
{
"id": "XCL-004",
"category": "cross-client",
"title": "Older clients safely ignore current capability announcements",
"participants": ["android-current", "android-legacy"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["announcement-version", "peer-state"]
},
{
"id": "XCL-005",
"category": "cross-client",
"title": "Canonical private media and prerelease decode-only alias interoperate",
"participants": ["android-current", "ios-current"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["wire-type", "delivery-digests", "downgrade-decision"]
},
{
"id": "XCL-006",
"category": "cross-client",
"title": "Golden vectors match across implementations",
"participants": ["android-current", "ios-current", "android-legacy"],
"required": true,
"capabilities": [],
"evidence": ["vector-manifest-digest", "comparison-counts"]
},
{
"id": "XCL-007",
"category": "cross-client",
"title": "Malformed and unauthenticated inputs are rejected consistently",
"participants": ["android-current", "ios-current", "android-legacy"],
"required": true,
"capabilities": [],
"evidence": ["corpus-digest", "rejection-counts", "crash-count"]
},
{
"id": "XCL-008",
"category": "cross-client",
"title": "Nostr fallback messages and receipts interoperate",
"participants": ["android-current", "ios-current"],
"required": true,
"capabilities": ["local-relay", "tor"],
"evidence": ["relay-fixture", "event-id-count", "receipt-counts"]
},
{
"id": "END-001",
"category": "endurance",
"title": "Multi-hour discovery, connect, and disconnect soak",
"participants": ["android-low", "android-current"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"minimum_duration_minutes": 240,
"evidence": ["duration-minutes", "connection-counts", "failure-counts"]
},
{
"id": "END-002",
"category": "endurance",
"title": "Repeated large-transfer and cancellation cycles",
"participants": ["android-low", "android-current"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"minimum_cycles": 50,
"evidence": ["cycle-count", "delivery-counts", "cancellation-counts"]
},
{
"id": "END-003",
"category": "endurance",
"title": "Resource growth remains bounded",
"participants": ["android-low", "android-current"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["memory-samples", "thread-samples", "fd-samples", "wakelock-samples", "battery-samples"]
},
{
"id": "END-004",
"category": "endurance",
"title": "Foreground service survives with screens off",
"participants": ["android-low", "android-current"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["service-state", "screen-state", "delivery-counts"]
},
{
"id": "END-005",
"category": "endurance",
"title": "Nostr survives network and Tor availability changes",
"participants": ["android-current"],
"required": true,
"capabilities": ["local-relay", "tor"],
"evidence": ["network-transitions", "tor-transitions", "receipt-counts"]
},
{
"id": "END-006",
"category": "endurance",
"title": "Shutdown releases radios, sockets, jobs, and wake locks",
"participants": ["android-low", "android-current"],
"required": true,
"capabilities": ["ble-central", "ble-peripheral"],
"evidence": ["resource-terminal-state", "late-callback-count"]
}
]
}

View File

@ -0,0 +1,367 @@
import json
import subprocess
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest import mock
from tools.release_gate import android_lab
from tools.release_gate.release_gate import (
GateError,
append_trace_event,
canonical_json_digest,
create_bundle,
initialize_run,
load_json,
parse_evidence,
record_result,
validate_manifest,
validate_matrix,
validate_privacy,
validate_run,
)
COMMIT = "1" * 40
TOOL_DIRECTORY = Path(__file__).parent
def valid_matrix():
return {
"schema_version": 1,
"commit": COMMIT,
"clients": {
"android-current": {"version": "2.0.0-rc1", "commit": COMMIT},
"android-legacy": {"version": "1.9.0"},
"ios-current": {"version": "2.0.0"},
},
"lab_capabilities": ["local-relay", "tor"],
"devices": [
{
"alias": "android-low",
"platform": "android",
"model": "model-low",
"manufacturer_class": "vendor-a",
"api_level": 28,
"physical": True,
"capabilities": ["ble-central", "ble-peripheral"],
},
{
"alias": "android-current",
"platform": "android",
"model": "model-current",
"manufacturer_class": "vendor-b",
"api_level": 35,
"physical": True,
"capabilities": ["ble-central", "ble-peripheral", "wifi-aware"],
},
{
"alias": "android-relay",
"platform": "android",
"model": "model-relay",
"manufacturer_class": "vendor-c",
"api_level": 33,
"physical": True,
"capabilities": ["ble-central", "ble-peripheral"],
},
{
"alias": "android-aware",
"platform": "android",
"model": "model-aware",
"manufacturer_class": "vendor-b",
"api_level": 35,
"physical": True,
"capabilities": ["ble-central", "ble-peripheral", "wifi-aware"],
},
{
"alias": "android-legacy",
"platform": "android",
"model": "model-legacy",
"manufacturer_class": "vendor-a",
"api_level": 28,
"physical": True,
"capabilities": ["ble-central", "ble-peripheral"],
},
{
"alias": "ios-current",
"platform": "ios",
"model": "ios-model",
"manufacturer_class": "apple",
"physical": True,
"capabilities": ["ble-central", "ble-peripheral"],
},
],
}
class ReleaseGateTest(unittest.TestCase):
def setUp(self):
self.manifest = load_json(TOOL_DIRECTORY / "scenarios.json")
def test_manifest_covers_every_release_category(self):
scenarios = validate_manifest(self.manifest)
self.assertEqual(27, len(scenarios))
self.assertEqual(
{
"transport",
"android-platform",
"android-to-android",
"cross-client",
"endurance",
},
{scenario["category"] for scenario in scenarios.values()},
)
def test_documented_matrix_template_is_schema_valid_but_not_runnable(self):
template = load_json(TOOL_DIRECTORY / "device-matrix.example.json")
self.assertEqual(6, len(validate_matrix(template, allow_placeholders=True)))
with self.assertRaises(GateError):
validate_matrix(template)
def test_matrix_enforces_distinct_api_manufacturer_and_counterpart_clients(self):
matrix = valid_matrix()
self.assertEqual(6, len(validate_matrix(matrix, COMMIT)))
for device in matrix["devices"]:
if device["platform"] == "android":
device["manufacturer_class"] = "one-vendor"
with self.assertRaisesRegex(GateError, "manufacturer"):
validate_matrix(matrix, COMMIT)
def test_privacy_policy_rejects_identifiers_paths_addresses_and_long_ids(self):
rejected = (
{"serial": "device-selector"},
{"note": "/" + "home/operator/result"},
{"note": "operator@example.test"},
{"note": "192.0.2.1"},
{"note": "aa:bb:cc:dd:ee:ff"},
{"note": "0123456789abcdef"},
)
for value in rejected:
with self.subTest(value=value), self.assertRaises(GateError):
validate_privacy(value)
validate_privacy({"commit": COMMIT, "packet-correlation-count": 3})
def test_initialize_creates_disposable_fixtures_and_pending_results(self):
with tempfile.TemporaryDirectory() as temporary:
run = Path(temporary) / "rc-run"
results = initialize_run(
self.manifest,
valid_matrix(),
run,
COMMIT,
"rc-run",
started_at="2026-01-01T00:00:00+00:00",
)
self.assertTrue(all(
result["status"] == "pending"
for result in results["scenario_results"].values()
))
fixtures = load_json(run / "fixtures" / "manifest.json")["fixtures"]
sizes = {fixture["name"]: fixture["size_bytes"] for fixture in fixtures}
self.assertEqual(0, sizes["empty.bin"])
self.assertEqual(50 * 1024 * 1024, sizes["maximum.bin"])
self.assertEqual(50 * 1024 * 1024 + 1, sizes["oversized.bin"])
self.assertEqual(
results["scenario_manifest_sha256"],
canonical_json_digest(self.manifest),
)
summary = validate_run(run, allow_incomplete=True)
self.assertEqual(27, summary["pending"])
self.assertFalse(summary["complete"])
def test_record_requires_structured_evidence_and_rejects_pii(self):
with tempfile.TemporaryDirectory() as temporary:
run = Path(temporary) / "rc-run"
initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run")
with self.assertRaises(GateError):
record_result(run, "A2A-001", "pass", {}, None)
with self.assertRaises(GateError):
record_result(
run,
"A2A-001",
"pass",
{"connection-transitions": "operator@example.test"},
None,
)
with self.assertRaisesRegex(GateError, "reason code"):
record_result(run, "A2A-001", "fail", {}, None)
recorded = record_result(
run,
"A2A-001",
"blocked",
{},
"counterpart-unavailable",
updated_at="2026-01-01T00:00:00+00:00",
)
self.assertEqual(
["blocked"],
[
item["status"]
for item in recorded["scenario_results"]["A2A-001"]["history"]
],
)
def test_complete_run_requires_all_evidence_traces_and_endurance_bounds(self):
with tempfile.TemporaryDirectory() as temporary:
run = Path(temporary) / "rc-run"
initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run")
scenarios = validate_manifest(self.manifest)
for scenario_id, scenario in scenarios.items():
evidence = {key: 1 for key in scenario["evidence"]}
if "duration-minutes" in evidence:
evidence["duration-minutes"] = 240
if "cycle-count" in evidence:
evidence["cycle-count"] = 50
record_result(
run,
scenario_id,
"pass",
evidence,
None,
updated_at="2026-01-01T04:00:00+00:00",
)
append_trace_event(
run,
scenario_id,
scenario["participants"][0],
"scenario-terminal",
"pass",
None,
{"assertion-count": len(evidence)},
timestamp="2026-01-01T04:00:00+00:00",
)
summary = validate_run(run)
self.assertTrue(summary["complete"])
self.assertEqual(27, summary["pass"])
self.assertEqual(27, summary["trace_events"])
bundle = Path(temporary) / "release-gate.zip"
create_bundle(run, bundle)
with zipfile.ZipFile(bundle) as archive:
self.assertEqual(
{
"SHA256SUMS",
"fixtures/manifest.json",
"manifest.json",
"matrix.json",
"results.json",
"summary.md",
"trace.jsonl",
},
set(archive.namelist()),
)
with self.assertRaisesRegex(GateError, "overwrite"):
create_bundle(run, bundle)
def test_manifest_tampering_after_initialization_is_detected(self):
with tempfile.TemporaryDirectory() as temporary:
run = Path(temporary) / "rc-run"
initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run")
manifest = load_json(run / "manifest.json")
manifest["scenario_version"] = "tampered"
(run / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
with self.assertRaisesRegex(GateError, "changed"):
validate_run(run, allow_incomplete=True)
def test_trace_accepts_only_aggregate_metrics(self):
with tempfile.TemporaryDirectory() as temporary:
run = Path(temporary) / "rc-run"
initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run")
with self.assertRaisesRegex(GateError, "numeric"):
append_trace_event(
run,
"A2A-001",
"android-current",
"packet",
"observed",
None,
{"raw-packet": "payload"},
)
def test_evidence_parser_is_typed_and_privacy_checked(self):
self.assertEqual(
{"count": 3, "ratio": 0.5, "clean": True},
parse_evidence(["count=3", "ratio=0.5", "clean=true"]),
)
with self.assertRaises(GateError):
parse_evidence(["note=10.0.0.1"])
@mock.patch("tools.release_gate.android_lab.find_adb", return_value="adb")
def test_adb_device_count_never_returns_selectors(self, _find_adb):
completed = subprocess.CompletedProcess(
["adb", "devices"],
0,
"List of devices attached\nselector-one\tdevice\nselector-two\toffline\n",
"",
)
count = android_lab.count_connected_devices(runner=lambda *args, **kwargs: completed)
self.assertEqual(1, count)
@mock.patch("tools.release_gate.android_lab.run_adb")
def test_adb_probe_emits_only_logical_device_metadata(self, run_adb):
run_adb.side_effect = [
"35",
"feature:android.hardware.wifi.aware",
"Vendor",
"Model",
]
probe = android_lab.probe_device("ephemeral-selector", "android-current")
self.assertEqual("android-current", probe["alias"])
self.assertNotIn("serial", probe)
self.assertIn("wifi-aware", probe["capabilities"])
@mock.patch("tools.release_gate.android_lab.run_adb")
def test_disposable_cleanup_targets_the_real_application_id(self, run_adb):
run_adb.side_effect = ["", "Success"]
android_lab.prepare_disposable_device("ephemeral-selector", confirmed=True)
self.assertEqual(
[
mock.call(
"ephemeral-selector",
["shell", "am", "force-stop", "com.bitchat.droid"],
),
mock.call(
"ephemeral-selector",
["shell", "pm", "clear", "com.bitchat.droid"],
),
],
run_adb.call_args_list,
)
@mock.patch("tools.release_gate.android_lab.run_adb")
def test_resource_snapshot_returns_only_aggregate_metrics(self, run_adb):
run_adb.side_effect = [
"123",
"TOTAL 2048",
"7",
"11",
"WakeLock com.bitchat.droid\nWakeLock another.package",
"level: 73",
]
metrics = android_lab.collect_resource_snapshot("ephemeral-selector")
self.assertEqual(
{
"process-running": True,
"total-pss-kb": 2048,
"thread-count": 7,
"fd-count": 11,
"app-wakelock-count": 1,
"battery-level-percent": 73,
},
metrics,
)
self.assertEqual(
mock.call(
"ephemeral-selector",
["shell", "pidof", "com.bitchat.droid"],
),
run_adb.call_args_list[0],
)
if __name__ == "__main__":
unittest.main()