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