diff --git a/.gitignore b/.gitignore index aa079292..a2dd3580 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ @@ -66,3 +72,6 @@ tools/arti-build/target/ # Generated from the pinned nostr-double-ratchet source submodule. app/src/main/jniLibs/*/libndr_ffi.so + +# JVM heap dumps (a Gradle daemon OOM drops these in the repo root) +*.hprof diff --git a/app/src/main/assets/design-icons/README.md b/app/src/main/assets/design-icons/README.md new file mode 100644 index 00000000..cd04de8f --- /dev/null +++ b/app/src/main/assets/design-icons/README.md @@ -0,0 +1,6 @@ +# Design-spec icons + +These standalone SVGs were extracted from the supplied 393 px Figma screen exports. The matching +Android vector resources in `res/drawable/ic_spec_*.xml` are the runtime copies used by Compose. +Paths and stroke weights remain faithful to the exports; UI tint and opacity are applied at the +call site so selected and disabled states remain theme-aware. diff --git a/app/src/main/assets/design-icons/bookmark-filled.svg b/app/src/main/assets/design-icons/bookmark-filled.svg new file mode 100644 index 00000000..a2f90c78 --- /dev/null +++ b/app/src/main/assets/design-icons/bookmark-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/bookmark-outline.svg b/app/src/main/assets/design-icons/bookmark-outline.svg new file mode 100644 index 00000000..7ffb105f --- /dev/null +++ b/app/src/main/assets/design-icons/bookmark-outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/chat-bubbles.svg b/app/src/main/assets/design-icons/chat-bubbles.svg new file mode 100644 index 00000000..32546ff8 --- /dev/null +++ b/app/src/main/assets/design-icons/chat-bubbles.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/command.svg b/app/src/main/assets/design-icons/command.svg new file mode 100644 index 00000000..3be46195 --- /dev/null +++ b/app/src/main/assets/design-icons/command.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/eye-off.svg b/app/src/main/assets/design-icons/eye-off.svg new file mode 100644 index 00000000..1e8ad44d --- /dev/null +++ b/app/src/main/assets/design-icons/eye-off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/globe.svg b/app/src/main/assets/design-icons/globe.svg new file mode 100644 index 00000000..e0dc1a45 --- /dev/null +++ b/app/src/main/assets/design-icons/globe.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/assets/design-icons/lock.svg b/app/src/main/assets/design-icons/lock.svg new file mode 100644 index 00000000..28a95cb0 --- /dev/null +++ b/app/src/main/assets/design-icons/lock.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/assets/design-icons/mention.svg b/app/src/main/assets/design-icons/mention.svg new file mode 100644 index 00000000..59f2fa19 --- /dev/null +++ b/app/src/main/assets/design-icons/mention.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/on-location-person.svg b/app/src/main/assets/design-icons/on-location-person.svg new file mode 100644 index 00000000..5b448ae9 --- /dev/null +++ b/app/src/main/assets/design-icons/on-location-person.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/assets/design-icons/panic.svg b/app/src/main/assets/design-icons/panic.svg new file mode 100644 index 00000000..614b9070 --- /dev/null +++ b/app/src/main/assets/design-icons/panic.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/people.svg b/app/src/main/assets/design-icons/people.svg new file mode 100644 index 00000000..f0bce1af --- /dev/null +++ b/app/src/main/assets/design-icons/people.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/person.svg b/app/src/main/assets/design-icons/person.svg new file mode 100644 index 00000000..c0c93053 --- /dev/null +++ b/app/src/main/assets/design-icons/person.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/range.svg b/app/src/main/assets/design-icons/range.svg new file mode 100644 index 00000000..88c36115 --- /dev/null +++ b/app/src/main/assets/design-icons/range.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/shuffle.svg b/app/src/main/assets/design-icons/shuffle.svg new file mode 100644 index 00000000..7f054978 --- /dev/null +++ b/app/src/main/assets/design-icons/shuffle.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/star-filled.svg b/app/src/main/assets/design-icons/star-filled.svg new file mode 100644 index 00000000..ebc21c4f --- /dev/null +++ b/app/src/main/assets/design-icons/star-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/star.svg b/app/src/main/assets/design-icons/star.svg new file mode 100644 index 00000000..b385fc1f --- /dev/null +++ b/app/src/main/assets/design-icons/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/teleport.svg b/app/src/main/assets/design-icons/teleport.svg new file mode 100644 index 00000000..4209d337 --- /dev/null +++ b/app/src/main/assets/design-icons/teleport.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/assets/design-icons/waveform.svg b/app/src/main/assets/design-icons/waveform.svg new file mode 100644 index 00000000..558ccc9d --- /dev/null +++ b/app/src/main/assets/design-icons/waveform.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/wifi-off.svg b/app/src/main/assets/design-icons/wifi-off.svg new file mode 100644 index 00000000..5bb5a4d7 --- /dev/null +++ b/app/src/main/assets/design-icons/wifi-off.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index e6344efa..2ae6bab4 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt index 94529d77..731a85fb 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt @@ -1,8 +1,11 @@ package com.bitchat.android.core.ui.component.button +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -12,10 +15,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.bitchat.android.core.ui.icon.BitChatIcon +import com.bitchat.android.ui.rememberPressScale import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -30,6 +38,7 @@ fun BitChatBrandButton( contentDescription: String, modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.primary, + iconSize: Dp = 22.dp, ) { var tapCount by remember { mutableIntStateOf(0) } var resetJob by remember { mutableStateOf(null) } @@ -37,33 +46,47 @@ fun BitChatBrandButton( val currentOnClick by rememberUpdatedState(onClick) val currentOnTripleClick by rememberUpdatedState(onTripleClick) - IconButton( - onClick = { - tapCount += 1 - resetJob?.cancel() + val interactionSource = remember { MutableInteractionSource() } + val pressScale = rememberPressScale(interactionSource) - if (tapCount == 3) { - tapCount = 0 - resetJob = null - currentOnTripleClick() - } else { - resetJob = coroutineScope.launch { - delay(MultiClickThreshold) - if (tapCount == 1) { - currentOnClick() - } + // A plain Box rather than an IconButton: IconButton insists on drawing a ripple, which was the + // only press background left in the header once every other control moved to scale-only + // feedback. + Box( + modifier = modifier + .clip(CircleShape) + .clickable( + interactionSource = interactionSource, + indication = null, + onClickLabel = contentDescription + ) { + tapCount += 1 + resetJob?.cancel() + + if (tapCount == 3) { tapCount = 0 resetJob = null + currentOnTripleClick() + } else { + resetJob = coroutineScope.launch { + delay(MultiClickThreshold) + if (tapCount == 1) { + currentOnClick() + } + tapCount = 0 + resetJob = null + } } - } - }, - modifier = modifier, + }, + contentAlignment = Alignment.Center ) { Icon( imageVector = BitChatIcon, contentDescription = contentDescription, tint = tint, - modifier = Modifier.size(16.dp), + modifier = Modifier + .size(iconSize) + .scale(pressScale), ) } } diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt index 96f408bc..9c60123f 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt @@ -1,34 +1,38 @@ package com.bitchat.android.core.ui.component.button -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.bitchat.android.R @Composable fun CloseButton( onClick: () -> Unit, - modifier: Modifier = Modifier.Companion + modifier: Modifier = Modifier ) { + val colorScheme = MaterialTheme.colorScheme IconButton( onClick = onClick, - modifier = modifier - .size(32.dp), + // 44.dp to match every other tap target in the app's chrome. + modifier = modifier.size(44.dp), colors = IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), - containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f) + contentColor = colorScheme.primary, + containerColor = Color.Transparent ) ) { Icon( imageVector = Icons.Default.Close, - contentDescription = "Close", - modifier = Modifier.Companion.size(18.dp) + contentDescription = stringResource(R.string.close_plain), + modifier = Modifier.size(18.dp) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt index 948fd6d9..e53fed46 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt @@ -9,8 +9,26 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +/** + * Dismisses the enclosing [BitchatBottomSheet], playing the slide-down first. + * + * `ModalBottomSheet` only animates itself out when *it* initiates the dismissal — a swipe or a tap + * on the scrim. Anything that closes a sheet programmatically (a close button, picking an item from + * a list) previously flipped the caller's `isPresented` flag straight to false, which yanks the + * composable out of the tree and makes the sheet vanish instantly. + * + * Anything inside a sheet that wants to close it should prefer this over calling its own + * `onDismiss` directly. + */ +val LocalSheetDismiss = staticCompositionLocalOf<(() -> Unit)?> { null } @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -20,6 +38,20 @@ fun BitchatBottomSheet( onDismissRequest: () -> Unit, content: @Composable (ColumnScope.() -> Unit), ) { + val scope = rememberCoroutineScope() + + // Runs the hide animation to completion, then tells the caller to drop the sheet. `hide()` + // throws if the sheet is already on its way out (two rapid taps on a close button), which is + // benign — the dismissal still has to go through. + val animatedDismiss: () -> Unit = remember(sheetState, onDismissRequest) { + { + scope.launch { + runCatching { sheetState.hide() } + onDismissRequest() + } + } + } + ModalBottomSheet( modifier = modifier.statusBarsPadding(), onDismissRequest = onDismissRequest, @@ -27,6 +59,9 @@ fun BitchatBottomSheet( dragHandle = null, shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), containerColor = MaterialTheme.colorScheme.background, - content = content, - ) -} \ No newline at end of file + ) { + CompositionLocalProvider(LocalSheetDismiss provides animatedDismiss) { + content() + } + } +} diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt index 036cf01b..b8a027dc 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt @@ -10,9 +10,10 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.runtime.CompositionLocalProvider +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.core.ui.component.button.CloseButton @OptIn(ExperimentalMaterial3Api::class) @@ -30,8 +31,9 @@ fun BitchatSheetTopBar( navigationIcon = { navigationIcon?.invoke() }, actions = { actions() + val dismiss = LocalSheetDismiss.current CloseButton( - onClick = onClose, + onClick = { dismiss?.invoke() ?: onClose() }, modifier = Modifier.padding(horizontal = 16.dp) ) }, @@ -59,8 +61,9 @@ fun BitchatSheetCenterTopBar( navigationIcon = { navigationIcon?.invoke() }, actions = { actions() + val dismiss = LocalSheetDismiss.current CloseButton( - onClick = onClose, + onClick = { dismiss?.invoke() ?: onClose() }, modifier = Modifier.padding(horizontal = 16.dp) ) }, @@ -80,7 +83,7 @@ fun BitchatSheetTitle(text: String) { text = text, style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } diff --git a/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt b/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt index 670d4cfc..a6c72852 100644 --- a/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt @@ -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
{ + override suspend fun getFromLocation( + latitude: Double, + longitude: Double, + maxResults: Int, + liveLocationToken: Long? + ): List
{ 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
) { - if (cont.isActive) cont.resume(addresses) - } + val startRequest = { + geocoder.getFromLocation( + latitude, + longitude, + maxResults, + object : Geocoder.GeocodeListener { + override fun onGeocode(addresses: MutableList
) { + 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() } } diff --git a/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt index b6d29c90..06439c80 100644 --- a/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt @@ -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() 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") } } } diff --git a/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt b/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt index bb4d69c2..34197565 100644 --- a/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt @@ -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
+ suspend fun getFromLocation( + latitude: Double, + longitude: Double, + maxResults: Int, + liveLocationToken: Long? = null + ): List
} diff --git a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt index 81d83b79..92e237cb 100644 --- a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt +++ b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt @@ -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) } diff --git a/app/src/main/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicy.kt b/app/src/main/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicy.kt new file mode 100644 index 00000000..26b9fd3c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicy.kt @@ -0,0 +1,24 @@ +package com.bitchat.android.geohash + +internal object GeohashNostrPrivacyPolicy { + fun livePresenceTargets( + availableChannels: Collection, + liveLocationEnabled: Boolean, + ): Set { + if (!liveLocationEnabled) return emptySet() + return availableChannels + .asSequence() + .filter { it.level.precision <= GeohashChannelLevel.CITY.precision } + .map { it.geohash } + .toSet() + } + + fun samplingTargets( + liveLocationGeohashes: Collection, + userSelectedGeohashes: Collection, + liveLocationEnabled: Boolean, + ): Set = buildSet { + addAll(userSelectedGeohashes) + if (liveLocationEnabled) addAll(liveLocationGeohashes) + } +} diff --git a/app/src/main/java/com/bitchat/android/geohash/LiveLocationPrivacyGate.kt b/app/src/main/java/com/bitchat/android/geohash/LiveLocationPrivacyGate.kt new file mode 100644 index 00000000..5a4f30fb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/LiveLocationPrivacyGate.kt @@ -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 = _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 = 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) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt index 6c91c32e..46f05950 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -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 = _isLoadingLocation - private val _locationServicesEnabled = MutableStateFlow(false) - val locationServicesEnabled: StateFlow = _locationServicesEnabled + val locationServicesEnabled: StateFlow = LiveLocationPrivacyGate.enabled private val _systemLocationEnabled = MutableStateFlow(checkSystemLocationEnabled()) val systemLocationEnabled: StateFlow = _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() @@ -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) } } diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/LocationProvider.kt index 0ead523d..f46837bd 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationProvider.kt @@ -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. diff --git a/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt b/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt index 587d2970..11be053e 100644 --- a/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt @@ -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
{ - return withContext(Dispatchers.IO) { + override suspend fun getFromLocation( + latitude: Double, + longitude: Double, + maxResults: Int, + liveLocationToken: Long? + ): List
{ + 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
() + 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
() + 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
() - - val address = mapToAddress(osmResponse, latitude, longitude) - listOf(address) - } catch (e: Exception) { - Log.e(TAG, "OSM Parse failed: ${e.message}") - emptyList
() + 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
() + }) + } + val started = if (liveLocationToken == null) { + enqueueRequest() + true + } else { + LiveLocationPrivacyGate.runIfAllowed( + liveLocationToken, + enqueueRequest + ) + } + if (!started && continuation.isActive) { + continuation.resume(emptyList()) } } } diff --git a/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt index 61c9d267..80bbbe4a 100644 --- a/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt @@ -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") } } } diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt index 795cce34..dbd3888b 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt @@ -30,12 +30,12 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BitchatTheme import com.bitchat.android.util.UniversalApkManager import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -112,7 +112,7 @@ fun HotspotScreen( title = { Text( text = "Share BitChat", - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) }, navigationIcon = { @@ -398,7 +398,7 @@ fun ActiveHotspotScreen(state: HotspotViewModel.HotspotState.Active) { text = { Text( text = title, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal ) } @@ -615,7 +615,7 @@ fun CredentialCard( Text( text = value, style = MaterialTheme.typography.bodyLarge, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSecondaryContainer ) diff --git a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt index 8e1731b1..528a269e 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt @@ -69,7 +69,15 @@ data class BitchatMessage( val encryptedContent: ByteArray? = null, val isEncrypted: Boolean = false, val deliveryStatus: DeliveryStatus? = null, - val powDifficulty: Int? = null + val powDifficulty: Int? = null, + /** + * Full canonical Nostr public key supplied by the local Nostr bridge. + * + * This is local identity metadata, not part of the Bitchat binary wire format. It lets UI + * surfaces color the sender by the same stable key while [senderPeerID] remains available for + * mesh IDs and private-chat routing aliases. + */ + val senderNostrPubkey: String? = null ) : Parcelable { /** @@ -355,4 +363,3 @@ data class BitchatMessage( } } - diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index 1c4896f3..1fc802d9 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -96,6 +96,7 @@ class GeohashMessageHandler( isRelay = false, originalSender = repo.displayNameForNostrPubkey(pubkey), senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, mentions = null, channel = "#$subscribedGeohash", powDifficulty = try { diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt index bed29902..d725f3e1 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt @@ -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 -> diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt index 2fa962ef..a6927d71 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -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?) -> Unit)? = null + private var sendEventFunc: ((NostrEvent, List?, 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?) -> Unit, + sendEvent: (NostrEvent, List?, 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 = 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 } diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt b/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt index db377803..ca4feae6 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt @@ -10,6 +10,47 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import kotlin.math.min +internal class BitchatNdrRelayAdapter( + private val relayManager: NostrRelayManager, + private val accountRelayUrls: List = NostrRelayManager.defaultRelays() +) : NdrRelayManager { + override fun subscribe( + filter: NostrFilter, + id: String, + handler: (NostrEvent) -> Boolean + ) { + relayManager.subscribeAfterSuccessfulProcessing( + filter = filter, + id = id, + targetRelayUrls = accountRelayUrls, + handler = handler + ) + } + + override fun unsubscribe(id: String) { + relayManager.unsubscribe(id) + } + + override fun sendEventConfirmed( + event: NostrEvent, + completion: (accepted: Boolean) -> Unit + ) { + relayManager.sendEventConfirmed( + event = event, + relayUrls = accountRelayUrls, + completion = completion + ) + } + + override fun cancelConfirmedEvent(eventId: String) { + relayManager.cancelConfirmedEvent(eventId) + } + + override fun setOnConnectionAvailable(handler: () -> Unit) { + relayManager.setNdrConnectionAvailableHandler(handler) + } +} + class NdrNostrService( private val relayManager: NdrRelayManager, private val runtimeFactory: NdrPairwiseRuntimeFactory, @@ -65,37 +106,8 @@ class NdrNostrService( val storageDirectory = context.filesDir.resolve("ndr") val establishedMarkerDirectory = context.filesDir.resolve("ndr-established-sessions") - val relayManager = object : NdrRelayManager { - override fun subscribe( - filter: NostrFilter, - id: String, - handler: (NostrEvent) -> Boolean - ) { - NostrRelayManager.getInstance(context) - .subscribeAfterSuccessfulProcessing(filter, id, handler) - } - - override fun unsubscribe(id: String) { - NostrRelayManager.getInstance(context).unsubscribe(id) - } - - override fun sendEventConfirmed( - event: NostrEvent, - completion: (accepted: Boolean) -> Unit - ) { - NostrRelayManager.getInstance(context) - .sendEventConfirmed(event, completion = completion) - } - - override fun cancelConfirmedEvent(eventId: String) { - NostrRelayManager.getInstance(context).cancelConfirmedEvent(eventId) - } - - override fun setOnConnectionAvailable(handler: () -> Unit) { - NostrRelayManager.getInstance(context) - .setNdrConnectionAvailableHandler(handler) - } - } + val relayManager = + BitchatNdrRelayAdapter(NostrRelayManager.getInstance(context)) val runtimeFactory = object : NdrPairwiseRuntimeFactory { override fun newWithStoragePath( diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt index a4803157..c1488037 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt @@ -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") } /** diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 4aa8f33d..81b1c555 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -332,8 +332,12 @@ class NostrDirectMessageHandler( isPrivate = true, recipientNickname = state.getNicknameValue(), senderPeerID = conversationID, + senderNostrPubkey = senderPubkey, deliveryStatus = - DeliveryStatus.Delivered(to = state.getNicknameValue(), at = Date()) + DeliveryStatus.Delivered( + to = state.getNicknameValue(), + at = Date() + ) ) val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID @@ -435,7 +439,8 @@ class NostrDirectMessageHandler( isRelay = false, isPrivate = true, recipientNickname = state.getNicknameValue(), - senderPeerID = conversationID + senderPeerID = conversationID, + senderNostrPubkey = senderPubkey ) } ) return NdrDeliveryResult.REJECTED diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt index 7b8552be..665bfd50 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt @@ -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() + private val geohashIdentityCache = ConcurrentHashMap() /** * 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) { + geohashes.forEach(geohashIdentityCache::remove) + } // MARK: - Private Methods diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacy.kt b/app/src/main/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacy.kt new file mode 100644 index 00000000..77f27387 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacy.kt @@ -0,0 +1,13 @@ +package com.bitchat.android.nostr + +internal object NostrLiveSubscriptionPrivacy { + fun closeTargets( + liveSubscriptionIds: Set, + subscriptionsByRelay: Map>, + ): Map> = buildMap { + subscriptionsByRelay.forEach { (relayUrl, relaySubscriptionIds) -> + val matchingIds = relaySubscriptionIds.intersect(liveSubscriptionIds) + if (matchingIds.isNotEmpty()) put(relayUrl, matchingIds) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index 251dba28..38b772a3 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -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 @@ -108,11 +110,17 @@ class NostrRelayManager internal constructor( val handler: (NostrEvent) -> Unit, val targetRelayUrls: Set? = null, // null means all relays val createdAt: Long = System.currentTimeMillis(), - val originGeohash: String? = null // used for logging and grouping + val liveLocationToken: Long? = null ) // Message queue for reliability - private val messageQueue = mutableListOf>>() + private data class QueuedEvent( + val event: NostrEvent, + val targetRelays: List, + val liveLocationToken: Long? = null + ) + + private val messageQueue = mutableListOf() private val messageQueueLock = Any() private data class ConfirmedPublish( @@ -137,27 +145,49 @@ class NostrRelayManager internal constructor( // Per-geohash relay selection private val geohashToRelays = ConcurrentHashMap>() // geohash -> relay URLs + private val liveGeohashTokens = ConcurrentHashMap() + private val liveLocationRelayTokens = ConcurrentHashMap() + private val nonLiveRelayUrls = ConcurrentHashMap.newKeySet() + private val liveLocationConnectionJobs = ConcurrentHashMap.newKeySet() // --- Public API for geohash-specific operation --- /** * Compute and connect to relays for a given geohash (nearest + optional defaults), cache the mapping. */ - fun ensureGeohashRelaysConnected(geohash: String, nRelays: Int = 5, includeDefaults: Boolean = false) { + fun ensureGeohashRelaysConnected( + geohash: String, + nRelays: Int = 5, + includeDefaults: Boolean = false, + liveLocationToken: Long? = null + ) { + if (!isNetworkActionAllowed(liveLocationToken)) return try { val nearest = RelayDirectory.closestRelaysForGeohash(geohash, nRelays) val selected = if (includeDefaults) { (nearest + Companion.defaultRelays()).toSet() } else nearest.toSet() if (selected.isEmpty()) { - Log.w(TAG, "No relays selected for geohash=$geohash") + Log.w(TAG, "No relays selected for a geohash") return } - geohashToRelays[geohash] = selected - Log.d(TAG, "Geohash $geohash using ${selected.size} relays") - ensureConnectionsFor(selected) + runNetworkAction(liveLocationToken) { + geohashToRelays[geohash] = selected + if (liveLocationToken == null) { + liveGeohashTokens.remove(geohash) + nonLiveRelayUrls.addAll(selected) + } else { + liveGeohashTokens[geohash] = liveLocationToken + selected.forEach { relayUrl -> + if (relayUrl !in nonLiveRelayUrls) { + liveLocationRelayTokens[relayUrl] = liveLocationToken + } + } + } + ensureConnectionsFor(selected, liveLocationToken) + } } catch (e: Exception) { - Log.e(TAG, "Failed to ensure relays for $geohash: ${e.message}") + Log.e(TAG, "Failed to ensure geohash relays") } } @@ -177,40 +207,135 @@ class NostrRelayManager internal 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) { + 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) { + 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, + liveLocationToken: Long? = null + ) { + if (!isNetworkActionAllowed(liveLocationToken)) return // Ensure relays are tracked for UI/status relayUrls.forEach { url -> if (relaysList.none { it.url == url }) { @@ -219,15 +344,22 @@ class NostrRelayManager internal 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 { @@ -240,8 +372,10 @@ class NostrRelayManager internal 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 @@ -254,12 +388,14 @@ class NostrRelayManager internal 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) + } } } } @@ -272,8 +408,6 @@ class NostrRelayManager internal constructor( * Disconnect from all relays */ fun disconnect() { - Log.i(TAG, "Disconnecting from all Nostr relays") - // Stop subscription validation stopSubscriptionValidation() @@ -295,23 +429,28 @@ class NostrRelayManager internal constructor( /** * Send an event to specified relays (or all if none specified) */ - fun sendEvent(event: NostrEvent, relayUrls: List? = null) { + fun sendEvent( + event: NostrEvent, + relayUrls: List? = 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 } /** @@ -389,14 +528,15 @@ class NostrRelayManager internal constructor( filter: NostrFilter, id: String = generateSubscriptionId(), handler: (NostrEvent) -> Unit, - targetRelayUrls: List? = null + targetRelayUrls: List? = 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 ) return registerSubscription( subscriptionInfo = subscriptionInfo, @@ -412,12 +552,14 @@ class NostrRelayManager internal constructor( fun subscribeAfterSuccessfulProcessing( filter: NostrFilter, id: String, + targetRelayUrls: List? = null, handler: (NostrEvent) -> Boolean ): String { val subscriptionInfo = SubscriptionInfo( id = id, filter = filter, - handler = {} + handler = {}, + targetRelayUrls = targetRelayUrls?.toSet() ) return registerSubscription( subscriptionInfo = subscriptionInfo, @@ -436,15 +578,17 @@ class NostrRelayManager internal constructor( commitAwareHandler: ((NostrEvent) -> Boolean)? = null ): String { require((ordinaryHandler == null) != (commitAwareHandler == null)) - activeSubscriptions[subscriptionInfo.id] = subscriptionInfo - if (commitAwareHandler != null) { - commitAwareMessageHandlers[subscriptionInfo.id] = commitAwareHandler - messageHandlers.remove(subscriptionInfo.id) - } else { - messageHandlers[subscriptionInfo.id] = requireNotNull(ordinaryHandler) - commitAwareMessageHandlers.remove(subscriptionInfo.id) + runNetworkAction(subscriptionInfo.liveLocationToken) { + activeSubscriptions[subscriptionInfo.id] = subscriptionInfo + if (commitAwareHandler != null) { + commitAwareMessageHandlers[subscriptionInfo.id] = commitAwareHandler + messageHandlers.remove(subscriptionInfo.id) + } else { + messageHandlers[subscriptionInfo.id] = requireNotNull(ordinaryHandler) + commitAwareMessageHandlers.remove(subscriptionInfo.id) + } + sendSubscriptionToRelays(subscriptionInfo) } - sendSubscriptionToRelays(subscriptionInfo) return subscriptionInfo.id } @@ -452,32 +596,38 @@ class NostrRelayManager internal 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") } } } @@ -492,7 +642,14 @@ class NostrRelayManager internal constructor( commitAwareMessageHandlers.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 } @@ -500,14 +657,21 @@ class NostrRelayManager internal 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") } } } @@ -519,6 +683,9 @@ class NostrRelayManager internal 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 @@ -530,7 +697,7 @@ class NostrRelayManager internal constructor( // Attempt immediate reconnection scope.launch { - connectToRelay(relayUrl) + connectToRelay(relayUrl, liveToken) } } @@ -680,7 +847,7 @@ class NostrRelayManager internal 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) -> @@ -692,7 +859,7 @@ class NostrRelayManager internal 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) } } @@ -702,6 +869,7 @@ class NostrRelayManager internal constructor( } } } + } /** @@ -714,7 +882,13 @@ class NostrRelayManager internal 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 @@ -725,21 +899,35 @@ class NostrRelayManager internal 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): Boolean { + private fun sendToRelay( + event: NostrEvent, + webSocket: WebSocket, + relayUrl: String, + liveLocationToken: Long? = null + ): Boolean { + if (!isNetworkActionAllowed(liveLocationToken)) return false return try { val request = NostrRequest.Event(event) val message = gson.toJson(request, NostrRequest::class.java) - val success = webSocket.send(message) + var success = false + runNetworkAction(liveLocationToken) { + success = webSocket.send(message) + } if (success) { // Update relay stats val relay = relaysList.find { it.url == relayUrl } @@ -760,7 +948,7 @@ class NostrRelayManager internal 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 } @@ -774,7 +962,10 @@ class NostrRelayManager internal 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 @@ -799,12 +990,15 @@ class NostrRelayManager internal 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 -> { @@ -824,24 +1018,30 @@ class NostrRelayManager internal constructor( } if (!isNip20ConfirmedSuccess(response.accepted, response.message)) { 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) confirmedPublishes.entries.toList().forEach { (eventId, tracker) -> if (tracker.awaitingRelayUrls.remove(relayUrl) && @@ -854,6 +1054,7 @@ class NostrRelayManager internal constructor( // 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() ?: "" @@ -863,7 +1064,7 @@ class NostrRelayManager internal 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 } @@ -874,7 +1075,7 @@ class NostrRelayManager internal 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 } @@ -886,12 +1087,14 @@ class NostrRelayManager internal 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) + } } } @@ -923,7 +1126,7 @@ class NostrRelayManager internal constructor( } private fun generateSubscriptionId(): String { - return "sub-${System.currentTimeMillis()}-${(Math.random() * 1000).toInt()}" + return "sub-${UUID.randomUUID()}" } /** @@ -932,30 +1135,34 @@ class NostrRelayManager internal 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") } } } @@ -963,10 +1170,17 @@ class NostrRelayManager internal 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) runCatching { ndrConnectionAvailableHandler?.invoke() } .onFailure { Log.w(TAG, "NDR reconnect callback failed") } @@ -978,9 +1192,16 @@ class NostrRelayManager internal 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 + ) } } } @@ -995,14 +1216,13 @@ class NostrRelayManager internal 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) } } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt index 1a8514ab..4b8365a1 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt @@ -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 + ) } } diff --git a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt index f591b2b8..980b95ef 100644 --- a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt @@ -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 { } } } - diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt index 1346cb23..50a58beb 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt @@ -1,5 +1,8 @@ package com.bitchat.android.onboarding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Security import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,9 +16,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.LocationOn -import androidx.compose.material.icons.filled.Security import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ColorScheme @@ -28,11 +28,11 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -99,7 +99,7 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.background_location_settings_tip), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = colorScheme.onBackground.copy(alpha = 0.8f) ) @@ -140,14 +140,14 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.background_location_needs_bullets), style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.8f) ) Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(R.string.background_location_privacy_note), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium ), color = colorScheme.onBackground @@ -183,7 +183,7 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.grant_background_location), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -201,7 +201,7 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.check_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } @@ -213,7 +213,7 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.battery_optimization_skip), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } @@ -234,7 +234,7 @@ private fun HeaderSection(colorScheme: ColorScheme) { Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -244,7 +244,7 @@ private fun HeaderSection(colorScheme: ColorScheme) { Text( text = stringResource(R.string.background_location_required_subtitle), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } diff --git a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt index 3fe392d6..713fe7f8 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt @@ -1,13 +1,13 @@ package com.bitchat.android.onboarding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* import androidx.compose.animation.core.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -15,11 +15,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -105,7 +105,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -115,7 +115,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.battery_optimization_detected_title), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } @@ -225,7 +225,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.disable_battery_optimization), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ) ) @@ -243,7 +243,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.check_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } @@ -259,7 +259,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.battery_optimization_skip), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } @@ -284,7 +284,7 @@ private fun BatteryOptimizationCheckingContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -294,7 +294,7 @@ private fun BatteryOptimizationCheckingContent( Text( text = stringResource(R.string.battery_optimization_disabled_title), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } @@ -322,7 +322,7 @@ private fun BatteryOptimizationCheckingContent( Text( text = stringResource(R.string.battery_optimization_success_message), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.8f) ), textAlign = TextAlign.Center @@ -347,7 +347,7 @@ private fun BatteryOptimizationNotSupportedContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -357,7 +357,7 @@ private fun BatteryOptimizationNotSupportedContent( Text( text = stringResource(R.string.battery_optimization_not_required), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } @@ -372,7 +372,7 @@ private fun BatteryOptimizationNotSupportedContent( Text( text = stringResource(R.string.battery_optimization_not_supported_message), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.8f) ), textAlign = TextAlign.Center @@ -388,7 +388,7 @@ private fun BatteryOptimizationNotSupportedContent( Text( text = stringResource(R.string.continue_btn), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt index 60c6e5e4..3eb6eea9 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt @@ -1,20 +1,20 @@ package com.bitchat.android.onboarding -import androidx.compose.animation.core.* -import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -83,7 +83,7 @@ private fun BluetoothDisabledContent( Text( text = stringResource(R.string.bluetooth_recommended), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -114,7 +114,7 @@ private fun BluetoothDisabledContent( Text( text = stringResource(R.string.bluetooth_needs_bullets), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ) ) @@ -138,7 +138,7 @@ private fun BluetoothDisabledContent( Text( text = stringResource(R.string.enable_bluetooth), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -187,7 +187,7 @@ private fun BluetoothNotSupportedContent( Text( text = stringResource(R.string.bluetooth_not_supported), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.error ), @@ -204,7 +204,7 @@ private fun BluetoothNotSupportedContent( Text( text = stringResource(R.string.bluetooth_unsupported_explanation), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface ), modifier = Modifier.padding(16.dp), @@ -235,7 +235,7 @@ private fun BluetoothCheckingContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -247,7 +247,7 @@ private fun BluetoothCheckingContent( Text( text = stringResource(R.string.checking_bluetooth_status), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.7f) ) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt index bd231afd..f7895c65 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt @@ -8,11 +8,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -63,7 +63,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -92,7 +92,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.initializing_mesh_network), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.7f) ) ) @@ -102,7 +102,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.dot), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = alpha) ) ) @@ -127,7 +127,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.setting_up_bluetooth), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ), textAlign = TextAlign.Center @@ -136,7 +136,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.should_take_seconds), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.6f) ), textAlign = TextAlign.Center @@ -184,7 +184,7 @@ fun InitializationErrorScreen( Text( text = stringResource(R.string.setup_not_complete), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.error ), @@ -201,7 +201,7 @@ fun InitializationErrorScreen( Text( text = errorMessage, style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface ), modifier = Modifier.padding(16.dp), @@ -220,7 +220,7 @@ fun InitializationErrorScreen( Text( text = stringResource(R.string.try_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -234,7 +234,7 @@ fun InitializationErrorScreen( Text( text = stringResource(R.string.open_settings), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), modifier = Modifier.padding(vertical = 4.dp) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt index c5bb8c9b..3654aba8 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt @@ -1,21 +1,21 @@ package com.bitchat.android.onboarding -import androidx.compose.animation.core.* -import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -80,7 +80,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.location_services_required), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -122,7 +122,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.location_explanation), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ) ) @@ -142,7 +142,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.location_needs_bullets), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ) ) @@ -166,7 +166,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.open_location_settings), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -180,7 +180,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.check_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), modifier = Modifier.padding(vertical = 4.dp) ) @@ -209,7 +209,7 @@ private fun LocationNotAvailableContent( Text( text = stringResource(R.string.location_services_unavailable), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.error ), @@ -226,7 +226,7 @@ private fun LocationNotAvailableContent( Text( text = stringResource(R.string.location_unavailable_explanation), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface ), modifier = Modifier.padding(16.dp), @@ -247,7 +247,7 @@ private fun LocationCheckingContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -259,7 +259,7 @@ private fun LocationCheckingContent( Text( text = stringResource(R.string.checking_location_services), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.7f) ) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt index 48138d66..afd91da3 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -1,9 +1,5 @@ package com.bitchat.android.onboarding -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.LocationOn @@ -13,17 +9,21 @@ import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.filled.Settings +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -68,7 +68,7 @@ fun PermissionExplanationScreen( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -79,7 +79,7 @@ fun PermissionExplanationScreen( Text( text = stringResource(R.string.about_tagline), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } @@ -117,7 +117,7 @@ fun PermissionExplanationScreen( Text( text = stringResource(R.string.privacy_bullets), style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.8f) ) } @@ -164,7 +164,7 @@ fun PermissionExplanationScreen( Text( text = stringResource(R.string.grant_permissions), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt new file mode 100644 index 00000000..e36ee7be --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -0,0 +1,605 @@ +package com.bitchat.android.ui + +import androidx.annotation.DrawableRes +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.R +import com.bitchat.android.core.ui.icon.BitChatIcon +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette + +/** + * Building blocks for the redesigned About sheet. + * + * Kept in a separate file from [AboutSheet] because the sheet itself is mostly wiring for + * preferences, whereas these are pure presentation. + */ + +/** Horizontal inset shared by every About section, so cards and labels align to one grid. */ +internal val AboutHorizontalPadding = 20.dp + +/** Card corner radius for grouped rows. */ +internal val AboutCardShape = RoundedCornerShape(16.dp) + +/** Leading icon column in settings-style sheet rows. */ +internal val SheetRowLeadingSlot = 22.dp +internal val SheetRowLeadingGutter = 16.dp +internal val SheetRowHorizontal = 16.dp +internal val SheetRowVertical = 13.dp + +/** + * Exact height of a people-list row. + * + * Fixed rather than derived from content: these lists reorder themselves constantly, and a row + * whose height depends on its content makes the whole card change height every time the order + * changes. Equals the leading glyph plus [SheetRowVertical] above and below. + */ +internal val SheetRowHeight = SheetRowLeadingSlot + SheetRowVertical * 2 +internal val SheetRowDividerInset = SheetRowHorizontal + SheetRowLeadingSlot + SheetRowLeadingGutter +/** Selection indicator sized for [SheetRowLeadingSlot]. */ +internal val SheetRowSelectedDot = 12.dp + +/** + * Two top-level views of the sheet: what the app is and how to drive it, versus the knobs. + */ +enum class AboutTab { + Info, + Settings, +} + +/** + * Small uppercase section label, e.g. `SETTINGS`. + * + * Uppercasing happens here rather than in the string resource so translators supply natural + * sentence case and locales without a case distinction are unaffected. + */ +@Composable +internal fun AboutSectionLabel( + text: String, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + Text( + text = text.uppercase(), + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = palette.textTertiary, + modifier = modifier.padding(start = AboutHorizontalPadding, top = 24.dp, bottom = 8.dp) + ) +} + +/** + * Icon + title on one line, optional short subtitle beneath. Used by location / network sheets. + */ +@Composable +internal fun SheetIconSectionHeader( + @DrawableRes iconRes: Int, + title: String, + subtitle: String? = null, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + Text( + text = title, + fontSize = 17.sp, + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + color = colorScheme.primary + ) + } + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + fontSize = 12.sp, + lineHeight = 17.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onSurfaceVariant + ) + } + } +} + +/** Inset divider used inside grouped sheet cards (aligns with text column after the leading slot). */ +@Composable +internal fun SheetCardDivider() { + val colorScheme = MaterialTheme.colorScheme + HorizontalDivider( + modifier = Modifier.padding(start = SheetRowDividerInset), + thickness = 1.dp, + color = colorScheme.outlineVariant + ) +} + +/** + * Centered app identity block: logo, wordmark, tagline, version. + */ +@Composable +internal fun AboutHero( + versionName: String, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = BitChatIcon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(64.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.app_name), + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 40.sp, + // Monospace at display size leaves too much air between glyphs; pull it in slightly + // so the wordmark reads as a single unit. + letterSpacing = (-0.5).sp, + color = colorScheme.primary + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.about_tagline), + fontFamily = BitchatFontFamily, + fontSize = 16.sp, + color = colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = stringResource(R.string.version_prefix, versionName), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = palette.textTertiary + ) + } +} + +/** + * Two-up tab bar with a sliding underline indicator. + * + * The indicator animates its offset rather than cross-fading two static bars, which is what + * makes the switch feel physically connected to the tap. + */ +@Composable +internal fun AboutTabBar( + selected: AboutTab, + onSelect: (AboutTab) -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val density = LocalDensity.current + + var rowWidth by remember { mutableStateOf(0.dp) } + val tabWidth = rowWidth / 2 + val indicatorOffset by animateDpAsState( + targetValue = if (selected == AboutTab.Info) 0.dp else tabWidth, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "aboutTabIndicator" + ) + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .onSizeChanged { size -> + rowWidth = with(density) { size.width.toDp() } + } + ) { + Row(modifier = Modifier.fillMaxWidth()) { + AboutTabLabel( + text = stringResource(R.string.about_tab_info), + isSelected = selected == AboutTab.Info, + onClick = { onSelect(AboutTab.Info) }, + modifier = Modifier.weight(1f) + ) + AboutTabLabel( + text = stringResource(R.string.about_tab_settings), + isSelected = selected == AboutTab.Settings, + onClick = { onSelect(AboutTab.Settings) }, + modifier = Modifier.weight(1f) + ) + } + + Box(modifier = Modifier.fillMaxWidth()) { + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) + Box( + modifier = Modifier + // Lambda overload: the offset is animated every frame, and the non-lambda + // version would invalidate composition rather than just layout. + .offset { IntOffset(x = indicatorOffset.roundToPx(), y = 0) } + .width(tabWidth) + .height(2.dp) + .background(colorScheme.primary) + ) + } + } +} + +@Composable +private fun AboutTabLabel( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + + val color by animateColorAsState( + targetValue = if (isSelected) colorScheme.primary else colorScheme.onSurfaceVariant, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + label = "aboutTabLabelColor" + ) + + Box( + modifier = modifier + .height(44.dp) + .clickable(onClickLabel = text) { onClick() }, + contentAlignment = Alignment.Center + ) { + Text( + text = text.uppercase(), + fontFamily = BitchatFontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = color + ) + } +} + +/** One line of the "How To Use" list: an icon plus a single instruction. */ +@Composable +private fun AboutInstructionRow( + @DrawableRes iconRes: Int, + text: String +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 1.dp) + .size(22.dp) + ) + Text( + text = text, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + lineHeight = 20.sp, + color = colorScheme.onSurface + ) + } +} + +/** + * The "How To Use" tab: a short, scannable list of the gestures that are not self-evident. + */ +@Composable +internal fun AboutHowToUseSection(modifier: Modifier = Modifier) { + val colorScheme = MaterialTheme.colorScheme + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.about_how_to_use_heading), + fontFamily = BitchatFontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = colorScheme.primary, + modifier = Modifier.padding( + start = AboutHorizontalPadding, + top = 20.dp, + bottom = 8.dp + ) + ) + + AboutInstructionRow( + iconRes = R.drawable.ic_spec_person, + text = stringResource(R.string.about_howto_nickname) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_globe, + text = stringResource(R.string.about_howto_channels) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_people, + text = stringResource(R.string.about_howto_people) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_bookmark_outline, + text = stringResource(R.string.about_howto_bookmark) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_mention, + text = stringResource(R.string.about_howto_mention) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_command, + text = stringResource(R.string.about_howto_commands) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_waveform, + text = stringResource(R.string.about_howto_panic) + ) + } +} + +/** + * Capability list, laid out like [AboutHowToUseSection]: flat rows, no card surface or dividers. + */ +@Composable +internal fun AboutFeatureCard(modifier: Modifier = Modifier) { + val features = listOf( + Triple( + R.drawable.ic_spec_wifi_off, + R.string.about_offline_mesh_title, + R.string.about_offline_mesh_desc + ), + Triple( + R.drawable.ic_spec_lock, + R.string.about_e2e_title, + R.string.about_e2e_desc + ), + Triple( + R.drawable.ic_spec_globe, + R.string.about_online_geohash_title, + R.string.about_online_geohash_desc + ), + Triple( + R.drawable.ic_spec_eye_off, + R.string.about_no_tracking_title, + R.string.about_no_tracking_desc + ), + ) + + Column(modifier = modifier.fillMaxWidth()) { + features.forEach { (icon, titleRes, descRes) -> + AboutFeatureRow( + iconRes = icon, + title = stringResource(titleRes), + subtitle = stringResource(descRes) + ) + } + } +} + +@Composable +private fun AboutFeatureRow( + @DrawableRes iconRes: Int, + title: String, + subtitle: String +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 1.dp) + .size(22.dp) + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + lineHeight = 20.sp, + color = colorScheme.onSurface + ) + Text( + text = subtitle, + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + lineHeight = 17.sp, + color = colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Small uppercase pill, e.g. the `RECOMMENDED` badge beside the Tor routing toggle. + */ +@Composable +internal fun BitchatBadge( + text: String, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + Box( + modifier = modifier + .background(colorScheme.primary.copy(alpha = 0.15f), RoundedCornerShape(4.dp)) + .padding(horizontal = 5.dp, vertical = 2.dp) + ) { + Text( + text = text.uppercase(), + fontFamily = BitchatFontFamily, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + color = colorScheme.primary + ) + } +} + +// MARK: - Shared bottom-sheet primitives + +/** Horizontal inset for sheet content. Cards inset from here; labels align to the same edge. */ +internal val SheetHorizontalPadding = 16.dp + +/** + * Section divider label used across the sheets, e.g. `BOOKMARKED`, `NEARBY`, `ON LOCATION`. + */ +@Composable +internal fun SheetSectionLabel( + text: String, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + Text( + text = text.uppercase(), + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = palette.textTertiary, + modifier = modifier + .fillMaxWidth() + .padding( + start = SheetHorizontalPadding + 14.dp, + end = SheetHorizontalPadding, + top = 20.dp, + bottom = 6.dp + ) + ) +} + +/** + * Circular tinted badge holding a section's icon, used at the top of the sheets. + */ +@Composable +internal fun SheetHeaderBadge( + icon: ImageVector, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + Box( + modifier = modifier + .size(44.dp) + .background(colorScheme.surface, androidx.compose.foundation.shape.CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(26.dp) + ) + } +} + +/** + * Full-width destructive action, e.g. `REMOVE LOCATION ACCESS`. + * + * Tinted fill plus an outline rather than a solid red button: the action is legitimate but + * rarely wanted, and a solid red block would dominate the sheet. + */ +@Composable +internal fun SheetDestructiveButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isDestructive: Boolean = true +) { + val colorScheme = MaterialTheme.colorScheme + val accent = if (isDestructive) colorScheme.error else colorScheme.primary + + Surface( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .height(44.dp), + shape = RoundedCornerShape(10.dp), + color = accent.copy(alpha = 0.10f), + border = BorderStroke(1.dp, accent.copy(alpha = 0.30f)) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = text.uppercase(), + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = accent + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 7a64ff50..67065182 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -3,11 +3,19 @@ package com.bitchat.android.ui import android.content.Intent import android.widget.Toast import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Speed import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -26,43 +34,17 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -70,14 +52,15 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.hotspot.HotspotActivity import com.bitchat.android.net.ArtiTorManager @@ -85,51 +68,10 @@ import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager import com.bitchat.android.nostr.NostrProofOfWork import com.bitchat.android.nostr.PoWPreferenceManager +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.util.UniversalApkManager -/** - * Feature row for displaying app capabilities - */ -@Composable -private fun FeatureRow( - icon: ImageVector, - title: String, - subtitle: String -) { - val colorScheme = MaterialTheme.colorScheme - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.Top - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier - .padding(top = 2.dp) - .size(22.dp) - ) - Spacer(modifier = Modifier.width(14.dp)) - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface.copy(alpha = 0.6f), - lineHeight = 18.sp - ) - } - } -} - /** * Theme selection chip with Apple-like styling */ @@ -141,17 +83,25 @@ private fun ThemeChip( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - + + // Cross-fade the chip so switching theme does not read as two separate flashes (the chip + // recolouring plus the whole app recolouring underneath it). + val containerColor by animateColorAsState( + targetValue = if (selected) colorScheme.primary else colorScheme.surfaceVariant, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "themeChipContainer" + ) + val labelColor by animateColorAsState( + targetValue = if (selected) Color.White else colorScheme.onSurfaceVariant, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "themeChipLabel" + ) + Surface( modifier = modifier, onClick = onClick, shape = RoundedCornerShape(10.dp), - color = if (selected) { - if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - } else { - colorScheme.surfaceVariant.copy(alpha = 0.5f) - } + color = containerColor ) { Box( modifier = Modifier @@ -161,9 +111,10 @@ private fun ThemeChip( ) { Text( text = label, - style = MaterialTheme.typography.bodySmall, + fontFamily = BitchatFontFamily, + fontSize = 13.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - color = if (selected) Color.White else colorScheme.onSurface.copy(alpha = 0.8f) + color = labelColor ) } } @@ -184,23 +135,49 @@ private fun SettingsToggleRow( statusIndicator: (@Composable () -> Unit)? = null ) { val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - + val palette = LocalBitchatPalette.current + val interactionSource = remember { MutableInteractionSource() } + + // Colours cross-fade so a row becoming available (Tor finishing bootstrap) eases in rather + // than popping. + val iconTint by animateColorAsState( + targetValue = if (enabled) colorScheme.primary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowIcon" + ) + val titleColor by animateColorAsState( + targetValue = if (enabled) colorScheme.onSurface else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowTitle" + ) + val subtitleColor by animateColorAsState( + targetValue = if (enabled) colorScheme.onSurfaceVariant else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowSubtitle" + ) + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), + // The whole row toggles, not just the switch: a 14.dp-tall switch is a poor target + // when there is a full-width row sitting right next to it. + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled + ) { onCheckedChange(!checked) } + .padding(horizontal = 16.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = icon, contentDescription = null, - tint = if (enabled) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0.3f), + tint = iconTint, modifier = Modifier.size(22.dp) ) - - Spacer(modifier = Modifier.width(14.dp)) - + + Spacer(modifier = Modifier.width(16.dp)) + Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) @@ -211,29 +188,32 @@ private fun SettingsToggleRow( ) { Text( text = title, - style = MaterialTheme.typography.bodyMedium, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = if (enabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f) + color = titleColor ) statusIndicator?.invoke() } Text( text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f), - lineHeight = 16.sp + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = subtitleColor, + lineHeight = 17.sp ) } - + Spacer(modifier = Modifier.width(16.dp)) - + Switch( checked = checked, onCheckedChange = { if (enabled) onCheckedChange(it) }, enabled = enabled, + interactionSource = interactionSource, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, - checkedTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), + checkedTrackColor = colorScheme.primary, uncheckedThumbColor = Color.White, uncheckedTrackColor = colorScheme.surfaceVariant ) @@ -272,12 +252,14 @@ fun AboutSheet( } val topBarAlpha by animateFloatAsState( targetValue = if (isScrolled) 0.98f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), label = "topBarAlpha" ) val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - + val palette = LocalBitchatPalette.current + var selectedTab by remember { mutableStateOf(AboutTab.Info) } + if (isPresented) { BitchatBottomSheet( modifier = modifier, @@ -287,103 +269,49 @@ fun AboutSheet( LazyColumn( state = lazyListState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 80.dp, bottom = 32.dp), - verticalArrangement = Arrangement.spacedBy(20.dp) + contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) ) { // Header Section - App Identity - item(key = "header") { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = stringResource(R.string.app_name), - style = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = 28.sp, - letterSpacing = 1.sp - ), - color = colorScheme.onBackground - ) - Text( - text = stringResource(R.string.version_prefix, versionName ?: ""), - fontSize = 13.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onBackground.copy(alpha = 0.5f) - ) - Text( - text = stringResource(R.string.about_tagline), - fontSize = 13.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onBackground.copy(alpha = 0.6f), - modifier = Modifier.padding(top = 4.dp) - ) - } + item(key = "hero") { + AboutHero(versionName = versionName ?: "") } - // Features Section - Grouped Card - item(key = "features") { - Column(modifier = Modifier.padding(horizontal = 20.dp)) { - Text( - text = stringResource(R.string.about_appearance).uppercase(), - style = MaterialTheme.typography.labelSmall, - color = colorScheme.onBackground.copy(alpha = 0.5f), - letterSpacing = 0.5.sp, - modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) - ) - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) - ) { - Column { - FeatureRow( - icon = Icons.Filled.Bluetooth, - title = stringResource(R.string.about_offline_mesh_title), - subtitle = stringResource(R.string.about_offline_mesh_desc) - ) - HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - color = colorScheme.outline.copy(alpha = 0.12f) - ) - FeatureRow( - icon = Icons.Default.Public, - title = stringResource(R.string.about_online_geohash_title), - subtitle = stringResource(R.string.about_online_geohash_desc) - ) - HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - color = colorScheme.outline.copy(alpha = 0.12f) - ) - FeatureRow( - icon = Icons.Default.Lock, - title = stringResource(R.string.about_e2e_title), - subtitle = stringResource(R.string.about_e2e_desc) - ) - } + item(key = "tabs") { + AboutTabBar( + selected = selectedTab, + onSelect = { selectedTab = it }, + modifier = Modifier.padding(top = 24.dp) + ) + } + + if (selectedTab == AboutTab.Info) { + // What the app is, then how to drive it. Both are reference material a + // new user reads once, so they belong on the same tab. + item(key = "features") { + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_about)) + AboutFeatureCard() } } + + item(key = "how_to_use") { + AboutHowToUseSection() + } } + if (selectedTab == AboutTab.Settings) { // Appearance Section item(key = "appearance") { - Column(modifier = Modifier.padding(horizontal = 20.dp)) { - Text( - text = "THEME", - style = MaterialTheme.typography.labelSmall, - color = colorScheme.onBackground.copy(alpha = 0.5f), - letterSpacing = 0.5.sp, - modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) - ) + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_theme)) val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() Surface( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + shape = AboutCardShape ) { Row( modifier = Modifier @@ -425,18 +353,14 @@ fun AboutSheet( val torStatus by torProvider.statusFlow.collectAsState() val torAvailable = remember { torProvider.isTorAvailable() } - Column(modifier = Modifier.padding(horizontal = 20.dp)) { - Text( - text = "SETTINGS", - style = MaterialTheme.typography.labelSmall, - color = colorScheme.onBackground.copy(alpha = 0.5f), - letterSpacing = 0.5.sp, - modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) - ) + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_settings)) Surface( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + shape = AboutCardShape ) { Column { // Background Mode Toggle @@ -455,12 +379,13 @@ fun AboutSheet( } } ) - + HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - color = colorScheme.outline.copy(alpha = 0.12f) + modifier = Modifier.padding(start = 54.dp), + thickness = 1.dp, + color = colorScheme.outlineVariant ) - + // Proof of Work Toggle SettingsToggleRow( icon = Icons.Filled.Speed, @@ -469,16 +394,17 @@ fun AboutSheet( checked = powEnabled, onCheckedChange = { PoWPreferenceManager.setPowEnabled(it) } ) - + HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - color = colorScheme.outline.copy(alpha = 0.12f) + modifier = Modifier.padding(start = 54.dp), + thickness = 1.dp, + color = colorScheme.outlineVariant ) - + // Tor Toggle SettingsToggleRow( icon = Icons.Filled.Security, - title = "Tor Network", + title = stringResource(R.string.about_tor_title), subtitle = stringResource(R.string.about_tor_route), checked = torMode.value == TorMode.ON, onCheckedChange = { enabled -> @@ -491,9 +417,9 @@ fun AboutSheet( statusIndicator = if (torMode.value == TorMode.ON) { { val statusColor = when { - torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - torStatus.running -> Color(0xFFFF9500) - else -> Color(0xFFFF3B30) + torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary + torStatus.running -> palette.accentOrange + else -> colorScheme.error } Surface( color = statusColor, @@ -857,15 +783,18 @@ fun AboutSheet( } } - + // Tor unavailable hint if (!torAvailable) { Text( text = stringResource(R.string.tor_not_available_in_this_build), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onBackground.copy(alpha = 0.5f), - modifier = Modifier.padding(start = 16.dp, top = 8.dp) + fontFamily = BitchatFontFamily, + color = palette.textTertiary, + modifier = Modifier.padding( + start = AboutHorizontalPadding + 16.dp, + top = 8.dp + ) ) } } @@ -875,13 +804,15 @@ fun AboutSheet( item(key = "pow_slider") { val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() - + if (powEnabled) { - Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Column(modifier = Modifier.padding(top = 12.dp)) { Surface( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + shape = AboutCardShape ) { Column( modifier = Modifier.padding(16.dp), @@ -893,31 +824,39 @@ fun AboutSheet( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Difficulty", - style = MaterialTheme.typography.bodyMedium, + text = stringResource(R.string.about_difficulty), + fontFamily = BitchatFontFamily, + fontSize = 14.sp, fontWeight = FontWeight.Medium, color = colorScheme.onSurface ) - Text( - text = "$powDifficulty bits • ${NostrProofOfWork.estimateMiningTime(powDifficulty)}", - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) + AnimatedCountLabel( + count = powDifficulty, + text = stringResource( + R.string.about_difficulty_value, + powDifficulty, + NostrProofOfWork.estimateMiningTime(powDifficulty) + ), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = colorScheme.onSurfaceVariant ) } - + Slider( value = powDifficulty.toFloat(), onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, valueRange = 0f..32f, steps = 31, colors = SliderDefaults.colors( - thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), - activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + thumbColor = colorScheme.primary, + activeTrackColor = colorScheme.primary, + inactiveTrackColor = colorScheme.surfaceVariant ) ) - - Text( + + AnimatedCountLabel( + count = powDifficulty, text = when { powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none) powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low) @@ -928,8 +867,8 @@ fun AboutSheet( else -> stringResource(R.string.about_pow_desc_extreme) }, fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f) + fontFamily = BitchatFontFamily, + color = palette.textTertiary ) } } @@ -942,13 +881,15 @@ fun AboutSheet( val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) } val torProvider = remember { ArtiTorManager.getInstance() } val torStatus by torProvider.statusFlow.collectAsState() - + if (torMode.value == TorMode.ON) { - Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Column(modifier = Modifier.padding(top = 12.dp)) { Surface( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + shape = AboutCardShape ) { Column( modifier = Modifier.padding(16.dp), @@ -959,14 +900,19 @@ fun AboutSheet( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { val statusColor = when { - torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - torStatus.running -> Color(0xFFFF9500) - else -> Color(0xFFFF3B30) + torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary + torStatus.running -> palette.accentOrange + else -> colorScheme.error } Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {} Text( - text = if (torStatus.running) "Connected (${torStatus.bootstrapPercent}%)" else "Disconnected", - style = MaterialTheme.typography.bodyMedium, + text = if (torStatus.running) { + stringResource(R.string.about_tor_connected, torStatus.bootstrapPercent) + } else { + stringResource(R.string.about_tor_disconnected) + }, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, fontWeight = FontWeight.Medium, color = colorScheme.onSurface ) @@ -975,8 +921,8 @@ fun AboutSheet( Text( text = torStatus.lastLogLine.take(120), fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f), + fontFamily = BitchatFontFamily, + color = palette.textTertiary, maxLines = 2 ) } @@ -986,67 +932,36 @@ fun AboutSheet( } } - // Emergency Warning - item(key = "warning") { - Surface( - modifier = Modifier - .padding(horizontal = 20.dp) - .fillMaxWidth(), - color = colorScheme.error.copy(alpha = 0.1f), - shape = RoundedCornerShape(16.dp) - ) { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top - ) { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = null, - tint = colorScheme.error, - modifier = Modifier.size(20.dp) - ) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = stringResource(R.string.about_emergency_title), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = colorScheme.error - ) - Text( - text = stringResource(R.string.about_emergency_tip), - fontSize = 13.sp, - color = colorScheme.onSurface.copy(alpha = 0.7f) - ) - } - } - } - } + } // end Settings tab // Footer item(key = "footer") { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp), + .padding( + start = AboutHorizontalPadding, + end = AboutHorizontalPadding, + top = 24.dp + ), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { - if (onShowDebug != null) { + if (selectedTab == AboutTab.Settings && onShowDebug != null) { TextButton(onClick = onShowDebug) { Text( text = stringResource(R.string.about_debug_settings), fontSize = 13.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.primary ) } } Text( text = stringResource(R.string.about_footer), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.4f) + fontSize = 11.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary ) Spacer(modifier = Modifier.height(20.dp)) } @@ -1059,10 +974,11 @@ fun AboutSheet( .align(Alignment.TopCenter) .fillMaxWidth() .height(64.dp) - .background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha)) + .background(colorScheme.background.copy(alpha = topBarAlpha)) ) { + val dismiss = LocalSheetDismiss.current CloseButton( - onClick = onDismiss, + onClick = { dismiss?.invoke() ?: onDismiss() }, modifier = modifier .align(Alignment.CenterEnd) .padding(horizontal = 16.dp), @@ -1112,7 +1028,7 @@ fun PasswordPromptDialog( onValueChange = onPasswordChange, label = { Text(stringResource(R.string.pwd_label), style = MaterialTheme.typography.bodyMedium) }, textStyle = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = colorScheme.primary, diff --git a/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt b/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt new file mode 100644 index 00000000..ecf5eb69 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt @@ -0,0 +1,117 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import com.bitchat.android.ui.theme.BitchatMotion + +/** + * Counts that roll to their new value instead of snapping. + * + * Peer counts change on their own, without the user doing anything, so a hard digit swap is easy + * to miss and looks like a rendering fault when it is noticed. Sliding the digits in the + * direction the number moved — up when someone joins, down when someone leaves — conveys the + * change without needing a separate indicator. + */ +@Composable +fun AnimatedCount( + count: Int, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + prefix: String = "", +) { + AnimatedContent( + targetState = count, + transitionSpec = { + val goingUp = targetState > initialState + ( + slideInVertically( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + initialOffsetY = { height -> if (goingUp) height else -height } + ) + fadeIn(tween(BitchatMotion.STANDARD_MS)) + ).togetherWith( + slideOutVertically( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + targetOffsetY = { height -> if (goingUp) -height else height } + ) + fadeOut(tween(BitchatMotion.QUICK_MS)) + // Clip so the outgoing digit cannot bleed past the text bounds mid-transition. + ) using SizeTransform(clip = true) + }, + modifier = modifier, + label = "animatedCount" + ) { value -> + Text( + text = "$prefix$value", + style = style, + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + fontFamily = fontFamily, + maxLines = 1 + ) + } +} + +/** + * Cross-fades a label whose text embeds a count, e.g. `People (7)` or `3 people`. + * + * Used where the number is not isolated in its own composable and cannot be rolled on its own. + * The transition is keyed on [count] rather than on [text], so a label changing for some other + * reason — a locale switch, say — does not animate. + */ +@Composable +fun AnimatedCountLabel( + count: Int, + text: String, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, +) { + AnimatedContent( + targetState = count, + transitionSpec = { + fadeIn(tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)) + .togetherWith(fadeOut(tween(BitchatMotion.QUICK_MS))) + .using(SizeTransform(clip = false)) + }, + modifier = modifier, + label = "animatedCountLabel" + ) { state -> + // Captured per state, so the outgoing copy keeps rendering the label it entered with. + // Reading `text` directly would show the *new* label on both sides of the cross-fade, + // turning the transition into a flicker between two identical strings. + val stateText = remember(state) { text } + Text( + text = stateText, + style = style, + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + fontFamily = fontFamily, + maxLines = 1 + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt b/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt new file mode 100644 index 00000000..26c8259a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt @@ -0,0 +1,85 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LookaheadScope + +/** + * Motion for a row moving to a new position, or resizing in place. + * + * People lists reorder themselves constantly and without user input — someone sends a DM and jumps + * to the top, a peer drops off the mesh, a favourite comes online. Rows teleporting between + * positions makes the list feel unreliable and costs the reader their place in it. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +private val RowBoundsTransform = BoundsTransform { _, _ -> + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Rect.VisibilityThreshold + ) +} + +/** Entry fade for a row that was not previously in the list. */ +private val RowEnterSpec: FiniteAnimationSpec = + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 900f) + +/** + * A vertical list whose rows animate when they are added, removed, or reordered. + * + * Deliberately not a `LazyColumn`: both people lists live *inside* an outer `LazyColumn` item, where + * nesting another lazy list is not possible. [LookaheadScope] plus [Modifier.animateBounds] gives + * the same reorder-and-resize animation that `LazyItemScope.animateItem` provides, without the list + * needing to be lazy. These lists are bounded (and the geohash one is explicitly capped), so + * nothing is lost by composing every row. + * + * Rows are keyed so identity survives reordering. Without stable keys a row that moved would look + * like a different row appearing, and would fade instead of sliding. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun AnimatedRowColumn( + items: List, + key: (T) -> Any, + modifier: Modifier = Modifier, + row: @Composable (index: Int, item: T) -> Unit +) { + LookaheadScope { + Column(modifier = modifier) { + items.forEachIndexed { index, item -> + key(key(item)) { + // Fades the row in on the composition it first appears, then never again — + // reordering an existing row must slide, not blink. + val enter = remember { Animatable(0f) } + LaunchedEffect(Unit) { enter.animateTo(1f, RowEnterSpec) } + + Box( + modifier = Modifier + .animateBounds( + lookaheadScope = this@LookaheadScope, + boundsTransform = RowBoundsTransform + ) + .graphicsLayer { alpha = enter.value } + ) { + row(index, item) + } + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index e91e30be..af38ea1d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -1,67 +1,277 @@ package com.bitchat.android.ui - -import android.util.Log +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import com.bitchat.android.R import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.foundation.Canvas -import androidx.compose.ui.geometry.Offset import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily +import androidx.annotation.DrawableRes +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.foundation.layout.RowScope +import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.BitChatBrandButton +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.TorMode +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette /** * Header components for ChatScreen * Extracted from ChatScreen.kt for better organization */ +/** Height of the chat top bar. Taller than the old 42.dp so 44.dp tap targets fit properly. */ +val ChatHeaderHeight = 52.dp +/** + * The single visible glyph size used by every icon in the top bar. + * + * The Figma header pairs 16 px icons with compact labels. At our 17sp header scale, 19dp preserves + * that icon-to-cap-height relationship without affecting the surrounding 44dp touch targets. + */ +internal val HeaderIconSize = 19.dp +/** + * Text size for the top bar's labels: nickname, channel name, peer count. + * + * A step up from the 15.sp body scale. The bar is the app's primary status readout and was + * noticeably harder to read than the messages below it; the extra point costs nothing because + * the bar's height is driven by [HeaderTapTarget], not by the text. + */ +private val HeaderTextSize = 17.sp + +/** Minimum tap target for every interactive element in the header. */ +private val HeaderTapTarget = 44.dp + +/** Corner radius for the header's tappable label+icon clusters. */ +private val HeaderClusterShape = RoundedCornerShape(8.dp) + +/** + * Edge insets for the bar. + * + * Asymmetric because the leading glyph sits in a 44.dp tap target whose padding already supplies + * some optical inset, while the trailing action's does the same on the other side. + */ +internal val HeaderInsetStart = 12.dp +internal val HeaderInsetEnd = 8.dp + +/** + * A minimum-48x40 tap target wrapping a small icon. + * + * The old header used bare 16.dp icons with `Modifier.clickable`, which produced tap targets far + * below the accessibility minimum and made the channel/bookmark controls genuinely hard to hit. + */ +@Composable +private fun HeaderIconButton( + onClick: () -> Unit, + contentDescription: String?, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Box( + modifier = modifier + .size(HeaderTapTarget) + .clip(CircleShape) + .pressScaleClickable(onClick = onClick, onClickLabel = contentDescription), + contentAlignment = Alignment.Center + ) { + content() + } +} + +/** + * Tor health for location-channel header glyphs. + * + * Status colours are heavily muted (blended into [normal]) so they read as a soft signal + * rather than an alarm. Connecting / not-yet-running also drives a slow glow pulse. + */ +internal data class TorConnectionVisual( + val tint: Color, + /** True while Tor is enabled but not fully bootstrapped — drives a pulse. */ + val isProgress: Boolean, +) @Composable -fun TorStatusDot( - modifier: Modifier = Modifier +internal fun rememberTorConnectionVisual(normal: Color): TorConnectionVisual { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + val torStatus by remember { ArtiTorManager.getInstance() }.statusFlow.collectAsState() + + // ~28% of the loud accent mixed into the base tint keeps the hue without intensity. + val mutedConnecting = lerp(normal, palette.accentOrange, 0.28f) + val mutedFailed = lerp(normal, colorScheme.error, 0.30f) + + val target = when { + torStatus.mode == TorMode.OFF -> TorConnectionVisual(normal, isProgress = false) + torStatus.running && torStatus.bootstrapPercent >= 100 -> + TorConnectionVisual(normal, isProgress = false) + torStatus.running -> TorConnectionVisual(mutedConnecting, isProgress = true) + else -> TorConnectionVisual(mutedFailed, isProgress = true) + } + + val animatedTint by animateColorAsState( + targetValue = target.tint, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "torConnectionTint" + ) + return TorConnectionVisual(tint = animatedTint, isProgress = target.isProgress) +} + +/** + * Soft, slow brightness pulse used while Tor is connecting. Keeps scale fixed so layout + * does not shift; only opacity / a faint halo breathe. + */ +@Composable +internal fun TorAwareHeaderIcon( + imageVector: ImageVector, + tint: Color, + isProgress: Boolean, + contentDescription: String?, + modifier: Modifier = Modifier, ) { - val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() } - val torStatus by torProvider.statusFlow.collectAsState() - - if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) { - val dotColor = when { - torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) // Orange - bootstrapping - torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) // Green - connected - else -> Color.Red // Red - error/disconnected - } - Canvas( - modifier = modifier - ) { - val radius = size.minDimension / 2 - drawCircle( - color = dotColor, - radius = radius, - center = Offset(size.width / 2, size.height / 2) + val pulse = if (isProgress) { + val transition = rememberInfiniteTransition(label = "torGlow") + transition.animateFloat( + initialValue = 0.42f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "torGlowPulse" + ).value + } else { + 1f + } + + // Fixed layout footprint = icon size. Glow is drawn larger via requiredSize so it never + // pushes neighbouring text when the pulse starts/stops. + Box( + contentAlignment = Alignment.Center, + modifier = modifier.size(HeaderIconSize) + ) { + if (isProgress) { + val glowBrush = remember(tint) { + Brush.radialGradient( + colorStops = arrayOf( + 0.0f to tint.copy(alpha = 0.55f), + 0.45f to tint.copy(alpha = 0.22f), + 1.0f to Color.Transparent, + ) + ) + } + Box( + modifier = Modifier + .requiredSize(HeaderIconSize + 14.dp) + .graphicsLayer { alpha = pulse * 0.85f } + .background(glowBrush) ) } + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = Modifier + .size(HeaderIconSize) + .graphicsLayer { + alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f + }, + tint = tint + ) + } +} + +/** Painter-resource counterpart used by the extracted Figma SVG family. */ +@Composable +internal fun TorAwareHeaderIcon( + painter: Painter, + tint: Color, + isProgress: Boolean, + contentDescription: String?, + modifier: Modifier = Modifier, +) { + val pulse = if (isProgress) { + val transition = rememberInfiniteTransition(label = "torPainterGlow") + transition.animateFloat( + initialValue = 0.42f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "torPainterGlowPulse" + ).value + } else { + 1f + } + + Box( + contentAlignment = Alignment.Center, + modifier = modifier.size(HeaderIconSize) + ) { + if (isProgress) { + val glowBrush = remember(tint) { + Brush.radialGradient( + colorStops = arrayOf( + 0.0f to tint.copy(alpha = 0.55f), + 0.45f to tint.copy(alpha = 0.22f), + 1.0f to Color.Transparent, + ) + ) + } + Box( + modifier = Modifier + .requiredSize(HeaderIconSize + 14.dp) + .graphicsLayer { alpha = pulse * 0.85f } + .background(glowBrush) + ) + } + Icon( + painter = painter, + contentDescription = contentDescription, + modifier = Modifier + .size(HeaderIconSize) + .graphicsLayer { + alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f + }, + tint = tint + ) } } @@ -70,39 +280,156 @@ fun NoiseSessionIcon( sessionState: String?, modifier: Modifier = Modifier ) { - val (icon, color, contentDescription) = when (sessionState) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + // The pre-redesign colours for the first two states were `0x87878700`, i.e. alpha 0x87 with + // an all-but-transparent RGB - the icons were effectively invisible. They now use the + // palette's secondary text colour. + val (iconRes, color, contentDescription) = when (sessionState) { "uninitialized" -> Triple( - Icons.Outlined.NoEncryption, - Color(0x87878700), // Grey - ready to establish + R.drawable.ic_spec_lock_open, + colorScheme.onSurfaceVariant, stringResource(R.string.cd_ready_for_handshake) ) "handshaking" -> Triple( - Icons.Outlined.Sync, - Color(0x87878700), // Grey - in progress + R.drawable.ic_spec_sync, + colorScheme.onSurfaceVariant, stringResource(R.string.cd_handshake_in_progress) ) "established" -> Triple( - Icons.Filled.Lock, - Color(0xFFFF9500), // Orange - secure + R.drawable.ic_spec_lock, + colorScheme.primary, stringResource(R.string.cd_encrypted) ) else -> { // "failed" or any other state Triple( - Icons.Outlined.Warning, - Color(0xFFFF4444), // Red - error + R.drawable.ic_spec_warning, + colorScheme.error, stringResource(R.string.cd_handshake_failed) ) } } - + Icon( - imageVector = icon, + painter = painterResource(iconRes), contentDescription = contentDescription, modifier = modifier, tint = color ) } +/** + * Reachability glyph for a conversation, drawn from the same spec set the main header uses. + * + * Mirrors the main header's channel button: a globe for anything reached over the internet, the + * range mark for the local mesh, and the more specific transport glyph when we know it. + */ +@DrawableRes +internal fun conversationTransportIcon( + isReachedOverInternet: Boolean, + isWifiAware: Boolean, + isDirect: Boolean +): Int = when { + isReachedOverInternet -> R.drawable.ic_spec_globe + isWifiAware -> R.drawable.ic_spec_wifi + isDirect -> R.drawable.ic_spec_bluetooth + else -> R.drawable.ic_spec_routed +} + +/** + * The shared chrome for a conversation header — private chats and channels alike. + * + * Deliberately built from the same tokens as [MainHeader] rather than from `TopAppBar`: identical + * height, identical 12/8.dp edge insets, the leading glyph in a [HeaderTapTarget]-sized slot so it + * lands exactly where the brand mark does, the same -6.dp optical nudge pulling the title toward + * that glyph, and the same [HeaderTextSize]. Anything less and the header visibly shifts as you + * move between the main timeline and a conversation. + * + * Actions are right-aligned and unweighted, so a long title yields space to them rather than + * pushing them off screen. + */ +@Composable +fun ConversationHeader( + @DrawableRes leadingIconRes: Int, + leadingIconTint: Color, + title: String, + modifier: Modifier = Modifier, + onTitleClick: (() -> Unit)? = null, + leadingContentDescription: String? = null, + actions: @Composable RowScope.() -> Unit = {} +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = modifier + .fillMaxWidth() + .height(ChatHeaderHeight) + .padding(start = HeaderInsetStart, end = HeaderInsetEnd), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(HeaderTapTarget), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(leadingIconRes), + contentDescription = leadingContentDescription, + modifier = Modifier.size(HeaderIconSize), + tint = leadingIconTint + ) + } + + // Same optical correction as the main header: the 44.dp tap target leaves more gap + // than the design wants between glyph and label. + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, + fontWeight = FontWeight.Medium, + color = colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .offset(x = (-6).dp) + .then( + if (onTitleClick != null) { + Modifier + .clip(HeaderClusterShape) + .pressScaleClickable(onClick = onTitleClick) + .padding(horizontal = 6.dp, vertical = 4.dp) + } else { + Modifier + } + ) + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + content = actions + ) + } +} + +/** An action slot in a [ConversationHeader], matching the main header's icon buttons. */ +@Composable +fun ConversationHeaderAction( + onClick: () -> Unit, + contentDescription: String?, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) = HeaderIconButton( + onClick = onClick, + contentDescription = contentDescription, + modifier = modifier, + content = content +) + @Composable fun NicknameEditor( value: String, @@ -125,15 +452,17 @@ fun NicknameEditor( Text( text = stringResource(R.string.at_symbol), style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary.copy(alpha = 0.8f) + fontSize = HeaderTextSize, + color = colorScheme.primary.copy(alpha = 0.7f) ) - + BasicTextField( value = value, onValueChange = onValueChange, textStyle = MaterialTheme.typography.bodyMedium.copy( color = colorScheme.primary, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily, + fontSize = HeaderTextSize ), cursorBrush = SolidColor(colorScheme.primary), singleLine = true, @@ -144,7 +473,7 @@ fun NicknameEditor( } ), modifier = Modifier - .widthIn(max = 120.dp) + .widthIn(max = 150.dp) .horizontalScroll(scrollState) ) } @@ -161,54 +490,68 @@ fun PeerCounter( onClick: () -> Unit, modifier: Modifier = Modifier ) { + val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme - + // Compute channel-aware people count and color (matches iOS logic exactly) val (peopleCount, countColor) = when (selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> { // Geohash channel: show geohash participants val count = geohashPeople.size - val green = Color(0xFF00C851) // Standard green - Pair(count, if (count > 0) green else Color.Gray) + Pair(count, if (count > 0) colorScheme.primary else palette.textTertiary) } is com.bitchat.android.geohash.ChannelID.Mesh, null -> { // Mesh channel: show Bluetooth-connected peers (excluding self) val count = connectedPeers.size - val meshBlue = Color(0xFF007AFF) // iOS-style blue for mesh - Pair(count, if (isConnected && count > 0) meshBlue else Color.Gray) + Pair(count, if (isConnected && count > 0) colorScheme.secondary else palette.textTertiary) } } - + + // Peers come and go constantly; fading the tint avoids a flicker every time the count + // crosses zero. + val animatedCountColor by animateColorAsState( + targetValue = countColor, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "peerCountColor" + ) + Row( verticalAlignment = Alignment.CenterVertically, - modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier + .clip(HeaderClusterShape) + .pressScaleClickable(onClick = onClick) + .height(HeaderTapTarget) + .padding(horizontal = 6.dp) ) { Icon( - imageVector = Icons.Default.Group, + // The extracted people glyph stays legible at the compact header scale; the number + // beside it carries the precise count. + painter = painterResource(R.drawable.ic_spec_people), contentDescription = when (selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants) else -> stringResource(R.string.cd_connected_peers) }, - modifier = Modifier.size(16.dp), - tint = countColor + modifier = Modifier.size(HeaderIconSize), + tint = animatedCountColor ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "$peopleCount", + AnimatedCount( + count = peopleCount, style = MaterialTheme.typography.bodyMedium, - color = countColor, - fontSize = 16.sp, + fontSize = HeaderTextSize, + color = animatedCountColor, fontWeight = FontWeight.Medium ) - + if (joinedChannels.isNotEmpty()) { - Text( - text = stringResource(R.string.channel_count_prefix) + "${joinedChannels.size}", + AnimatedCount( + count = joinedChannels.size, + prefix = stringResource(R.string.channel_count_prefix), style = MaterialTheme.typography.bodyMedium, - color = if (isConnected) Color(0xFF00C851) else Color.Red, - fontSize = 16.sp, + fontSize = HeaderTextSize, + color = if (isConnected) colorScheme.primary else colorScheme.error, fontWeight = FontWeight.Medium ) } @@ -266,57 +609,26 @@ private fun ChannelHeader( onSidebarClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - - Box(modifier = Modifier.fillMaxWidth()) { - // Back button - positioned all the way to the left with minimal margin - Button( + + // No back affordance: the close action on the right is the way out, exactly as in a private + // chat. Leaving the channel outright lives on its row in the network sheet, so it does not + // need a second, easily-mistaken home next to the exit. + ConversationHeader( + leadingIconRes = R.drawable.ic_spec_chat_bubbles, + leadingIconTint = colorScheme.primary, + leadingContentDescription = null, + title = "#$channel", + onTitleClick = onSidebarClick + ) { + ConversationHeaderAction( onClick = onBackClick, - colors = ButtonDefaults.buttonColors( - containerColor = Color.Transparent, - contentColor = colorScheme.primary - ), - contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding - modifier = Modifier - .align(Alignment.CenterStart) - .offset(x = (-8).dp) // Move even further left to minimize margin + contentDescription = stringResource(R.string.close_plain) ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), - modifier = Modifier.size(16.dp), - tint = colorScheme.primary - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(R.string.chat_back), - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary - ) - } - } - - // Title - perfectly centered regardless of other elements - Text( - text = stringResource(R.string.chat_channel_prefix, channel), - style = MaterialTheme.typography.titleMedium, - color = Color(0xFFFF9500), // Orange to match input field - modifier = Modifier - .align(Alignment.Center) - .clickable { onSidebarClick() } - ) - - // Leave button - positioned on the right - TextButton( - onClick = onLeaveChannel, - modifier = Modifier.align(Alignment.CenterEnd) - ) { - Text( - text = stringResource(R.string.chat_leave), - style = MaterialTheme.typography.bodySmall, - color = Color.Red + Icon( + painter = painterResource(R.drawable.ic_spec_close), + contentDescription = stringResource(R.string.close_plain), + modifier = Modifier.size(HeaderIconSize), + tint = colorScheme.onSurfaceVariant ) } } @@ -334,6 +646,7 @@ private fun MainHeader( viewModel: ChatViewModel ) { val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle() val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle() @@ -342,109 +655,98 @@ private fun MainHeader( val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() - // Bookmarks store for current geohash toggle (iOS parity) - val context = androidx.compose.ui.platform.LocalContext.current - val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) } - val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle() - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .height(ChatHeaderHeight) + .padding(start = HeaderInsetStart, end = HeaderInsetEnd), verticalAlignment = Alignment.CenterVertically ) { + // MARK: - Identity cluster. + // + // Weighted so it yields space to the status cluster rather than pushing it off screen. + // Compose measures unweighted children first, so the icons on the right always get the + // width they need and a long nickname simply scrolls within what is left. Row( - modifier = Modifier.fillMaxHeight(), + modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { BitChatBrandButton( onClick = onTitleClick, onTripleClick = onTripleTitleClick, contentDescription = stringResource(R.string.cd_open_about), + modifier = Modifier.size(HeaderTapTarget), ) - Text( - text = "/", - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary, - ) + // Nudge toward the brand glyph: the 44.dp tap target leaves more optical gap than + // spacing between the mark and the path label. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.offset(x = (-6).dp) + ) { + Text( + text = "/", + style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, + // Dimmed: the slash is a separator, not content. At full brightness it competed + // with the nickname beside it. + color = colorScheme.primary.copy(alpha = 0.45f), + modifier = Modifier.padding(end = 2.dp) + ) - Spacer(modifier = Modifier.width(2.dp)) - - NicknameEditor( - value = nickname, - onValueChange = onNicknameChange - ) - } - - // Right section with location channels button and peer counter - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp) - ) { - - // Unread private messages badge (click to open most recent DM) - if (hasUnreadPrivateMessages.isNotEmpty()) { - // Render icon directly to avoid symbol resolution issues - Icon( - imageVector = Icons.Filled.Email, - contentDescription = stringResource(R.string.cd_unread_private_messages), - modifier = Modifier - .size(16.dp) - .clickable { viewModel.openLatestUnreadPrivateChat() }, - tint = Color(0xFFFF9500) + NicknameEditor( + value = nickname, + onValueChange = onNicknameChange ) } + } + + // MARK: - Status cluster. + // + // Order, left to right: unread DMs, notes, channel, people. + // Tor health is read from the location channel / notes icon colour rather than a + // dedicated status dot. + Row( + verticalAlignment = Alignment.CenterVertically, + // Tight, because every child below is its own >=44.dp tap target. + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + // Unread private messages badge (click to open most recent DM) + if (hasUnreadPrivateMessages.isNotEmpty()) { + HeaderIconButton( + onClick = { viewModel.openLatestUnreadPrivateChat() }, + contentDescription = stringResource(R.string.cd_unread_private_messages) + ) { + Icon( + painter = painterResource(R.drawable.ic_spec_envelope), + contentDescription = stringResource(R.string.cd_unread_private_messages), + modifier = Modifier.size(HeaderIconSize), + tint = palette.accentOrange + ) + } + } + + // Location notes + channel badge: one tight unit so the document glyph and the + // bluetooth/globe glyph sit at the same visual pitch as other header pairings. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(0.dp) + ) { + LocationNotesButton( + viewModel = viewModel, + onClick = onLocationNotesClick + ) + + // Bookmarking lives in the Location Channels sheet, one tap away via the channel + // button. Duplicating it here bought a shortcut for a rare action at the cost + // of a slot in the app's most crowded row. - // Location channels button (matching iOS implementation) and bookmark grouped tightly - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 4.dp)) { LocationChannelsButton( viewModel = viewModel, onClick = onLocationChannelsClick ) - - // Bookmark toggle for current geohash (not shown for mesh) - val currentGeohash: String? = when (val sc = selectedLocationChannel) { - is com.bitchat.android.geohash.ChannelID.Location -> sc.channel.geohash - else -> null - } - if (currentGeohash != null) { - val isBookmarked = bookmarks.contains(currentGeohash) - Box( - modifier = Modifier - .padding(start = 2.dp) // minimal gap between geohash and bookmark - .size(20.dp) - .clickable { bookmarksStore.toggle(currentGeohash) }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, - contentDescription = stringResource(R.string.cd_toggle_bookmark), - tint = if (isBookmarked) Color(0xFF00C851) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f), - modifier = Modifier.size(16.dp) - ) - } - } } - // Location Notes button (extracted to separate component) - LocationNotesButton( - viewModel = viewModel, - onClick = onLocationNotesClick - ) - - // Tor status dot when Tor is enabled - TorStatusDot( - modifier = Modifier - .size(8.dp) - .padding(start = 0.dp, end = 2.dp) - ) - - // PoW status indicator - PoWStatusIndicator( - modifier = Modifier, - style = PoWIndicatorStyle.COMPACT - ) - Spacer(modifier = Modifier.width(2.dp)) PeerCounter( connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, joinedChannels = joinedChannels, @@ -458,56 +760,72 @@ private fun MainHeader( } } +/** + * Current channel indicator: a globe for geohash channels, a mesh glyph for the local mesh. + * + * The design brief asked for the "addition of globe icon to represent channels". Previously this + * was a text-only badge wrapped in an M3 [Button], which imposed a hidden 58.dp minimum width + * and 40.dp minimum height that fought the header's explicit sizing. + */ @Composable private fun LocationChannelsButton( viewModel: ChatViewModel, onClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - + // Get current channel selection from location manager val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() - val teleported by viewModel.isTeleported.collectAsStateWithLifecycle() - - val (badgeText, badgeColor) = when (selectedChannel) { - is com.bitchat.android.geohash.ChannelID.Mesh -> { - "#mesh" to Color(0xFF007AFF) // iOS blue for mesh - } - is com.bitchat.android.geohash.ChannelID.Location -> { - val geohash = (selectedChannel as com.bitchat.android.geohash.ChannelID.Location).channel.geohash - "#$geohash" to Color(0xFF00C851) // Green for location - } - null -> "#mesh" to Color(0xFF007AFF) // Default to mesh + + val isLocation = selectedChannel is com.bitchat.android.geohash.ChannelID.Location + val badgeText = when (val channel = selectedChannel) { + // Geohashes keep the '#' because that is how they are written and typed everywhere else. + is com.bitchat.android.geohash.ChannelID.Location -> "#${channel.channel.geohash}" + // The local mesh is not a hashtag channel, and the mesh glyph already says what it is, + // so it is plain "mesh". + else -> stringResource(R.string.mesh_label) } - - Button( - onClick = onClick, - colors = ButtonDefaults.buttonColors( - containerColor = Color.Transparent, - contentColor = badgeColor - ), - contentPadding = PaddingValues(start = 4.dp, end = 0.dp, top = 2.dp, bottom = 2.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = badgeText, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace - ), - color = badgeColor, - maxLines = 1 + val channelColor = if (isLocation) colorScheme.primary else colorScheme.secondary + // Tor status only tints the globe (location channels). The local mesh stays blue. + val torVisual = if (isLocation) { + rememberTorConnectionVisual(normal = channelColor) + } else { + TorConnectionVisual(tint = channelColor, isProgress = false) + } + val badgeIconRes = if (isLocation) { + R.drawable.ic_spec_globe + } else { + R.drawable.ic_spec_range + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .clip(HeaderClusterShape) + .pressScaleClickable( + onClick = onClick, + onClickLabel = stringResource(R.string.location_channels_title) ) - - // Teleportation indicator (like iOS) - if (teleported) { - Spacer(modifier = Modifier.width(2.dp)) - Icon( - imageVector = Icons.Default.PinDrop, - contentDescription = stringResource(R.string.cd_teleported), - modifier = Modifier.size(12.dp), - tint = badgeColor - ) - } - } + .height(HeaderTapTarget) + // No start padding: the notes icon is paired directly to the left; keep end + // padding so the gap to PeerCounter matches other cluster separations. + .padding(start = 0.dp, end = 6.dp) + ) { + TorAwareHeaderIcon( + painter = painterResource(badgeIconRes), + tint = torVisual.tint, + isProgress = torVisual.isProgress, + contentDescription = stringResource(R.string.cd_tor_status) + ) + + Text( + text = badgeText, + style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, + fontWeight = FontWeight.Medium, + color = channelColor, + maxLines = 1 + ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 0514217d..e9c8e343 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -1,33 +1,36 @@ package com.bitchat.android.ui + +import com.bitchat.android.ui.theme.BitchatFontFamily // [Goose] Bridge file share events to ViewModel via dispatcher is installed in ChatScreen composition // [Goose] Installing FileShareDispatcher handler in ChatScreen to forward file sends to ViewModel +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.animation.* +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalContext -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.IconButton +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.zIndex import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle @@ -42,6 +45,7 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.nostr.LocationNotesManager import com.bitchat.android.nostr.NearbyNotesController import com.bitchat.android.ui.media.FullScreenImageViewer +import com.bitchat.android.ui.theme.BitchatMotion /** * Main ChatScreen - REFACTORED to use component-based architecture @@ -58,6 +62,8 @@ fun ChatScreen(viewModel: ChatViewModel) { val colorScheme = MaterialTheme.colorScheme val messages by viewModel.messages.collectAsStateWithLifecycle() val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() val nickname by viewModel.nickname.collectAsStateWithLifecycle() val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle() @@ -179,6 +185,47 @@ fun ChatScreen(viewModel: ChatViewModel) { } } + // Identity of the timeline on screen, derived exactly like displayMessages above. Drives the + // per-conversation scroll position and animation state in MessagesList. + val conversationKey = when { + currentChannel != null -> "channel:$currentChannel" + else -> { + val locationChannel = selectedLocationChannel + if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) { + "geo:${locationChannel.channel.geohash}" + } else { + "mesh" + } + } + } + + val mentionPeerIdentities = remember( + displayMessages, + currentChannel, + selectedLocationChannel, + connectedPeers, + peerNicknames, + geohashPeople, + ) { + val knownPeers = if ( + currentChannel == null && selectedLocationChannel is ChannelID.Location + ) { + val duplicateNames = duplicateGeohashBaseNames(geohashPeople) + geohashPeople.mapNotNull { person -> + if (isUnannouncedNickname(person.displayName)) return@mapNotNull null + val displayName = disambiguatedGeohashDisplayName(person, duplicateNames) + displayName to PeerIdentity.nostr(person.id) + } + } else { + connectedPeers.mapNotNull { peerID -> + peerNicknames[peerID]?.let { displayName -> + displayName to PeerIdentity.mesh(peerID) + } + } + } + buildMentionPeerIdentityMap(displayMessages, knownPeers) + } + // Determine whether to show media buttons (only hide in geohash location chats) val showMediaButtons = when { currentChannel != null -> true @@ -191,8 +238,15 @@ fun ChatScreen(viewModel: ChatViewModel) { .fillMaxSize() .background(colorScheme.background) // Extend background to fill entire screen including status bar ) { - val headerHeight = 42.dp - + val headerHeight = ChatHeaderHeight + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + + // Both bars are translucent and the conversation scrolls underneath them, so their + // heights are reserved as list padding instead of as layout space. The composer's height + // varies (suggestion rows, wrapped lines), so it is measured rather than assumed. + var composerHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + // Main content area that responds to keyboard/window insets Column( modifier = Modifier @@ -200,100 +254,92 @@ fun ChatScreen(viewModel: ChatViewModel) { .windowInsetsPadding(WindowInsets.ime) // This handles keyboard insets .windowInsetsPadding(WindowInsets.navigationBars) // Add bottom padding when keyboard is not expanded ) { - // Header spacer - creates exact space for the floating header (status bar + compact header) - Spacer( - modifier = Modifier - .windowInsetsPadding(WindowInsets.statusBars) - .height(headerHeight) + Box(modifier = Modifier.weight(1f)) { + // Messages area - takes up available space, will compress when keyboard appears + // Nearby-notes strip and the reveal hint both live in this Box alongside the + // list, rather than in a Column above it, because the conversation has to scroll + // underneath the translucent bars. Their heights are reserved as list padding. + var notesStripHeight by remember { mutableStateOf(0.dp) } + val showNotesStrip = + isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty() + + MessagesList( + messages = displayMessages, + currentUserNickname = nickname, + meshService = viewModel.meshServiceFacade, + mentionPeerIdentities = mentionPeerIdentities, + modifier = Modifier.fillMaxSize(), + conversationKey = conversationKey, + contentPadding = PaddingValues( + top = statusBarHeight + headerHeight + + (if (showNotesStrip) notesStripHeight else 0.dp), + bottom = composerHeight + ), + forceScrollToBottom = forceScrollToBottom, + onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, + onNicknameClick = { fullSenderName -> + // Single click - mention user in text input + val currentText = messageText.text + + // Extract base nickname and hash suffix from full sender name + val (baseName, hashSuffix) = splitSuffix(fullSenderName) + + // Check if we're in a geohash channel to include hash suffix + val selectedLocationChannel = viewModel.selectedLocationChannel.value + val mentionText = if ( + selectedLocationChannel is ChannelID.Location && + hashSuffix.isNotEmpty() + ) { + // In geohash chat - include the hash suffix from the full display name + "@$baseName$hashSuffix" + } else { + // Regular chat - just the base nickname + "@$baseName" + } + + val newText = when { + currentText.isEmpty() -> "$mentionText " + currentText.endsWith(" ") -> "$currentText$mentionText " + else -> "$currentText $mentionText " + } + + messageText = TextFieldValue( + text = newText, + selection = TextRange(newText.length) + ) + }, + onMessageLongPress = { message -> + // Message long press - open user action sheet with message context + // Extract base nickname from message sender (contains all necessary info) + val (baseName, _) = splitSuffix(message.sender) + selectedUserForSheet = baseName + selectedMessageForSheet = message + showUserSheet = true + }, + onCancelTransfer = { msg -> + viewModel.cancelMediaSend(msg.id) + }, + onImageClick = { currentPath, allImagePaths, initialIndex -> + viewerImagePaths = allImagePaths + initialViewerIndex = initialIndex + showFullScreenImageViewer = true + } ) - // Messages area - takes up available space, will compress when keyboard appears - Column(modifier = Modifier.weight(1f)) { - if (isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty()) { - NearbyNotesStrip( - noteCount = nearbyNotes.size, - onClick = { showLocationNotesSheet = true }, - ) - } - - Box( + if (showNotesStrip) { + NearbyNotesStrip( + noteCount = nearbyNotes.size, + onClick = { showLocationNotesSheet = true }, modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) { - MessagesList( - messages = displayMessages, - currentUserNickname = nickname, - meshService = viewModel.meshServiceFacade, - modifier = Modifier.fillMaxSize(), - forceScrollToBottom = forceScrollToBottom, - onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, - onNicknameClick = { fullSenderName -> - // Single click - mention user in text input - val currentText = messageText.text - - // Extract base nickname and hash suffix from full sender name - val (baseName, hashSuffix) = splitSuffix(fullSenderName) - - // Check if we're in a geohash channel to include hash suffix - val selectedLocationChannel = viewModel.selectedLocationChannel.value - val mentionText = if ( - selectedLocationChannel is ChannelID.Location && - hashSuffix.isNotEmpty() - ) { - // In geohash chat - include the hash suffix from the full display name - "@$baseName$hashSuffix" - } else { - // Regular chat - just the base nickname - "@$baseName" - } - - val newText = when { - currentText.isEmpty() -> "$mentionText " - currentText.endsWith(" ") -> "$currentText$mentionText " - else -> "$currentText $mentionText " - } - - messageText = TextFieldValue( - text = newText, - selection = TextRange(newText.length), - ) + .align(Alignment.TopCenter) + .padding(top = statusBarHeight + headerHeight) + .onSizeChanged { size -> + notesStripHeight = with(density) { size.height.toDp() } }, - onMessageLongPress = { message -> - // Message long press - open user action sheet with message context - // Extract base nickname from message sender (contains all necessary info) - val (baseName, _) = splitSuffix(message.sender) - selectedUserForSheet = baseName - selectedMessageForSheet = message - showUserSheet = true - }, - onCancelTransfer = { msg -> - viewModel.cancelMediaSend(msg.id) - }, - onImageClick = { currentPath, allImagePaths, initialIndex -> - viewerImagePaths = allImagePaths - initialViewerIndex = initialIndex - showFullScreenImageViewer = true - }, - ) - - if ( - displayMessages.isEmpty() && - isMeshTimeline && - !nearbyNotesRevealed && - locationEnabled && - locationPermissionState == - LocationChannelManager.PermissionState.AUTHORIZED && - buildingGeohash != null - ) { - NearbyNotesRevealHint( - onClick = nearbyNotesController::reveal, - modifier = Modifier.align(Alignment.Center), - ) - } - } + ) } - // Input area - stays at bottom + + // Input area - overlays the bottom of the conversation // Bridge file share from lower-level input to ViewModel androidx.compose.runtime.LaunchedEffect(Unit) { com.bitchat.android.ui.events.FileShareDispatcher.setHandler { peer, channel, path -> @@ -302,6 +348,11 @@ fun ChatScreen(viewModel: ChatViewModel) { } ChatInputSection( + modifier = Modifier + .align(Alignment.BottomCenter) + .onSizeChanged { size -> + composerHeight = with(density) { size.height.toDp() } + }, messageText = messageText, onMessageTextChange = { newText: TextFieldValue -> messageText = newText @@ -329,6 +380,7 @@ fun ChatScreen(viewModel: ChatViewModel) { commandSuggestions = commandSuggestions, showMentionSuggestions = showMentionSuggestions, mentionSuggestions = mentionSuggestions, + mentionPeerIdentities = mentionPeerIdentities, onCommandSuggestionClick = { suggestion: CommandSuggestion -> val commandText = viewModel.selectCommandSuggestion(suggestion) messageText = TextFieldValue( @@ -349,11 +401,11 @@ fun ChatScreen(viewModel: ChatViewModel) { colorScheme = colorScheme, showMediaButtons = showMediaButtons ) + } } // Floating header - positioned absolutely at top, ignores keyboard ChatFloatingHeader( - headerHeight = headerHeight, selectedPrivatePeer = null, currentChannel = currentChannel, nickname = nickname, @@ -369,40 +421,39 @@ fun ChatScreen(viewModel: ChatViewModel) { } ) - // Divider under header - positioned after status bar + header height - HorizontalDivider( - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.statusBars) - .offset(y = headerHeight) - .zIndex(1f), - color = colorScheme.outline.copy(alpha = 0.3f) - ) - // Scroll-to-bottom floating button AnimatedVisibility( visible = isScrolledUp, - enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), + // Short and eased: the button appears mid-scroll, so a slow entrance draws the eye + // away from the messages the user is actually reading. + enter = slideInVertically( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + initialOffsetY = { it / 2 } + ) + fadeIn(tween(BitchatMotion.STANDARD_MS)), + exit = slideOutVertically( + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + targetOffsetY = { it / 2 } + ) + fadeOut(tween(BitchatMotion.QUICK_MS)), modifier = Modifier .align(Alignment.BottomEnd) - .padding(end = 16.dp, bottom = 64.dp) + .padding(end = 16.dp, bottom = composerHeight + 8.dp) .zIndex(1.5f) .windowInsetsPadding(WindowInsets.navigationBars) .windowInsetsPadding(WindowInsets.ime) ) { Surface( shape = CircleShape, - color = colorScheme.background, + color = colorScheme.surface, tonalElevation = 3.dp, shadowElevation = 6.dp, - border = BorderStroke(2.dp, Color(0xFF00C851)) + border = BorderStroke(1.dp, colorScheme.primary) ) { IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) { Icon( imageVector = Icons.Filled.ArrowDownward, contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom), - tint = Color(0xFF00C851) + modifier = Modifier.size(22.dp), + tint = colorScheme.primary ) } } @@ -487,30 +538,6 @@ fun ChatScreen(viewModel: ChatViewModel) { } } -@Composable -private fun NearbyNotesRevealHint( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val actionLabel = stringResource(R.string.nearby_notes_reveal) - TextButton( - onClick = onClick, - modifier = modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .padding(horizontal = 24.dp) - .semantics { contentDescription = actionLabel }, - ) { - Text( - text = "📍 $actionLabel", - modifier = Modifier.clearAndSetSemantics { }, - color = MaterialTheme.colorScheme.primary, - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - ) - } -} - @Composable private fun NearbyNotesStrip( noteCount: Int, @@ -537,7 +564,7 @@ private fun NearbyNotesStrip( }, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.primary, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, ) Text( @@ -561,58 +588,115 @@ fun ChatInputSection( commandSuggestions: List, showMentionSuggestions: Boolean, mentionSuggestions: List, + mentionPeerIdentities: Map = emptyMap(), onCommandSuggestionClick: (CommandSuggestion) -> Unit, onMentionSuggestionClick: (String) -> Unit, selectedPrivatePeer: String?, currentChannel: String?, nickname: String, colorScheme: ColorScheme, - showMediaButtons: Boolean + showMediaButtons: Boolean, + modifier: Modifier = Modifier ) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.background + Column( + // Flat, slightly translucent screen background — the same treatment as the top bar, so the + // two bars are visibly the same kind of surface. No gradient: a soft ramp here just looked + // like a smudge above a crisp hairline. The rule is inside the background so the whole bar + // is one surface with a top border, rather than a line floating over the conversation. + modifier = modifier + .fillMaxWidth() + .background(colorScheme.background.copy(alpha = BarBackgroundAlpha)) ) { - Column { - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) - // Command suggestions box - if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { - CommandSuggestionsBox( - suggestions = commandSuggestions, - onSuggestionClick = onCommandSuggestionClick, - modifier = Modifier.fillMaxWidth() - ) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f)) + // Hairline marking where chrome begins. Faint on purpose — it is a hint, not a border. + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) + + // Command suggestions box + if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { + CommandSuggestionsBox( + suggestions = commandSuggestions, + onSuggestionClick = onCommandSuggestionClick, + modifier = Modifier.fillMaxWidth() + ) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) + } + // Retain the final populated list while the picker exits. The state layer clears + // suggestions together with visibility; without this snapshot the panel would empty and + // snap shut before its shrink/fade animation had a frame to run. + var retainedMentionSuggestions by remember { mutableStateOf(emptyList()) } + LaunchedEffect(mentionSuggestions) { + if (mentionSuggestions.isNotEmpty()) { + retainedMentionSuggestions = mentionSuggestions } - // Mention suggestions box - if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) { + } + val mentionPickerVisible = showMentionSuggestions && mentionSuggestions.isNotEmpty() + val displayedMentionSuggestions = mentionSuggestions.ifEmpty { + retainedMentionSuggestions + } + + AnimatedVisibility( + visible = mentionPickerVisible, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandVertically( + animationSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ), + expandFrom = Alignment.Bottom + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkVertically( + animationSpec = tween( + BitchatMotion.QUICK_MS, + easing = FastOutSlowInEasing + ), + shrinkTowards = Alignment.Bottom + ) + ) { + Column { MentionSuggestionsBox( - suggestions = mentionSuggestions, + suggestions = displayedMentionSuggestions, + mentionPeerIdentities = mentionPeerIdentities, onSuggestionClick = onMentionSuggestionClick, modifier = Modifier.fillMaxWidth() ) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f)) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) } - MessageInput( - value = messageText, - onValueChange = onMessageTextChange, - onSend = onSend, - onSendVoiceNote = onSendVoiceNote, - onSendImageNote = onSendImageNote, - onSendFileNote = onSendFileNote, - selectedPrivatePeer = selectedPrivatePeer, - currentChannel = currentChannel, - nickname = nickname, - showMediaButtons = showMediaButtons, - modifier = Modifier.fillMaxWidth() - ) } + MessageInput( + value = messageText, + onValueChange = onMessageTextChange, + onSend = onSend, + onSendVoiceNote = onSendVoiceNote, + onSendImageNote = onSendImageNote, + onSendFileNote = onSendFileNote, + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + showMediaButtons = showMediaButtons, + mentionPeerIdentities = mentionPeerIdentities, + modifier = Modifier.fillMaxWidth() + ) } } -@OptIn(ExperimentalMaterial3Api::class) + +/** + * Opacity shared by both bars. + * + * Slight, so the conversation scrolling underneath stays faintly perceptible and the chrome reads + * as sitting over the content rather than boxing it in — without ever costing legibility. + */ +private const val BarBackgroundAlpha = 0.88f + +/** + * Fraction of the header that stays fully opaque, measured from the top. + * + * The header is the one place a gradient earns its keep: the status bar is transparent, so the + * header has to be the true background colour where the two meet or the system bar stops looking + * like part of the app. Everything below that stop matches the composer's flat translucency. + */ +private const val HeaderOpaqueStop = 0.72f @Composable private fun ChatFloatingHeader( - headerHeight: Dp, selectedPrivatePeer: String?, currentChannel: String?, nickname: String, @@ -626,42 +710,48 @@ private fun ChatFloatingHeader( ) { val context = androidx.compose.ui.platform.LocalContext.current val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) } - - Surface( + + Box( modifier = Modifier .fillMaxWidth() .zIndex(1f) - .windowInsetsPadding(WindowInsets.statusBars), // Extend into status bar area - color = colorScheme.background // Solid background color extending into status bar - ) { - TopAppBar( - title = { - ChatHeaderContent( - selectedPrivatePeer = selectedPrivatePeer, - currentChannel = currentChannel, - nickname = nickname, - viewModel = viewModel, - onBackClick = { - when { - selectedPrivatePeer != null -> viewModel.endPrivateChat() - currentChannel != null -> viewModel.switchToChannel(null) - } - }, - onSidebarClick = onSidebarToggle, - onTripleClick = onPanicClear, - onShowAppInfo = onShowAppInfo, - onLocationChannelsClick = onLocationChannelsClick, - onLocationNotesClick = { - // Ensure location is loaded before showing sheet - locationManager.refreshChannels() - onLocationNotesClick() - } + // Fully opaque where it meets the system status bar, fading to translucent at its + // lower edge. The status bar itself is transparent, so anything less than opaque at + // the top would let the wallpaper or a light system-bar scrim bleed through and the + // header would stop reading as part of the app. + .background( + Brush.verticalGradient( + 0f to colorScheme.background, + HeaderOpaqueStop to colorScheme.background, + 1f to colorScheme.background.copy(alpha = BarBackgroundAlpha) ) + ) + .windowInsetsPadding(WindowInsets.statusBars) // Extend into status bar area + ) { + // No TopAppBar: it silently injects a 4.dp horizontal pad plus a 12.dp title inset and + // applies its own minimum heights, which made the header's spacing impossible to specify + // exactly. Height and edge insets belong to each header variant, so that a conversation + // header rendered here and one rendered in a sheet are laid out identically. + ChatHeaderContent( + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + viewModel = viewModel, + onBackClick = { + when { + selectedPrivatePeer != null -> viewModel.endPrivateChat() + currentChannel != null -> viewModel.switchToChannel(null) + } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent - ), - modifier = Modifier.height(headerHeight) // Ensure compact header height + onSidebarClick = onSidebarToggle, + onTripleClick = onPanicClear, + onShowAppInfo = onShowAppInfo, + onLocationChannelsClick = onLocationChannelsClick, + onLocationNotesClick = { + // Ensure location is loaded before showing sheet + locationManager.refreshChannels() + onLocationNotesClick() + } ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index d227eb41..d2ab06d3 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -7,9 +7,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.sp import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.mesh.MeshService -import androidx.compose.material3.ColorScheme import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.BitchatPalette +import com.bitchat.android.ui.theme.ChatVisualTokens +import com.bitchat.android.ui.theme.colorForPeer import java.text.SimpleDateFormat import java.util.* @@ -18,6 +19,27 @@ import java.util.* * Extracted from ChatScreen.kt for better organization */ +/** Opacity applied to the `#abcd` disambiguation suffix so the readable name dominates. */ +internal const val SUFFIX_ALPHA = ChatVisualTokens.SenderSuffixAlpha + +/** Compact transcript timestamp; seconds add noise without helping conversation scanning. */ +internal const val CHAT_TIMESTAMP_PATTERN = "HH:mm" + +/** Background opacity for a mention chip referring to somebody else. */ +internal const val MENTION_CHIP_ALPHA = ChatVisualTokens.HighlightAlpha + +/** Background opacity for a mention chip referring to you. Slightly stronger to catch the eye. */ +internal const val MENTION_CHIP_ALPHA_SELF = ChatVisualTokens.HighlightAlpha + +/** + * Mention token grammar shared by rendered messages and the composer. + * + * The optional `#abcd` suffix is part of the mention because it disambiguates peers that use the + * same nickname. Keeping one regex prevents the composer from styling only `@name` while the + * rendered transcript styles the complete token. + */ +internal val MENTION_TOKEN_REGEX = Regex("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)") + /** * Get RSSI-based color for signal strength visualization */ @@ -32,152 +54,31 @@ fun getRSSIColor(rssi: Int): Color { } /** - * Format message as annotated string with iOS-style formatting - * Timestamp at END, peer colors, hashtag suffix handling - */ -fun formatMessageAsAnnotatedString( - message: BitchatMessage, - currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) -): AnnotatedString { - val builder = AnnotatedString.Builder() - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - - // Determine if this message was sent by self - val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) - - if (message.sender != "system") { - // Get base color for this peer (iOS-style color assignment) - val baseColor = if (isSelf) { - Color(0xFFFF9500) // Orange for self (iOS orange) - } else { - getPeerColor(message, isDark) - } - - // Split sender into base name and hashtag suffix - val (baseName, suffix) = splitSuffix(message.sender) - - // Sender prefix "<@" - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append("<@") - builder.pop() - - // Base name (clickable) - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - val nicknameStart = builder.length - val truncatedBase = truncateNickname(baseName) - builder.append(truncatedBase) - val nicknameEnd = builder.length - - // Add click annotation for nickname (store canonical sender name with hash if available) - if (!isSelf) { - builder.addStringAnnotation( - tag = "nickname_click", - annotation = (message.originalSender ?: message.sender), - start = nicknameStart, - end = nicknameEnd - ) - } - builder.pop() - - // Hashtag suffix in lighter color (iOS style) - if (suffix.isNotEmpty()) { - builder.pushStyle(SpanStyle( - color = baseColor.copy(alpha = 0.6f), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append(suffix) - builder.pop() - } - - // Sender suffix "> " - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append("> ") - builder.pop() - - // Message content with iOS-style hashtag and mention highlighting - appendIOSFormattedContent( - builder, - message.content, - message.mentions, - currentUserNickname, - baseColor, - isSelf, - ) - - // iOS-style timestamp at the END (smaller, grey) - // Timestamp (and optional PoW badge) - builder.pushStyle(SpanStyle( - color = Color.Gray.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 4).sp - )) - builder.append(" [${timeFormatter.format(message.timestamp)}]") - // If message has valid PoW difficulty, append bits immediately after timestamp with minimal spacing - message.powDifficulty?.let { bits -> - if (bits > 0) { - builder.append(" ⛨${bits}b") - } - } - builder.pop() - - } else { - // System message - iOS style - builder.pushStyle(SpanStyle( - color = Color.Gray, - fontSize = (BASE_FONT_SIZE - 2).sp, - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic - )) - builder.append("* ${message.content} *") - builder.pop() - - // Timestamp for system messages too - builder.pushStyle(SpanStyle( - color = Color.Gray.copy(alpha = 0.5f), - fontSize = (BASE_FONT_SIZE - 4).sp - )) - builder.append(" [${timeFormatter.format(message.timestamp)}]") - builder.pop() - } - - return builder.toAnnotatedString() -} - -/** - * Build the sender label used by the two-row text-message layout. + * Build the sender label shown above the first message of a group. + * + * Renders `@name` plus a dimmed `#abcd` suffix. The name carries a `nickname_click` + * annotation for everyone except yourself. */ fun formatTextMessageSender( message: BitchatMessage, currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme + myPeerID: String, + palette: BitchatPalette ): AnnotatedString { val builder = AnnotatedString.Builder() - val isDark = - colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) - val senderColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) - val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium + val isSelf = message.isFromSelf(currentUserNickname, myPeerID) + val senderColor = if (isSelf) { + palette.accentOrange + } else { + colorForPeer(peerIdentityForMessage(message), palette) + } + val senderWeight = FontWeight.SemiBold val (baseName, suffix) = splitSuffix(message.sender) builder.pushStyle( SpanStyle( color = senderColor, - fontSize = BASE_FONT_SIZE.sp, + fontSize = ChatVisualTokens.SenderFontSize, fontWeight = senderWeight ) ) @@ -198,9 +99,9 @@ fun formatTextMessageSender( if (suffix.isNotEmpty()) { builder.pushStyle( SpanStyle( - color = senderColor.copy(alpha = 0.6f), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = senderWeight + color = senderColor.copy(alpha = SUFFIX_ALPHA), + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.Normal ) ) builder.append(suffix) @@ -212,10 +113,13 @@ fun formatTextMessageSender( /** * Build the compact timestamp and optional proof-of-work label. + * + * Used standalone by media rows; text messages get the same span appended inline to the end of + * their body via [appendBodyTimestamp]. */ fun formatTextMessageMetadata( message: BitchatMessage, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) ): AnnotatedString { val builder = AnnotatedString.Builder() builder.pushStyle( @@ -233,66 +137,159 @@ fun formatTextMessageMetadata( } /** - * Build only the message body while retaining mention, URL and geohash styling. + * Append the timestamp (and optional PoW difficulty) directly after the message body so it + * trails the final words rather than occupying its own column. + * + * Deliberately carries no click annotation: the timestamp is decoration, and making it + * tappable would create dead zones inside the message body. + */ +private fun appendTimestampText( + builder: AnnotatedString.Builder, + message: BitchatMessage, + timeFormatter: SimpleDateFormat +) { + builder.append(" ") + builder.append(timeFormatter.format(message.timestamp)) + message.powDifficulty?.takeIf { it > 0 }?.let { bits -> + builder.append(" ⛨${bits}b") + } +} + +private fun appendBodyTimestamp( + builder: AnnotatedString.Builder, + message: BitchatMessage, + palette: BitchatPalette, + timeFormatter: SimpleDateFormat, +) { + builder.pushStyle( + SpanStyle( + color = palette.textTertiary, + fontSize = ChatVisualTokens.SystemTimeFontSize, + fontWeight = FontWeight.Normal, + ) + ) + appendTimestampText(builder, message, timeFormatter) + builder.pop() +} + +private fun appendMutedTimestamp( + builder: AnnotatedString.Builder, + message: BitchatMessage, + contentColor: Color, + timeFormatter: SimpleDateFormat, +) { + builder.pushStyle( + SpanStyle( + color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha), + fontSize = ChatVisualTokens.SystemTimeFontSize, + fontWeight = FontWeight.Normal, + ) + ) + appendTimestampText(builder, message, timeFormatter) + builder.pop() +} + +/** + * Build the message body: neutral text with mention/URL/geohash accents, followed by an inline + * trailing timestamp. + * + * Body text is intentionally neutral rather than peer-colored. Colour is reserved for + * `@names`, which is what makes a busy channel scannable. */ fun formatTextMessageBody( message: BitchatMessage, currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme + palette: BitchatPalette, + contentColor: Color, + linkColor: Color, + mentionPeerIdentities: Map = emptyMap(), + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), + includeTimestamp: Boolean = true ): AnnotatedString { val builder = AnnotatedString.Builder() - val isDark = - colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) - val accentColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) appendIOSFormattedContent( builder = builder, content = message.content, - mentions = message.mentions, currentUserNickname = currentUserNickname, - baseColor = accentColor, - isSelf = isSelf, - contentColor = colorScheme.onSurface + palette = palette, + contentColor = contentColor, + linkColor = linkColor, + mentionPeerIdentities = mentionPeerIdentities, ) + + if (includeTimestamp) { + appendBodyTimestamp(builder, message, palette, timeFormatter) + } return builder.toAnnotatedString() } /** - * Build only the nickname + timestamp header line for a message, matching styles of normal messages. + * Build a system / background-action line, e.g. `// Tor started. Routing all chats… 11:09:56`. + * + * The `//` prefix reads as machine narration in a monospace context and is far quieter than + * the previous `* italic asterisk *` treatment, which competed with real messages. + */ +fun formatSystemMessage( + message: BitchatMessage, + contentColor: Color, + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) +): AnnotatedString { + val builder = AnnotatedString.Builder() + builder.pushStyle( + SpanStyle( + color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha), + fontSize = ChatVisualTokens.SystemActionFontSize, + fontWeight = FontWeight.Medium, + ) + ) + builder.append("// ") + builder.append(message.content) + builder.pop() + + appendMutedTimestamp(builder, message, contentColor, timeFormatter) + return builder.toAnnotatedString() +} + +/** + * Header line for media (image / audio / file) rows. + * + * Matches the text-message treatment: `@name#abcd` with no angle brackets, followed by an + * inline trailing timestamp. Media rows have no body text to trail, so the timestamp sits on + * the same line as the name. */ fun formatMessageHeaderAnnotatedString( message: BitchatMessage, currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + myPeerID: String, + palette: BitchatPalette, + contentColor: Color, + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), + includeSender: Boolean = true ): AnnotatedString { val builder = AnnotatedString.Builder() - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + val isSelf = message.isFromSelf(currentUserNickname, myPeerID) - val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) + if (message.sender == "system") { + return formatSystemMessage(message, contentColor, timeFormatter) + } - if (message.sender != "system") { - val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) + if (includeSender) { + val baseColor = if (isSelf) { + palette.accentOrange + } else { + colorForPeer(peerIdentityForMessage(message), palette) + } val (baseName, suffix) = splitSuffix(message.sender) - // "<@" - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append("<@") - builder.pop() - - // Base name (clickable when not self) - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) + builder.pushStyle( + SpanStyle( + color = baseColor, + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.SemiBold, + ) + ) + builder.append("@") val nicknameStart = builder.length builder.append(truncateNickname(baseName)) val nicknameEnd = builder.length @@ -306,112 +303,23 @@ fun formatMessageHeaderAnnotatedString( } builder.pop() - // Hashtag suffix if (suffix.isNotEmpty()) { - builder.pushStyle(SpanStyle( - color = baseColor.copy(alpha = 0.6f), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) + builder.pushStyle( + SpanStyle( + color = baseColor.copy(alpha = SUFFIX_ALPHA), + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.Normal, + ) + ) builder.append(suffix) builder.pop() } - - // Sender suffix ">" - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append(">") - builder.pop() - - // Timestamp and optional PoW bits, matching normal message appearance - builder.pushStyle(SpanStyle( - color = Color.Gray.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 4).sp - )) - builder.append(" [${timeFormatter.format(message.timestamp)}]") - message.powDifficulty?.let { bits -> - if (bits > 0) builder.append(" ⛨${bits}b") - } - builder.pop() - } else { - // System message header (should rarely apply to voice) - builder.pushStyle(SpanStyle( - color = Color.Gray, - fontSize = (BASE_FONT_SIZE - 2).sp, - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic - )) - builder.append("* ${message.content} *") - builder.pop() - builder.pushStyle(SpanStyle( - color = Color.Gray.copy(alpha = 0.5f), - fontSize = (BASE_FONT_SIZE - 4).sp - )) - builder.append(" [${timeFormatter.format(message.timestamp)}]") - builder.pop() } + appendMutedTimestamp(builder, message, contentColor, timeFormatter) return builder.toAnnotatedString() } -/** - * iOS-style peer color assignment using djb2 hash algorithm - * Avoids orange (~30°) reserved for self messages - */ -fun getPeerColor(message: BitchatMessage, isDark: Boolean): Color { - // Create seed from peer identifier (prioritizing stable keys) - val seed = when { - message.senderPeerID?.startsWith("nostr:") == true || message.senderPeerID?.startsWith("nostr_") == true -> { - // For Nostr peers, use the full key if available, otherwise the peer ID - "nostr:${message.senderPeerID.lowercase()}" - } - message.senderPeerID?.length == 16 -> { - // For ephemeral peer IDs, try to get stable Noise key, fallback to peer ID - "noise:${message.senderPeerID.lowercase()}" - } - message.senderPeerID?.length == 64 -> { - // This is already a stable Noise key - "noise:${message.senderPeerID.lowercase()}" - } - else -> { - // Fallback to sender name - message.sender.lowercase() - } - } - - return colorForPeerSeed(seed, isDark) -} - -/** - * Generate consistent peer color using djb2 hash (matches iOS algorithm exactly) - */ -fun colorForPeerSeed(seed: String, isDark: Boolean): Color { - // djb2 hash algorithm (matches iOS implementation) - var hash = 5381UL - for (byte in seed.toByteArray()) { - hash = ((hash shl 5) + hash) + byte.toUByte().toULong() - } - - var hue = (hash % 360UL).toDouble() / 360.0 - - // Avoid orange (~30°) reserved for self (matches iOS logic) - val orange = 30.0 / 360.0 - if (kotlin.math.abs(hue - orange) < 0.05) { - hue = (hue + 0.12) % 1.0 - } - - val saturation = if (isDark) 0.50 else 0.70 - val brightness = if (isDark) 0.85 else 0.35 - - return Color.hsv( - hue = (hue * 360).toFloat(), - saturation = saturation.toFloat(), - value = brightness.toFloat() - ) -} - /** * Split a name into base and a '#abcd' suffix if present (matches iOS splitSuffix exactly) */ @@ -430,23 +338,106 @@ fun splitSuffix(name: String): Pair { } /** - * iOS-style content formatting with proper hashtag and mention handling + * Build a case-insensitive mention-token lookup from canonical peer identities. + * + * Suffixed names such as `alice#04af` resolve exactly. Their unsuffixed base is only retained when + * it identifies one peer; ambiguous bases are deliberately omitted rather than coloring a mention + * as the wrong person. + */ +internal fun buildMentionPeerIdentityMap( + messages: List, + knownPeers: List> = emptyList(), +): Map { + val candidates = linkedMapOf>() + + fun add(displayName: String, identity: PeerIdentity) { + val normalizedName = displayName.trim().removePrefix("@") + if (normalizedName.isEmpty()) return + + val (baseName, suffix) = splitSuffix(normalizedName) + val exactKey = normalizedName.lowercase(Locale.ROOT) + candidates.getOrPut(exactKey) { linkedSetOf() }.add(identity) + + if (suffix.isNotEmpty()) { + val baseKey = baseName.lowercase(Locale.ROOT) + candidates.getOrPut(baseKey) { linkedSetOf() }.add(identity) + } + } + + messages + .asSequence() + .filterNot { it.sender == "system" } + .forEach { add(it.sender, peerIdentityForMessage(it)) } + knownPeers.forEach { (displayName, identity) -> add(displayName, identity) } + + return candidates.mapNotNull { (token, identities) -> + identities.singleOrNull()?.let { token to it } + }.toMap() +} + +internal fun resolveMentionPeerIdentity( + mention: String, + mentionPeerIdentities: Map, +): PeerIdentity? { + val mentionWithoutAt = mention.trim().removePrefix("@") + val baseName = splitSuffix(mentionWithoutAt).first + return mentionPeerIdentities[mentionWithoutAt.lowercase(Locale.ROOT)] + ?: mentionPeerIdentities[baseName.lowercase(Locale.ROOT)] +} + +/** + * Resolve the deterministic color for a mention on every surface that displays one. + * + * Exact suffixed tokens win; an unsuffixed nickname is only present in the identity map when it is + * unambiguous. The nickname fallback preserves the legacy behavior for peers with no stable ID. + */ +internal fun colorForMention( + mention: String, + mentionPeerIdentities: Map, + palette: BitchatPalette, +): Color { + val mentionWithoutAt = mention.trim().removePrefix("@") + val identity = resolveMentionPeerIdentity(mentionWithoutAt, mentionPeerIdentities) + ?: PeerIdentity.nickname(mentionWithoutAt) + return colorForPeer(identity, palette) +} + +/** + * A bare `anon` label means the geohash heartbeat has not announced a username yet. The transport + * may append a `#abcd` disambiguator, which does not turn it into an announced name. Names such as + * `anon1234`, `anonymous`, and `anonracer` are real announced usernames. + */ +internal fun isUnannouncedNickname(displayName: String): Boolean { + val base = splitSuffix(displayName.trim()).first + return base.equals("anon", ignoreCase = true) +} + +/** + * iOS-style content formatting with proper hashtag and mention handling. + * + * Redesign notes: + * - Plain text renders in Material `onSurface`; colour is reserved for `@mentions`, + * links and geohashes. + * - Mentions get a tinted background chip so they read as a distinct token inside a sentence. + * The chip is tinted by *the mentioned peer's* colour, not the sender's, so `@alice` looks + * the same everywhere she is referenced. + * - Neither "self" nor "you were mentioned" bolds the whole body any more. Bolding entire + * paragraphs was the single biggest source of visual noise in the old layout; the mention + * chip carries that emphasis instead. */ private fun appendIOSFormattedContent( builder: AnnotatedString.Builder, content: String, - mentions: List?, currentUserNickname: String, - baseColor: Color, - isSelf: Boolean, - contentColor: Color = baseColor, + palette: BitchatPalette, + contentColor: Color, + linkColor: Color, + mentionPeerIdentities: Map, ) { - // iOS-style patterns: allow optional '#abcd' suffix in mentions val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex() - val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex() val hashtagMatches = hashtagPattern.findAll(content).toList() - val mentionMatches = mentionPattern.findAll(content).toList() + val mentionMatches = MENTION_TOKEN_REGEX.findAll(content).toList() // Combine and sort matches, but exclude hashtags that overlap with mentions val mentionRanges = mentionMatches.map { it.range } @@ -507,28 +498,28 @@ private fun appendIOSFormattedContent( } allMatches.sortBy { it.first.first } - + + val plainStyle = SpanStyle( + color = contentColor, + fontSize = BASE_FONT_SIZE.sp, + fontWeight = FontWeight.Normal + ) + val linkStyle = SpanStyle( + color = linkColor, + fontSize = BASE_FONT_SIZE.sp, + fontWeight = FontWeight.Normal, + textDecoration = TextDecoration.Underline + ) + var lastEnd = 0 - val isMentioned = mentions?.contains(currentUserNickname) == true - + for ((range, type) in allMatches) { // Add text before match if (lastEnd < range.first) { val beforeText = content.substring(lastEnd, range.first) if (beforeText.isNotEmpty()) { - builder.pushStyle(SpanStyle( - color = contentColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) - if (isMentioned) { - // Make entire message bold if user is mentioned - builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - builder.append(beforeText) - builder.pop() - } else { - builder.append(beforeText) - } + builder.pushStyle(plainStyle) + builder.append(beforeText) builder.pop() } } @@ -540,65 +531,58 @@ private fun appendIOSFormattedContent( // iOS-style mention with hashtag suffix support val mentionWithoutAt = matchText.removePrefix("@") val (mBase, mSuffix) = splitSuffix(mentionWithoutAt) - - // Check if this mention targets current user + + // Mentions targeting you are the one thing worth shouting about. val isMentionToMe = mBase == currentUserNickname - val mentionColor = if (isMentionToMe) Color(0xFFFF9500) else baseColor - - // "@" symbol + val mentionColor = if (isMentionToMe) { + palette.accentOrange + } else { + colorForMention( + mention = mentionWithoutAt, + mentionPeerIdentities = mentionPeerIdentities, + palette = palette, + ) + } + val chipAlpha = if (isMentionToMe) MENTION_CHIP_ALPHA_SELF else MENTION_CHIP_ALPHA + val mentionWeight = if (isMentionToMe) FontWeight.Bold else FontWeight.SemiBold + + // A single outer span carrying the background makes the chip render as one + // continuous rectangle. Pushing the background per-token would leave hairline + // seams between "@", the name and the "#abcd" suffix. + builder.pushStyle(SpanStyle(background = mentionColor.copy(alpha = chipAlpha))) + builder.pushStyle(SpanStyle( color = mentionColor, fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold + fontWeight = mentionWeight )) builder.append("@") - builder.pop() - - // Base name (truncate for rendering) - builder.pushStyle(SpanStyle( - color = mentionColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold - )) builder.append(truncateNickname(mBase)) builder.pop() - + // Hashtag suffix in lighter color if (mSuffix.isNotEmpty()) { builder.pushStyle(SpanStyle( - color = mentionColor.copy(alpha = 0.6f), + color = mentionColor.copy(alpha = SUFFIX_ALPHA), fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold + fontWeight = mentionWeight )) builder.append(mSuffix) builder.pop() } + + builder.pop() // background chip } "hashtag" -> { // Render general hashtags like normal content - builder.pushStyle(SpanStyle( - color = contentColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) - if (isMentioned) { - builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - builder.append(matchText) - builder.pop() - } else { - builder.append(matchText) - } + builder.pushStyle(plainStyle) + builder.append(matchText) builder.pop() } else -> { if (type == "geohash") { - // Style geohash in blue, underlined, and add click annotation - builder.pushStyle(SpanStyle( - color = Color(0xFF007AFF), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold, - textDecoration = TextDecoration.Underline - )) + // Style geohash as a link and add click annotation + builder.pushStyle(linkStyle) val start = builder.length builder.append(matchText) val end = builder.length @@ -611,13 +595,8 @@ private fun appendIOSFormattedContent( ) builder.pop() } else if (type == "url") { - // Style URL in blue, underlined, and add click annotation with the raw text - builder.pushStyle(SpanStyle( - color = Color(0xFF007AFF), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold, - textDecoration = TextDecoration.Underline - )) + // Style URL as a link and add click annotation with the raw text + builder.pushStyle(linkStyle) val start = builder.length builder.append(matchText) val end = builder.length @@ -630,11 +609,7 @@ private fun appendIOSFormattedContent( builder.pop() } else { // Fallback: treat as normal text - builder.pushStyle(SpanStyle( - color = contentColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) + builder.pushStyle(plainStyle) builder.append(matchText) builder.pop() } @@ -647,18 +622,8 @@ private fun appendIOSFormattedContent( // Add remaining text if (lastEnd < content.length) { val remainingText = content.substring(lastEnd) - builder.pushStyle(SpanStyle( - color = contentColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) - if (isMentioned) { - builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - builder.append(remainingText) - builder.pop() - } else { - builder.append(remainingText) - } + builder.pushStyle(plainStyle) + builder.append(remainingText) builder.pop() } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt index 0cfa91ba..f0647e6c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt @@ -6,11 +6,12 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.LocalBitchatPalette import androidx.compose.ui.res.stringResource import com.bitchat.android.R import androidx.compose.ui.platform.LocalClipboardManager @@ -35,14 +36,13 @@ fun ChatUserSheet( val coroutineScope = rememberCoroutineScope() val clipboardManager = LocalClipboardManager.current - // iOS system colors (matches LocationChannelsSheet exactly) val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green - val standardBlue = Color(0xFF007AFF) // iOS blue - val standardPurple = if (isDark) Color(0xFFBF5AF2) else Color(0xFFAF52DE) // iOS purple - val standardRed = Color(0xFFFF3B30) // iOS red - val standardGrey = if (isDark) Color(0xFF8E8E93) else Color(0xFF6D6D70) // iOS grey + val palette = LocalBitchatPalette.current + val standardGreen = colorScheme.primary + val standardBlue = colorScheme.secondary + val standardPurple = palette.accentPurple + val standardRed = colorScheme.error + val standardGrey = colorScheme.onSurfaceVariant if (isPresented) { BitchatBottomSheet( @@ -59,7 +59,7 @@ fun ChatUserSheet( Text( text = stringResource(R.string.at_nickname, targetNickname), fontSize = 18.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) @@ -67,7 +67,7 @@ fun ChatUserSheet( Text( text = if (selectedMessage != null) stringResource(R.string.choose_action_message_or_user) else stringResource(R.string.choose_action_user), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -183,7 +183,7 @@ fun ChatUserSheet( Text( text = stringResource(R.string.cancel_lower), fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } @@ -214,7 +214,7 @@ private fun UserActionRow( Text( text = title, fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, color = titleColor ) @@ -222,7 +222,7 @@ private fun UserActionRow( Text( text = subtitle, fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 82a70226..be27db44 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -442,42 +442,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) @@ -1259,6 +1226,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. @@ -1414,8 +1386,14 @@ class ChatViewModel( /** * Begin sampling multiple geohashes for participant activity */ - fun beginGeohashSampling(geohashes: List) { - geohashViewModel.beginGeohashSampling(geohashes) + fun beginGeohashSampling( + liveLocationGeohashes: Collection, + userSelectedGeohashes: Collection, + ) { + geohashViewModel.beginGeohashSampling( + liveLocationGeohashes = liveLocationGeohashes, + userSelectedGeohashes = userSelectedGeohashes + ) } /** @@ -1506,22 +1484,17 @@ class ChatViewModel( } } - // MARK: - iOS-Compatible Color System + // MARK: - Canonical peer identities /** - * Get consistent color for a mesh peer by ID (iOS-compatible) + * Return the stable identity used by every UI surface to color a mesh peer. */ - fun colorForMeshPeer(peerID: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - // Try to get stable Noise key, fallback to peer ID - val seed = "noise:${peerID.lowercase()}" - return colorForPeerSeed(seed, isDark).copy() - } + fun peerIdentityForMeshPeer(peerID: String): PeerIdentity = PeerIdentity.mesh(peerID) /** - * Get consistent color for a Nostr pubkey (iOS-compatible) + * Return the stable identity used by every UI surface to color a Nostr peer. */ - fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark) -} + fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity = + geohashViewModel.peerIdentityForNostrPubkey(pubkeyHex) } diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index a92d2d60..34b205af 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import java.util.Date +import java.util.Locale /** * Handles processing of IRC-style commands @@ -448,18 +449,28 @@ class CommandProcessor( is com.bitchat.android.geohash.ChannelID.Mesh, null -> { // Mesh channel: use Bluetooth mesh peer nicknames - meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] } + val peerNicknames = meshService.getPeerNicknames() + peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] } } is com.bitchat.android.geohash.ChannelID.Location -> { // Location channel: use geohash participants with collision-resistant suffixes - val geohashPeople = viewModel.geohashPeople.value ?: emptyList() + val geohashPeople = viewModel.geohashPeople.value val currentNickname = state.getNicknameValue() + val duplicateNames = duplicateGeohashBaseNames(geohashPeople) geohashPeople.mapNotNull { person -> - val displayName = person.displayName - // Exclude self from suggestions - if (displayName.startsWith("${currentNickname}#")) { + val baseName = splitSuffix(person.displayName).first + val hasNicknameCollision = + baseName.lowercase(Locale.ROOT) in duplicateNames + val displayName = disambiguatedGeohashDisplayName(person, duplicateNames) + // A unique local nickname can be excluded directly. If it collides, the + // nickname alone cannot identify which row is self, so keep the suffixed + // rows rather than accidentally hiding the other user. + if ( + !hasNicknameCollision && + baseName.equals(currentNickname, ignoreCase = true) + ) { null } else { displayName @@ -469,13 +480,11 @@ class CommandProcessor( } } else { // Fallback to mesh peers if no viewModel available - meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] } + val peerNicknames = meshService.getPeerNicknames() + peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] } } - // Filter nicknames based on the text after @ - val filteredNicknames = peerCandidates.filter { nickname -> - nickname.startsWith(textAfterAt, ignoreCase = true) - }.sorted() + val filteredNicknames = filterMentionCandidates(peerCandidates, textAfterAt) if (filteredNicknames.isNotEmpty()) { state.setMentionSuggestions(filteredNicknames) @@ -533,3 +542,22 @@ class CommandProcessor( } } } + +/** + * Keep mention autocomplete useful in crowded channels: a bare `anon` identity has not announced + * a username and is not actionable. Names such as `anon1234` are announced usernames and remain + * valid mention targets. + */ +internal fun filterMentionCandidates( + candidates: List, + query: String +): List { + return candidates.asSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .filterNot(::isUnannouncedNickname) + .filter { nickname -> nickname.startsWith(query, ignoreCase = true) } + .distinctBy { nickname -> nickname.lowercase(Locale.ROOT) } + .sortedWith(String.CASE_INSENSITIVE_ORDER) + .toList() +} diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt index b338c864..4f37d47a 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 0bdc85ee..0cf88b74 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -1,39 +1,34 @@ package com.bitchat.android.ui -import android.util.Log -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.Explore -import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material.icons.filled.Email +import android.util.Log +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.bitchat.android.ui.theme.BASE_FONT_SIZE -import java.util.* import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.painterResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.colorForPeer import com.bitchat.android.R +import com.bitchat.android.ui.theme.LocalBitchatPalette +import java.util.* /** - * GeohashPeopleList - iOS-compatible component for displaying geohash participants - * Shows peers discovered through Nostr ephemeral events instead of Bluetooth peers + * Geohash people list — card groups matching location / settings sheet rows. */ -/** - * GeoPerson data class - matches iOS GeoPerson structure exactly - */ data class GeoPerson( val id: String, // pubkey hex (lowercased) - matches iOS - val displayName: String, // nickname with #suffix - matches iOS + val displayName: String, // nickname with #suffix - matches iOS val lastSeen: Date // activity timestamp - matches iOS ) @@ -43,116 +38,227 @@ fun GeohashPeopleList( onTapPerson: () -> Unit, modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme - - // Observe geohash people from ChatViewModel val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle() + val teleportedGeo by viewModel.teleportedGeo.collectAsStateWithLifecycle() val nickname by viewModel.nickname.collectAsStateWithLifecycle() val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() - - Column { - // Header matching iOS style - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.LocationOn, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(R.string.geohash_people_header), - style = MaterialTheme.typography.labelSmall.copy( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold - ), - color = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - - if (geohashPeople.isEmpty()) { - // Empty state - matches iOS "nobody around..." - Text( - text = stringResource(R.string.nobody_around), - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = colorScheme.onSurface.copy(alpha = 0.5f), - modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp) - ) - } else { - // Get current geohash identity for "me" detection - val myHex = remember(selectedLocationChannel) { - when (val channel = selectedLocationChannel) { - is com.bitchat.android.geohash.ChannelID.Location -> { - try { - val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity( - forGeohash = channel.channel.geohash, - context = viewModel.getApplication() - ) - identity.publicKeyHex.lowercase() - } catch (e: Exception) { - Log.e("GeohashPeopleList", "Failed to derive identity: ${e.message}") - null - } - } - else -> null - } - } - - // Sort people: me first, then by lastSeen (matches iOS exactly) - val orderedPeople = remember(geohashPeople, myHex) { - geohashPeople.sortedWith { a, b -> - when { - myHex != null && a.id == myHex && b.id != myHex -> -1 - myHex != null && b.id == myHex && a.id != myHex -> 1 - else -> b.lastSeen.compareTo(a.lastSeen) // Most recent first - } - } - } - // Compute base name collisions to decide whether to show hash suffix - val baseNameCounts = remember(geohashPeople) { - val counts = mutableMapOf() - geohashPeople.forEach { person -> - val (b, _) = com.bitchat.android.ui.splitSuffix(person.displayName) - counts[b] = (counts[b] ?: 0) + 1 + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + val myHex = remember(selectedLocationChannel) { + when (val channel = selectedLocationChannel) { + is com.bitchat.android.geohash.ChannelID.Location -> { + try { + val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity( + forGeohash = channel.channel.geohash, + context = viewModel.getApplication() + ) + identity.publicKeyHex.lowercase(Locale.ROOT) + } catch (e: Exception) { + Log.e("GeohashPeopleList", "Failed to derive identity: ${e.message}") + null } - counts } - - val firstID = orderedPeople.firstOrNull()?.id - - orderedPeople.forEach { person -> + else -> null + } + } + val peopleIncludingSelf = remember(geohashPeople, myHex, nickname) { + if (myHex != null && geohashPeople.none { it.id.equals(myHex, ignoreCase = true) }) { + listOf( + GeoPerson( + id = myHex, + displayName = nickname.ifBlank { "anon" }, + lastSeen = Date(0) + ) + ) + geohashPeople + } else { + geohashPeople + } + } + val sections = remember(peopleIncludingSelf, myHex, isTeleported, teleportedGeo) { + sectionGeohashPeople( + people = peopleIncludingSelf, + myId = myHex, + selfIsTeleported = isTeleported, + teleportedIds = teleportedGeo + ) + } + val displayedPeople = remember(sections) { + sections.onLocation + sections.teleportedIn + } + val teleportedPersonIds = remember(sections.teleportedIn) { + sections.teleportedIn.mapTo(mutableSetOf()) { it.id.lowercase(Locale.ROOT) } + } + val duplicateBaseNames = remember(displayedPeople) { + duplicateGeohashBaseNames(displayedPeople) + } + + Column(modifier = modifier) { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_people, + title = stringResource(R.string.people_count_title, displayedPeople.size) + ) + + if (displayedPeople.isEmpty()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Text( + text = stringResource(R.string.nobody_around), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = palette.textTertiary, + modifier = Modifier.padding( + horizontal = SheetRowHorizontal, + vertical = SheetRowVertical + ) + ) + } + } else { + @Composable + fun personRow(person: GeoPerson) { + val isMe = myHex != null && person.id.equals(myHex, ignoreCase = true) + val personIsTeleported = if (isMe) { + isTeleported + } else { + person.id.lowercase(Locale.ROOT) in teleportedPersonIds + } GeohashPersonItem( person = person, - isFirst = person.id == firstID, - isMe = myHex != null && person.id == myHex, + isMe = isMe, hasUnreadDM = unreadPrivateMessages.contains("nostr_${person.id.take(16)}"), - isTeleported = person.id != myHex && viewModel.isPersonTeleported(person.id), - isMyTeleported = person.id == myHex && isTeleported, - nickname = nickname, - colorScheme = colorScheme, + isTeleported = personIsTeleported, viewModel = viewModel, - showHashSuffix = (baseNameCounts[com.bitchat.android.ui.splitSuffix(person.displayName).first] ?: 0) > 1, + showHashSuffix = splitSuffix(person.displayName) + .first + .lowercase(Locale.ROOT) in duplicateBaseNames, onTap = { - if (person.id != myHex) { - // TODO: Re-enable when NIP-17 geohash DM issues are fixed - // Start geohash DM (iOS-compatible) + if (!isMe) { viewModel.startGeohashDM(person.id) onTapPerson() } } ) } + + if (sections.onLocation.isNotEmpty()) { + AboutSectionLabel(text = stringResource(R.string.section_on_location)) + PeopleCard( + people = sections.onLocation, + row = { personRow(it) } + ) + } + + if (sections.teleportedIn.isNotEmpty()) { + AboutSectionLabel(text = stringResource(R.string.section_teleported_in)) + PeopleCard( + people = sections.teleportedIn, + row = { personRow(it) } + ) + } + } + } +} + +internal data class GeohashPeopleSections( + val onLocation: List, + val teleportedIn: List +) + +/** + * Names that require a short identity suffix, calculated across both people sections. + * + * Matching is case-insensitive to mirror geohash chat's nickname collision handling. + */ +internal fun duplicateGeohashBaseNames(people: List): Set = + people + .groupingBy { splitSuffix(it.displayName).first.lowercase(Locale.ROOT) } + .eachCount() + .filterValues { it > 1 } + .keys + +/** + * The same `#abcd` disambiguator used by geohash chat. + * + * Presence rows normally carry only a base nickname, so derive the suffix from the full Nostr + * public key when a collision exists. Preserve an already-announced suffix for compatibility. + */ +internal fun geohashIdentitySuffix(person: GeoPerson, showHashSuffix: Boolean): String { + if (!showHashSuffix) return "" + val announcedSuffix = splitSuffix(person.displayName).second + return announcedSuffix.ifEmpty { "#${person.id.takeLast(4)}" } +} + +internal fun disambiguatedGeohashDisplayName( + person: GeoPerson, + duplicateBaseNames: Set, +): String { + val baseName = splitSuffix(person.displayName).first + val showSuffix = baseName.lowercase(Locale.ROOT) in duplicateBaseNames + return baseName + geohashIdentitySuffix(person, showSuffix) +} + +/** + * Split announced identities by how they entered this geohash. Bare `anon` heartbeat identities + * are omitted, while announced names such as `anon1234` remain ordinary participants. Self is + * retained even before a nickname announcement and is always first in the matching section. + */ +internal fun sectionGeohashPeople( + people: List, + myId: String?, + selfIsTeleported: Boolean, + teleportedIds: Set +): GeohashPeopleSections { + val normalizedMyId = myId?.lowercase(Locale.ROOT) + val normalizedTeleportedIds = teleportedIds + .mapTo(mutableSetOf()) { it.lowercase(Locale.ROOT) } + fun isSelf(person: GeoPerson): Boolean = + normalizedMyId != null && person.id.lowercase(Locale.ROOT) == normalizedMyId + fun isTeleported(person: GeoPerson): Boolean = + if (isSelf(person)) selfIsTeleported + else person.id.lowercase(Locale.ROOT) in normalizedTeleportedIds + + val displayedPeople = people.filter { person -> + isSelf(person) || !isUnannouncedNickname(person.displayName) + } + val ordered = displayedPeople.sortedWith( + compareByDescending(::isSelf) + .thenByDescending { it.lastSeen } + ) + return GeohashPeopleSections( + onLocation = ordered.filterNot(::isTeleported), + teleportedIn = ordered.filter(::isTeleported) + ) +} + +/** One uncapped card of people. The enclosing sheet owns scrolling. */ +@Composable +private fun PeopleCard( + people: List, + row: @Composable (GeoPerson) -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + AnimatedRowColumn(items = people, key = { it.id }) { index, person -> + Column { + if (index > 0) SheetCardDivider() + row(person) + } } } } @@ -160,113 +266,97 @@ fun GeohashPeopleList( @Composable private fun GeohashPersonItem( person: GeoPerson, - isFirst: Boolean, isMe: Boolean, hasUnreadDM: Boolean, isTeleported: Boolean, - isMyTeleported: Boolean, - nickname: String, - colorScheme: ColorScheme, viewModel: ChatViewModel, showHashSuffix: Boolean, onTap: () -> Unit ) { + val palette = LocalBitchatPalette.current + + val statusIconRes = + if (isTeleported) R.drawable.ic_spec_teleport + else R.drawable.ic_spec_on_location_person + + val (baseNameRaw, _) = splitSuffix(person.displayName) + val baseName = truncateNickname(baseNameRaw) + val suffix = geohashIdentitySuffix(person, showHashSuffix) + val assignedColor = colorForPeer( + viewModel.peerIdentityForNostrPubkey(person.id), + palette + ) + val baseColor = if (isMe) palette.accentOrange else assignedColor + Row( modifier = Modifier .fillMaxWidth() - .clickable { onTap() } - .padding(horizontal = 24.dp, vertical = 4.dp) - .padding(top = if (isFirst) 10.dp else 0.dp), + // Exact height, not padding: a row that sizes to its content makes the card change + // height whenever the list reorders. + .height(SheetRowHeight) + .clickable(onClick = onTap) + .padding(horizontal = SheetRowHorizontal), verticalAlignment = Alignment.CenterVertically ) { - // Icon logic matching iOS exactly - if (hasUnreadDM) { - // Unread DM indicator (orange envelope) - Icon( - imageVector = Icons.Filled.Email, - contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(12.dp), - tint = Color(0xFFFF9500) // iOS orange - ) - } else { - // Face icon with teleportation state - val (iconName, iconColor) = when { - isMe && isMyTeleported -> "face.dashed" to Color(0xFFFF9500) // Orange for teleported me - isTeleported -> "face.dashed" to colorScheme.onSurface // Regular color for teleported others - isMe -> "face.smiling" to Color(0xFFFF9500) // Orange for me - else -> "face.smiling" to colorScheme.onSurface // Regular color for others + Box( + modifier = Modifier.size(SheetRowLeadingSlot), + contentAlignment = Alignment.Center + ) { + if (hasUnreadDM) { + Icon( + imageVector = Icons.Filled.Email, + contentDescription = stringResource(R.string.cd_unread_message), + modifier = Modifier.size(22.dp), + tint = palette.accentOrange + ) + } else { + Icon( + painter = painterResource(statusIconRes), + contentDescription = if (isTeleported) { + stringResource(R.string.cd_teleported) + } else { + stringResource(R.string.section_on_location) + }, + modifier = Modifier.size(22.dp), + tint = baseColor + ) } - - // Use appropriate Material icon (closest match to iOS SF Symbols) - val icon = when (iconName) { - "face.dashed" -> Icons.Outlined.Explore - else -> Icons.Outlined.LocationOn - } - - Icon( - imageVector = icon, - contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User", - modifier = Modifier.size(12.dp), - tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) // Make dashed faces slightly transparent - ) } - - Spacer(modifier = Modifier.width(8.dp)) - - // Display name with suffix handling - val (baseNameRaw, suffixRaw) = com.bitchat.android.ui.splitSuffix(person.displayName) - val baseName = truncateNickname(baseNameRaw) - val suffix = if (showHashSuffix) suffixRaw else "" - - // Get consistent peer color (matches iOS color assignment exactly) - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val assignedColor = viewModel.colorForNostrPubkey(person.id, isDark) - val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor - + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { - // Base name with peer-specific color Text( text = baseName, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal - ), + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, color = baseColor, maxLines = 1, overflow = TextOverflow.Ellipsis ) - - // Suffix (collision-resistant #abcd) in lighter shade + if (suffix.isNotEmpty()) { Text( text = suffix, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = baseColor.copy(alpha = 0.6f) + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + color = baseColor.copy(alpha = SUFFIX_ALPHA) ) } - - // "You" indicator for current user + if (isMe) { Text( text = stringResource(R.string.you_suffix), - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), + fontFamily = BitchatFontFamily, + fontSize = 14.sp, color = baseColor ) } } - - Spacer(modifier = Modifier.width(8.dp)) } } - - diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt index e15f9930..9565c384 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -1,5 +1,9 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Remove import android.annotation.SuppressLint import android.app.Activity import android.content.Intent @@ -15,10 +19,6 @@ import android.webkit.WebViewClient import androidx.activity.compose.setContent import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -30,14 +30,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.bitchat.android.ui.theme.BitchatTheme import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import androidx.core.view.updateLayoutParams import com.bitchat.android.geohash.Geohash @@ -87,15 +87,13 @@ class GeohashPickerActivity : OrientationAwareActivity() { val initialPrecision = geohashToFocus?.length ?: 5 setContent { - MaterialTheme { + BitchatTheme { var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") } var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) } var webViewRef by remember { mutableStateOf(null) } - // iOS system-like colors used across app val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + val standardGreen = colorScheme.primary Scaffold { padding -> Box(Modifier.fillMaxSize()) { @@ -186,7 +184,7 @@ class GeohashPickerActivity : OrientationAwareActivity() { text = stringResource(R.string.pan_zoom_instruction), fontSize = 12.sp, textAlign = TextAlign.Center, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier .padding(horizontal = 14.dp, vertical = 10.dp) @@ -211,7 +209,7 @@ class GeohashPickerActivity : OrientationAwareActivity() { Text( text = if (currentGeohash.isNotEmpty()) "#${currentGeohash}" else "select location", fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier @@ -277,7 +275,7 @@ class GeohashPickerActivity : OrientationAwareActivity() { Text( text = stringResource(R.string.select), fontSize = (BASE_FONT_SIZE - 2).sp, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 40465d7d..4fdfa601 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -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() + private val samplingSubscriptionIds = mutableMapOf() + private val liveSamplingSubscriptionGeohashes = mutableSetOf() + private var requestedLiveSamplingGeohashes: Set = emptySet() + private var requestedUserSamplingGeohashes: Set = 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> = state.geohashParticipantCounts val selectedLocationChannel: StateFlow = state.selectedLocationChannel + init { + LiveLocationPrivacyGate.addRevocationListener(liveLocationRevocationListener) + } + fun initialize() { subscriptionManager.connect() // Observe process lifecycle to manage background sampling @@ -126,10 +149,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 @@ -147,8 +173,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}") } @@ -174,6 +202,7 @@ class GeohashViewModel( currentGeohashMsgSubId = null currentGeohashPresenceSubId = null currentDmSubId = null + currentDmGeohash = null activeChannelGeohash = null geoTimer?.cancel() geoTimer = null @@ -187,22 +216,52 @@ class GeohashViewModel( dmHandler.invalidateDoubleRatchetAccount() } - 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( @@ -216,51 +275,73 @@ class GeohashViewModel( powDifficulty = if (pow.enabled) pow.difficulty else null ) messageManager.addChannelMessage("geo:${channel.geohash}", localMsg) - val startedMining = pow.enabled && pow.difficulty > 0 - if (startedMining) { - com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempId) - } - try { - 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 relayManager = NostrRelayManager.getInstance(getApplication()) - relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5) - } finally { - // Ensure we stop the per-message mining animation regardless of success/failure - if (startedMining) { - com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage(tempId) - } - } + 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, + liveLocationToken = liveLocationToken + ) } catch (e: Exception) { Log.e(TAG, "Failed to send geohash message: ${e.message}") } } } - fun beginGeohashSampling(geohashes: List) { - if (geohashes.isEmpty()) { - endGeohashSampling() - return - } - - // Diffing logic to avoid redundant REQ and leaks + fun beginGeohashSampling( + liveLocationGeohashes: Collection, + userSelectedGeohashes: Collection, + ) { + 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()) { @@ -271,11 +352,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() } @@ -293,7 +376,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) { @@ -346,19 +429,28 @@ 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) - fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - val seed = "nostr:${pubkeyHex.lowercase()}" - return colorForPeerSeed(seed, isDark).copy() - } + fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity = + PeerIdentity.nostr(pubkeyHex) private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { geoTimer?.cancel(); geoTimer = null 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 -> { @@ -370,7 +462,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() @@ -381,19 +478,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. @@ -411,14 +511,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 ) } @@ -427,14 +531,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 ) } @@ -443,18 +551,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() { @@ -471,13 +579,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 @@ -496,7 +620,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 @@ -506,15 +630,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 { diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 91555b38..7302379b 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -1,27 +1,55 @@ package com.bitchat.android.ui + +import com.bitchat.android.ui.theme.BitchatFontFamily // [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.expandVertically +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue @@ -36,8 +64,10 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.text.withStyle import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.BitchatPalette +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.features.voice.normalizeAmplitudeSample import com.bitchat.android.features.voice.AudioWaveformExtractor import com.bitchat.android.ui.media.RealtimeScrollingWaveform @@ -53,41 +83,28 @@ import com.bitchat.android.ui.media.FilePickerButton * VisualTransformation that styles slash commands with background and color * while preserving cursor positioning and click handling */ -class SlashCommandVisualTransformation : VisualTransformation { +class SlashCommandVisualTransformation( + private val commandColor: Color, + private val commandBackground: Color, +) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { val slashCommandRegex = Regex("(/\\w+)(?=\\s|$)") - val annotatedString = buildAnnotatedString { - var lastIndex = 0 - - slashCommandRegex.findAll(text.text).forEach { match -> - // Add text before the match - if (match.range.first > lastIndex) { - append(text.text.substring(lastIndex, match.range.first)) - } - - // Add the styled slash command - withStyle( - style = SpanStyle( - color = Color(0xFF00FF7F), // Bright green - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - background = Color(0xFF2D2D2D) // Dark gray background - ) - ) { - append(match.value) - } - - lastIndex = match.range.last + 1 - } - - // Add remaining text - if (lastIndex < text.text.length) { - append(text.text.substring(lastIndex)) - } + val builder = AnnotatedString.Builder(text) + slashCommandRegex.findAll(text.text).forEach { match -> + builder.addStyle( + style = SpanStyle( + color = commandColor, + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + background = commandBackground + ), + start = match.range.first, + end = match.range.last + 1, + ) } return TransformedText( - text = annotatedString, + text = builder.toAnnotatedString(), offsetMapping = OffsetMapping.Identity ) } @@ -97,40 +114,56 @@ class SlashCommandVisualTransformation : VisualTransformation { * VisualTransformation that styles mentions with background and color * while preserving cursor positioning and click handling */ -class MentionVisualTransformation : VisualTransformation { +class MentionVisualTransformation( + private val mentionPeerIdentities: Map, + private val palette: BitchatPalette, +) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { - val mentionRegex = Regex("@([a-zA-Z0-9_]+)") - val annotatedString = buildAnnotatedString { - var lastIndex = 0 - - mentionRegex.findAll(text.text).forEach { match -> - // Add text before the match - if (match.range.first > lastIndex) { - append(text.text.substring(lastIndex, match.range.first)) - } - - // Add the styled mention - withStyle( + val builder = AnnotatedString.Builder(text) + + MENTION_TOKEN_REGEX.findAll(text.text).forEach { match -> + val start = match.range.first + val end = match.range.last + 1 + val suffixOffset = match.value.lastIndexOf('#').takeIf { it > 0 } + val suffixStart = suffixOffset?.let(start::plus) ?: end + val mentionColor = colorForMention( + mention = match.value, + mentionPeerIdentities = mentionPeerIdentities, + palette = palette, + ) + + // Keep the whole token on one continuous color-derived chip. + builder.addStyle( + style = SpanStyle( + background = mentionColor.copy(alpha = MENTION_CHIP_ALPHA), + ), + start = start, + end = end, + ) + builder.addStyle( + style = SpanStyle( + color = mentionColor, + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + ), + start = start, + end = suffixStart, + ) + if (suffixStart < end) { + builder.addStyle( style = SpanStyle( - color = Color(0xFFFF9500), // Orange - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold - ) - ) { - append(match.value) - } - - lastIndex = match.range.last + 1 - } - - // Add remaining text - if (lastIndex < text.text.length) { - append(text.text.substring(lastIndex)) + color = mentionColor.copy(alpha = SUFFIX_ALPHA), + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + ), + start = suffixStart, + end = end, + ) } } return TransformedText( - text = annotatedString, + text = builder.toAnnotatedString(), offsetMapping = OffsetMapping.Identity ) } @@ -159,6 +192,107 @@ class CombinedVisualTransformation(private val transformations: List Unit +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + val accent = if (activeColor == Color.Unspecified) colorScheme.primary else activeColor + + val container by animateColorAsState( + // A tint rather than a fill. A solid accent disc next to the text you are typing was the + // loudest thing on the screen; at 20% it still reads as "armed" without competing. + targetValue = if (isActive) accent.copy(alpha = 0.20f) else palette.inputButton, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerButtonContainer" + ) + val tint by animateColorAsState( + targetValue = if (isActive) accent else colorScheme.onSurfaceVariant, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerButtonTint" + ) + // A small dip on press. Spring rather than tween so the release overshoots very slightly and + // the button feels physical instead of merely animated. + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.88f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "composerButtonScale" + ) + + Box( + modifier = modifier.size(ComposerButtonSize), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(ComposerButtonDisc) + .scale(scale) + .background(container, CircleShape) + .semantics { contentDescription?.let { this.contentDescription = it } }, + contentAlignment = Alignment.Center + ) { + content(tint) + } + } +} + @Composable fun MessageInput( value: TextFieldValue, @@ -171,192 +305,276 @@ fun MessageInput( currentChannel: String?, nickname: String, showMediaButtons: Boolean, + mentionPeerIdentities: Map = emptyMap(), modifier: Modifier = Modifier ) { + val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme val isFocused = remember { mutableStateOf(false) } - val hasText = value.text.isNotBlank() // Check if there's text for send button state - val keyboard = LocalSoftwareKeyboardController.current + val hasText = value.text.isNotBlank() val focusRequester = remember { FocusRequester() } var isRecording by remember { mutableStateOf(false) } var elapsedMs by remember { mutableStateOf(0L) } var amplitude by remember { mutableStateOf(0) } + // Recording is the one state worth shouting about, so it overrides focus. + val borderColor by animateColorAsState( + targetValue = when { + isRecording -> colorScheme.error.copy(alpha = 0.7f) + isFocused.value -> palette.inputOutlineFocused + else -> palette.inputOutline + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerBorder" + ) + // A barely-there lift on focus. Enough to register, not enough to look like a different + // component. Slightly translucent so the messages scrolling underneath stay faintly visible. + val containerColor by animateColorAsState( + targetValue = (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface) + .copy(alpha = ComposerFillAlpha), + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerContainer" + ) + Row( - modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + modifier = modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.Bottom ) { - // Text input with placeholder OR visualizer when recording - Box( - modifier = Modifier.weight(1f) - ) { - // Always keep the text field mounted to retain focus and avoid IME collapse - BasicTextField( - value = value, - onValueChange = onValueChange, - textStyle = MaterialTheme.typography.bodyMedium.copy( - color = colorScheme.primary, - fontFamily = FontFamily.Monospace - ), - cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { - if (hasText) onSend() // Only send if there's text - }), - visualTransformation = CombinedVisualTransformation( - listOf(SlashCommandVisualTransformation(), MentionVisualTransformation()) - ), - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - .onFocusChanged { focusState -> - isFocused.value = focusState.isFocused - } - ) - - // Show placeholder when there's no text and not recording - if (value.text.isEmpty() && !isRecording) { - Text( - text = stringResource(R.string.type_a_message_placeholder), - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace - ), - color = colorScheme.onSurface.copy(alpha = 0.5f), // Muted grey - modifier = Modifier.fillMaxWidth() + // MARK: - The pill. Field and action buttons are one visual object. + Row( + modifier = Modifier + .weight(1f) + .heightIn(min = ComposerMinHeight) + // Grow smoothly as the field wraps to more lines rather than jumping a line at + // a time. + .animateContentSize( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ) + .background(containerColor, ComposerShape) + .border(1.dp, borderColor, ComposerShape), + verticalAlignment = Alignment.Bottom + ) { + Box( + modifier = Modifier + .weight(1f) + .padding(start = 18.dp, end = 4.dp, top = 15.dp, bottom = 15.dp) + ) { + // Always keep the text field mounted to retain focus and avoid IME collapse + BasicTextField( + value = value, + onValueChange = onValueChange, + // Near-white, not terminal green: this is the one place in the app where the + // user is composing rather than reading, and green-on-black is tiring to + // type into. + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = colorScheme.onSurface, + fontFamily = BitchatFontFamily + ), + cursorBrush = SolidColor( + if (isRecording) Color.Transparent else colorScheme.onSurface + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { + if (hasText) onSend() + }), + // Cap the growth so a pasted wall of text cannot swallow the message list. + maxLines = 6, + visualTransformation = remember( + palette, + colorScheme.primary, + mentionPeerIdentities, + ) { + CombinedVisualTransformation( + listOf( + SlashCommandVisualTransformation( + commandColor = colorScheme.primary, + commandBackground = colorScheme.primary.copy(alpha = 0.14f), + ), + MentionVisualTransformation( + mentionPeerIdentities = mentionPeerIdentities, + palette = palette, + ), + ) + ) + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + isFocused.value = focusState.isFocused + } ) - } - // Overlay the real-time scrolling waveform while recording - if (isRecording) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - RealtimeScrollingWaveform( - modifier = Modifier.weight(1f).height(32.dp), - amplitudeNorm = normalizeAmplitudeSample(amplitude) - ) - Spacer(Modifier.width(20.dp)) - val secs = (elapsedMs / 1000).toInt() - val mm = secs / 60 - val ss = secs % 60 - val maxSecs = 10 // 10 second max recording time - val maxMm = maxSecs / 60 - val maxSs = maxSecs % 60 + // Placeholder fades rather than blinking, which matters because it reappears + // every time a message is sent. + val placeholderAlpha by animateFloatAsState( + targetValue = if (value.text.isEmpty() && !isRecording) 1f else 0f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "placeholderAlpha" + ) + if (placeholderAlpha > 0f) { Text( - text = String.format("%02d:%02d / %02d:%02d", mm, ss, maxMm, maxSs), - fontFamily = FontFamily.Monospace, - color = colorScheme.primary, - fontSize = (BASE_FONT_SIZE - 4).sp + text = stringResource(R.string.type_a_message_placeholder), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily + ), + color = palette.textTertiary, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .alpha(placeholderAlpha) ) } - } - } - - Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing - - // Voice and image buttons when no text (only visible in Mesh chat) - if (value.text.isEmpty() && showMediaButtons) { - // Hold-to-record microphone - val bg = if (colorScheme.background == Color.Black) Color(0xFF00FF00).copy(alpha = 0.75f) else Color(0xFF008000).copy(alpha = 0.75f) - // Ensure latest values are used when finishing recording + // Recording visualiser, layered over the (empty) field. + val waveformAlpha by animateFloatAsState( + targetValue = if (isRecording) 1f else 0f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "waveformAlpha" + ) + if (isRecording) { + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(waveformAlpha), + verticalAlignment = Alignment.CenterVertically + ) { + RealtimeScrollingWaveform( + modifier = Modifier.weight(1f).height(22.dp), + amplitudeNorm = normalizeAmplitudeSample(amplitude) + ) + Spacer(Modifier.width(12.dp)) + val secs = (elapsedMs / 1000).toInt() + val maxSecs = 10 // 10 second max recording time + Text( + text = String.format( + "%02d:%02d / %02d:%02d", + secs / 60, secs % 60, maxSecs / 60, maxSecs % 60 + ), + fontFamily = BitchatFontFamily, + color = colorScheme.error, + fontSize = (BASE_FONT_SIZE - 4).sp + ) + } + } + } + + // MARK: - Action cluster, inside the pill. + // + // Swaps between the auxiliary buttons and send. AnimatedContent cross-fades and + // scales between the two, and SizeTransform animates the width change, so typing the + // first character morphs camera+mic into send instead of snapping. val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer) val latestChannel = rememberUpdatedState(currentChannel) val latestOnSendVoiceNote = rememberUpdatedState(onSendVoiceNote) - // Image button (image picker) - hide during recording - if (!isRecording) { - // Revert to original separate buttons: round File button (left) and the old Image plus button (right) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - // DISABLE FILE PICKER - //FilePickerButton( - // onFileReady = { path -> - // onSendFileNote(latestSelectedPeer.value, latestChannel.value, path) - // } - //) - ImagePickerButton( - onImageReady = { outPath -> - onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath) - } - ) - } - } - - Spacer(Modifier.width(1.dp)) - - VoiceRecordButton( - backgroundColor = bg, - onStart = { - isRecording = true - elapsedMs = 0L - // Keep existing focus to avoid IME collapse, but do not force-show keyboard - if (isFocused.value) { - try { focusRequester.requestFocus() } catch (_: Exception) {} + AnimatedContent( + targetState = hasText, + transitionSpec = { + ( + fadeIn(tween(BitchatMotion.STANDARD_MS)) + + scaleIn( + initialScale = 0.7f, + animationSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ) + ) + ).togetherWith( + fadeOut(tween(BitchatMotion.QUICK_MS)) + + scaleOut( + targetScale = 0.7f, + animationSpec = tween( + BitchatMotion.QUICK_MS, + easing = FastOutSlowInEasing + ) + ) + ) using SizeTransform(clip = false) { _, _ -> + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) } }, - onAmplitude = { amp, ms -> - amplitude = amp - elapsedMs = ms - }, - onFinish = { path -> - isRecording = false - // Extract and cache waveform from the actual audio file to match receiver rendering - AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr -> - if (arr != null) { - try { com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr) } catch (_: Exception) {} - } - } - // BLE path (private or public) — use latest values to avoid stale captures - latestOnSendVoiceNote.value( - latestSelectedPeer.value, - latestChannel.value, - path + modifier = Modifier.padding(end = 6.dp, bottom = 6.dp), + label = "composerActions" + ) { showSend -> + if (showSend) { + SendButton( + isAccented = latestSelectedPeer.value != null || latestChannel.value != null, + onSend = onSend ) - } - ) - - } else { - // Send button with enabled/disabled state - IconButton( - onClick = { if (hasText) onSend() }, // Only execute if there's text - enabled = hasText, // Enable only when there's text - modifier = Modifier.size(32.dp) - ) { - // Update send button to match input field colors - Box( - modifier = Modifier - .size(30.dp) - .background( - color = if (!hasText) { - // Disabled state - muted grey - colorScheme.onSurface.copy(alpha = 0.3f) - } else if (selectedPrivatePeer != null || currentChannel != null) { - // Orange for both private messages and channels when enabled - Color(0xFFFF9500).copy(alpha = 0.75f) - } else if (colorScheme.background == Color.Black) { - Color(0xFF00FF00).copy(alpha = 0.75f) // Bright green for dark theme - } else { - Color(0xFF008000).copy(alpha = 0.75f) // Dark green for light theme - }, - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Filled.ArrowUpward, - contentDescription = stringResource(id = R.string.send_message), - modifier = Modifier.size(20.dp), - tint = if (!hasText) { - // Disabled state - muted grey icon - colorScheme.onSurface.copy(alpha = 0.5f) - } else if (selectedPrivatePeer != null || currentChannel != null) { - // Black arrow on orange for both private and channel modes - Color.Black - } else if (colorScheme.background == Color.Black) { - Color.Black // Black arrow on bright green in dark theme + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + if (showMediaButtons) { + // The camera steps aside while recording so the microphone is the + // only thing that can be released. + AnimatedVisibility( + visible = !isRecording, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandHorizontally( + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkHorizontally( + tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ) + ) { + ImagePickerButton( + onImageReady = { outPath -> + onSendImageNote( + latestSelectedPeer.value, + latestChannel.value, + outPath + ) + } + ) + } + + VoiceRecordButton( + isRecording = isRecording, + onStart = { + isRecording = true + elapsedMs = 0L + // Keep existing focus to avoid IME collapse, but do not + // force-show the keyboard. + if (isFocused.value) { + try { focusRequester.requestFocus() } catch (_: Exception) {} + } + }, + onAmplitude = { amp, ms -> + amplitude = amp + elapsedMs = ms + }, + onFinish = { path -> + isRecording = false + // Extract and cache the waveform from the actual audio file + // so it matches the receiver's rendering. + AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr -> + if (arr != null) { + try { + com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr) + } catch (_: Exception) {} + } + } + latestOnSendVoiceNote.value( + latestSelectedPeer.value, + latestChannel.value, + path + ) + }, + // Any capture that ends without a file must clear the recording + // state here too, otherwise the pill stays red with a live + // waveform over an empty field. + onCancel = { + isRecording = false + amplitude = 0 + elapsedMs = 0L + } + ) } else { - Color.White // White arrow on dark green in light theme + // No media in this context, so keep an inert send button rather than + // leaving a hole where the action cluster should be. + SendButton(isAccented = false, onSend = {}, enabled = false) } - ) + } } } } @@ -365,6 +583,42 @@ fun MessageInput( // Auto-stop handled inside VoiceRecordButton } +/** + * Send affordance. Only rendered when there is something to send, so its mere presence is the + * signal; it does not need to shout in the terminal's full-brightness green as well. + */ +@Composable +private fun SendButton( + isAccented: Boolean, + onSend: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + ComposerActionSurface( + isActive = enabled, + isPressed = isPressed, + // Private chats and channels keep their orange identity, disc and glyph together. + activeColor = if (isAccented) palette.accentOrange else colorScheme.primary, + modifier = modifier.clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled + ) { onSend() } + ) { tint -> + Icon( + imageVector = Icons.Filled.ArrowUpward, + contentDescription = stringResource(id = R.string.send_message), + modifier = Modifier.size(ComposerIconSize), + tint = tint + ) + } +} + @Composable fun CommandSuggestionsBox( suggestions: List, @@ -377,8 +631,8 @@ fun CommandSuggestionsBox( modifier = modifier .verticalScroll(rememberScrollState()) .background(colorScheme.surface) - .border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp)) - .padding(vertical = 8.dp) + .border(1.dp, colorScheme.outlineVariant, RoundedCornerShape(8.dp)) + .padding(vertical = 6.dp) ) { suggestions.forEach { suggestion: CommandSuggestion -> CommandSuggestionItem( @@ -395,13 +649,14 @@ fun CommandSuggestionItem( onClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current Row( modifier = Modifier .fillMaxWidth() .clickable { onClick() } - .padding(horizontal = 12.dp, vertical = 3.dp) - .background(Color.Gray.copy(alpha = 0.1f)), + // Roomier rows: at 3.dp vertical these were below a comfortable tap height. + .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -415,11 +670,11 @@ fun CommandSuggestionItem( Text( text = allCommands.joinToString(", "), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium ), color = colorScheme.primary, - fontSize = (BASE_FONT_SIZE - 4).sp + fontSize = (BASE_FONT_SIZE - 2).sp ) // Show syntax if any @@ -427,10 +682,10 @@ fun CommandSuggestionItem( Text( text = syntax, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), - color = colorScheme.onSurface.copy(alpha = 0.8f), - fontSize = (BASE_FONT_SIZE - 5).sp + color = colorScheme.onSurfaceVariant, + fontSize = (BASE_FONT_SIZE - 4).sp ) } @@ -438,34 +693,64 @@ fun CommandSuggestionItem( Text( text = suggestion.description, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 5).sp, + color = palette.textTertiary, + fontSize = (BASE_FONT_SIZE - 4).sp, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun MentionSuggestionsBox( suggestions: List, + mentionPeerIdentities: Map, onSuggestionClick: (String) -> Unit, modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - - Column( + val palette = LocalBitchatPalette.current + + LazyColumn( modifier = modifier + .heightIn(max = MentionSuggestionsMaxHeight) + .animateContentSize( + animationSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ) + ) + .clip(MentionSuggestionsShape) .background(colorScheme.surface) - .border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp)) - .padding(vertical = 8.dp) + .border(1.dp, colorScheme.outlineVariant, MentionSuggestionsShape), + contentPadding = PaddingValues(vertical = MentionSuggestionsVerticalPadding) ) { - suggestions.forEach { suggestion: String -> + items( + items = suggestions, + key = { suggestion -> suggestion.lowercase() } + ) { suggestion -> MentionSuggestionItem( suggestion = suggestion, - onClick = { onSuggestionClick(suggestion) } + userColor = colorForMention( + mention = suggestion, + mentionPeerIdentities = mentionPeerIdentities, + palette = palette, + ), + onClick = { onSuggestionClick(suggestion) }, + modifier = Modifier.animateItem( + fadeInSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ), + placementSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ), + fadeOutSpec = tween(BitchatMotion.QUICK_MS) + ) ) } } @@ -474,37 +759,76 @@ fun MentionSuggestionsBox( @Composable fun MentionSuggestionItem( suggestion: String, - onClick: () -> Unit + userColor: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme - + val palette = LocalBitchatPalette.current + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val pressedBackground by animateColorAsState( + targetValue = if (isPressed) { + userColor.copy(alpha = 0.10f) + } else { + Color.Transparent + }, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + label = "mentionSuggestionPressedBackground" + ) + val pressedScale by animateFloatAsState( + targetValue = if (isPressed) 0.985f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "mentionSuggestionPressedScale" + ) + Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .clickable { onClick() } - .padding(horizontal = 12.dp, vertical = 3.dp) - .background(Color.Gray.copy(alpha = 0.1f)), + .height(MentionSuggestionRowHeight) + .scale(pressedScale) + .background(pressedBackground) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick + ) + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource(R.string.mention_suggestion_at, suggestion), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.SemiBold ), - color = Color(0xFFFF9500), // Orange like mentions - fontSize = (BASE_FONT_SIZE - 4).sp + color = userColor, + fontSize = (BASE_FONT_SIZE - 2).sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) - - Spacer(modifier = Modifier.weight(1f)) - + + Spacer(modifier = Modifier.width(12.dp)) + Text( text = stringResource(R.string.mention), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 5).sp + color = palette.textTertiary, + fontSize = (BASE_FONT_SIZE - 4).sp, + maxLines = 1 ) } } + +/** Mention autocomplete stays compact even in crowded channels. */ +internal const val MaxVisibleMentionSuggestions = 5 +private val MentionSuggestionRowHeight = 48.dp +private val MentionSuggestionsVerticalPadding = 6.dp +private val MentionSuggestionsMaxHeight = + (48 * MaxVisibleMentionSuggestions + 12).dp +private val MentionSuggestionsShape = RoundedCornerShape(8.dp) diff --git a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt index 4896e345..57710171 100644 --- a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt +++ b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Link import android.content.Intent import android.net.Uri import androidx.compose.foundation.background @@ -7,8 +9,6 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Link import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -17,11 +17,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BASE_FONT_SIZE import java.net.URL @@ -43,11 +43,8 @@ fun LinkPreviewPill( ) { val context = LocalContext.current val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - - // iOS-style colors - val textColor = if (isDark) Color.Green else Color(red = 0f, green = 0.5f, blue = 0f) - val backgroundColor = if (isDark) Color.Gray.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.08f) + val textColor = colorScheme.secondary + val backgroundColor = colorScheme.secondaryContainer.copy(alpha = 0.35f) val borderColor = textColor.copy(alpha = 0.3f) // Parse URL for host extraction @@ -87,7 +84,7 @@ fun LinkPreviewPill( Surface( modifier = Modifier.size(60.dp), shape = RoundedCornerShape(8.dp), - color = Color.Blue.copy(alpha = 0.1f) + color = colorScheme.secondaryContainer ) { Box( modifier = Modifier.fillMaxSize(), @@ -97,7 +94,7 @@ fun LinkPreviewPill( imageVector = Icons.Outlined.Link, contentDescription = stringResource(com.bitchat.android.R.string.cd_link), modifier = Modifier.size(24.dp), - tint = Color.Blue + tint = colorScheme.secondary ) } } @@ -110,7 +107,7 @@ fun LinkPreviewPill( // Title - matches iOS styling Text( text = displayTitle, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = BASE_FONT_SIZE.sp, fontWeight = FontWeight.SemiBold, color = textColor, @@ -121,7 +118,7 @@ fun LinkPreviewPill( // Host - matches iOS styling Text( text = displayHost, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = textColor.copy(alpha = 0.6f), maxLines = 1, diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 0732d912..19156485 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -1,52 +1,105 @@ package com.bitchat.android.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.Hub +import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.PinDrop +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.outlined.BookmarkBorder +import androidx.compose.material.icons.outlined.Public import android.content.Intent import android.net.Uri import android.provider.Settings +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Map -import androidx.compose.material.icons.filled.PinDrop -import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import com.bitchat.android.geohash.ChannelID -import kotlinx.coroutines.launch -import com.bitchat.android.geohash.GeohashChannel -import com.bitchat.android.geohash.GeohashChannelLevel -import com.bitchat.android.geohash.LocationChannelManager -import com.bitchat.android.geohash.GeohashBookmarksStore +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.nostr.NearbyNotesController import com.bitchat.android.nostr.geohashesForSampling import com.bitchat.android.ui.theme.BASE_FONT_SIZE -import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet -import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashBookmarksStore +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.TorMode +import com.bitchat.android.net.TorPreferenceManager +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.wifiaware.WifiAwareController +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull /** - * Location Channels Sheet for selecting geohash-based location channels - * Direct port from iOS LocationChannelsSheet for 100% compatibility + * Leading column width matching settings rows: 22.dp glyph + 16.dp gutter before title text. + * Selection dots and row icons sit in this column so every option lines up with About settings. + */ +private val ChannelLeadingSlot = SheetRowLeadingSlot +private val ChannelLeadingGutter = SheetRowLeadingGutter +private val ChannelRowHorizontal = SheetRowHorizontal +private val ChannelRowVertical = SheetRowVertical +private val ChannelDividerInset = SheetRowDividerInset +/** 2× the previous 6.dp selected indicator; sits centered in [ChannelLeadingSlot]. */ +private val ChannelSelectedDot = SheetRowSelectedDot + +/** + * Pause between applying a channel selection and dismissing the sheet. + * + * Just enough for the active dot to land on the chosen row, so the tap is acknowledged rather than + * answered by the sheet simply disappearing. + */ +private const val SelectionConfirmDelayMs = 180L + +/** + * Location Channels sheet: grouped card rows matching About → Settings. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -60,7 +113,6 @@ fun LocationChannelsSheet( val locationManager = LocationChannelManager.getInstance(context) val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) } - // Observe location manager state val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() val notesRevealed by NearbyNotesController.shared.revealed.collectAsStateWithLifecycle() @@ -70,25 +122,17 @@ fun LocationChannelsSheet( val systemLocationEnabled by locationManager.systemLocationEnabled.collectAsStateWithLifecycle() val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle() - // Observe bookmarks state val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle() val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle() - - // Observe reactive participant counts val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle() + val wifiAwareEnabled by WifiAwareController.enabled.collectAsStateWithLifecycle() - // UI state var customGeohash by remember { mutableStateOf("") } var customError by remember { mutableStateOf(null) } - var isInputFocused by remember { mutableStateOf(false) } - // Bottom sheet state - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val coroutineScope = rememberCoroutineScope() - // Scroll state for LazyColumn with animated top bar val listState = rememberLazyListState() val isScrolled by remember { derivedStateOf { @@ -96,7 +140,8 @@ fun LocationChannelsSheet( } } val topBarAlpha by animateFloatAsState( - targetValue = if (isScrolled) 0.95f else 0f, + targetValue = if (isScrolled) 0.98f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), label = "topBarAlpha" ) @@ -112,11 +157,20 @@ fun LocationChannelsSheet( } } - // iOS system colors (matches iOS exactly) val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green - val standardBlue = Color(0xFF007AFF) // iOS blue + val palette = LocalBitchatPalette.current + val standardGreen = colorScheme.primary + val standardBlue = colorScheme.secondary + + val nearbyChannels = remember(availableChannels) { + availableChannels.filter { it.level != GeohashChannelLevel.BUILDING } + } + val selectedChannelOutsideNearby = remember(selectedChannel, nearbyChannels) { + selectedLocationChannelOutsideNearby(selectedChannel, nearbyChannels) + } + val showNearbyLoading = nearbyChannels.isEmpty() && + permissionState == LocationChannelManager.PermissionState.AUTHORIZED && + locationServicesEnabled if (isPresented) { BitchatBottomSheet( @@ -124,393 +178,413 @@ fun LocationChannelsSheet( onDismissRequest = onDismiss, sheetState = sheetState, ) { + // Selection is applied immediately so the active dot snaps to the new row, then the + // sheet slides away after a beat. Long enough to register the change, short enough + // that it never feels like waiting. + val animatedDismiss = LocalSheetDismiss.current + val confirmSelectionThenDismiss: () -> Unit = { + coroutineScope.launch { + delay(SelectionConfirmDelayMs) + animatedDismiss?.invoke() ?: onDismiss() + } + } + Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 64.dp, bottom = 16.dp) + // Edge-to-edge + adjustResize reports the keyboard as WindowInsets.ime — + // without consuming it here the list stays full-height and the teleport + // field can't scroll above the keyboard. + modifier = Modifier + .fillMaxSize() + .imePadding(), + contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) ) { - // Header Section - item(key = "header") { - Text( - text = stringResource(R.string.location_channels_desc), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier - .padding(horizontal = 24.dp) + // Mesh section: icon + title header, offline subtitle, then selection card + item(key = "mesh_card") { + Column { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_range, + title = stringResource(R.string.mesh_title), + subtitle = stringResource(R.string.mesh_section_subtitle), + modifier = Modifier.padding(top = 8.dp) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + ChannelOptionRow( + title = meshTitleWithCount(viewModel), + subtitle = stringResource( + if (wifiAwareEnabled) { + R.string.location_bluetooth_wifi_subtitle + } else { + R.string.location_bluetooth_subtitle + }, + meshRangeString() + ), + isSelected = selectedChannel is ChannelID.Mesh, + participantCount = meshCount(viewModel), + titleColor = standardBlue, + titleBold = meshCount(viewModel) > 0, + onClick = { + locationManager.select(ChannelID.Mesh) + onDismiss() + } + ) + } + } + } + + item(key = "location_channels_header") { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_globe, + title = stringResource(R.string.location_channels_heading), + subtitle = stringResource(R.string.location_channels_desc), + modifier = Modifier.padding(top = 20.dp) ) } - // Permission controls if services enabled - if (locationServicesEnabled) { + selectedChannelOutsideNearby?.let { channel -> + item(key = "teleported_card") { + val coverage = coverageString(channel.geohash.length) + val name = bookmarkNames[channel.geohash] + val subtitle = "#${channel.geohash} • $coverage" + + (name?.let { " • ${formattedNamePrefix(channel.level)}$it" } ?: "") + val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 + val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) + + Column { + AboutSectionLabel(text = stringResource(R.string.cd_teleported)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + ChannelOptionRow( + title = geohashHashTitleWithCount( + channel.geohash, + participantCount + ), + subtitle = subtitle, + isSelected = true, + participantCount = participantCount, + titleColor = standardGreen, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = isBookmarked, + onClick = { + bookmarksStore.toggle(channel.geohash) + } + ) + }, + onClick = { + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } + ) + } + } + } + } + + if (bookmarks.isNotEmpty()) { + item(key = "bookmarks_card") { + Column { + AboutSectionLabel(text = stringResource(R.string.bookmarked)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column { + bookmarks.forEachIndexed { index, gh -> + if (index > 0) SheetCardDivider() + val level = levelForLength(gh.length) + val channel = GeohashChannel(level = level, geohash = gh) + val coverage = coverageString(gh.length) + val name = bookmarkNames[gh] + val subtitle = "#$gh • $coverage" + + (name?.let { " • ${formattedNamePrefix(level)}$it" } ?: "") + val participantCount = geohashParticipantCounts[gh] ?: 0 + + ChannelOptionRow( + title = geohashHashTitleWithCount(gh, participantCount), + subtitle = subtitle, + isSelected = isChannelSelected(channel, selectedChannel), + participantCount = participantCount, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = true, + onClick = { bookmarksStore.toggle(gh) } + ) + }, + onClick = { + val inRegional = + availableChannels.any { it.geohash == gh } + locationManager.selectManual( + channel = channel, + teleported = !appLocationEnabled || + availableChannels.isEmpty() || + !inRegional + ) + onDismiss() + } + ) + LaunchedEffect(gh) { + bookmarksStore.resolveNameIfNeeded(gh) + } + } + } + } + } + } + } + + if (locationServicesEnabled && + permissionState == LocationChannelManager.PermissionState.DENIED + ) { item(key = "permissions") { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 8.dp), + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - when (permissionState) { - LocationChannelManager.PermissionState.DENIED -> { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = stringResource(R.string.location_permission_denied), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = Color.Red.copy(alpha = 0.8f) - ) - TextButton( - onClick = { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - } - context.startActivity(intent) - } - ) { - Text( - text = stringResource(R.string.open_settings), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace + Text( + text = stringResource(R.string.location_permission_denied), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.error + ) + TextButton( + onClick = { + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts( + "package", + context.packageName, + null ) } - } - } - LocationChannelManager.PermissionState.AUTHORIZED -> { - Text( - text = stringResource(R.string.location_permission_granted), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = standardGreen - ) - } - } - } - } - } - - // Mesh option first - item(key = "mesh") { - ChannelRow( - title = meshTitleWithCount(viewModel), - subtitle = stringResource(R.string.location_bluetooth_subtitle, bluetoothRangeString()), - isSelected = selectedChannel is ChannelID.Mesh, - titleColor = standardBlue, - titleBold = meshCount(viewModel) > 0, - trailingContent = null, - onClick = { - locationManager.select(ChannelID.Mesh) - onDismiss() - } - ) - } - - // Nearby options (only show if location services are enabled) - // CRITICAL: Filter out .building level (precision 8) - iOS pattern - // iOS: let nearby = manager.availableChannels.filter { $0.level != .building } - if (availableChannels.isNotEmpty() && locationServicesEnabled) { - val nearbyChannels = availableChannels.filter { it.level != GeohashChannelLevel.BUILDING } - items(nearbyChannels) { channel -> - val coverage = coverageString(channel.geohash.length) - val nameBase = locationNames[channel.level] - val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } - val subtitlePrefix = "#${channel.geohash} • $coverage" - val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 - val highlight = participantCount > 0 - val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) - - ChannelRow( - title = geohashTitleWithCount(channel, participantCount), - subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""), - isSelected = isChannelSelected(channel, selectedChannel), - titleColor = standardGreen, - titleBold = highlight, - trailingContent = { - IconButton(onClick = { bookmarksStore.toggle(channel.geohash) }) { - Icon( - imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, - contentDescription = if (isBookmarked) stringResource(R.string.cd_remove_bookmark) else stringResource(R.string.cd_add_bookmark), - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), - ) - } - }, - onClick = { - // Selecting a suggested nearby channel is not a teleport - locationManager.setTeleported(false) - locationManager.select(ChannelID.Location(channel)) - onDismiss() - } - ) - } - } else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - Text( - text = stringResource(R.string.finding_nearby_channels), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace - ) - } - } - } - - // Bookmarked geohashes - if (bookmarks.isNotEmpty()) { - item(key = "bookmarked_header") { - Text( - text = stringResource(R.string.bookmarked), - style = MaterialTheme.typography.labelLarge, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 8.dp, bottom = 4.dp) - ) - } - items(bookmarks) { gh -> - val level = levelForLength(gh.length) - val channel = GeohashChannel(level = level, geohash = gh) - val coverage = coverageString(gh.length) - val subtitlePrefix = "#${gh} • $coverage" - val name = bookmarkNames[gh] - val subtitle = subtitlePrefix + (name?.let { " • ${formattedNamePrefix(level)}$it" } ?: "") - val participantCount = geohashParticipantCounts[gh] ?: 0 - val title = geohashHashTitleWithCount(gh, participantCount) - - ChannelRow( - title = title, - subtitle = subtitle, - isSelected = isChannelSelected(channel, selectedChannel), - titleColor = null, - titleBold = participantCount > 0, - trailingContent = { - IconButton(onClick = { bookmarksStore.toggle(gh) }) { - Icon( - imageVector = Icons.Filled.Bookmark, - contentDescription = stringResource(R.string.cd_remove_bookmark), - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), - ) - } - }, - onClick = { - // For bookmarked selection, mark teleported based on regional membership - val inRegional = availableChannels.any { it.geohash == gh } - if (!inRegional && availableChannels.isNotEmpty()) { - locationManager.setTeleported(true) - } else { - locationManager.setTeleported(false) - } - locationManager.select(ChannelID.Location(channel)) - onDismiss() - } - ) - LaunchedEffect(gh) { bookmarksStore.resolveNameIfNeeded(gh) } - } - } - - // Custom geohash teleport (iOS-style inline form) - item(key = "custom_geohash") { - Surface( - color = Color.Transparent, - shape = MaterialTheme.shapes.medium, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.hash_symbol), - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - - BasicTextField( - value = customGeohash, - onValueChange = { newValue -> - // iOS-style geohash validation (base32 characters only) - val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() - val filtered = newValue - .lowercase() - .replace("#", "") - .filter { it in allowed } - .take(12) - - customGeohash = filtered - customError = null + context.startActivity(intent) }, - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface - ), - modifier = Modifier - .weight(1f) - .onFocusChanged { focusState -> - isInputFocused = focusState.isFocused - if (focusState.isFocused) { - coroutineScope.launch { - sheetState.expand() - // Scroll to bottom to show input and remove button - listState.animateScrollToItem( - index = listState.layoutInfo.totalItemsCount - 1 - ) - } - } - }, - singleLine = true, - decorationBox = { innerTextField -> - if (customGeohash.isEmpty()) { - Text( - text = stringResource(R.string.geohash_placeholder), - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) - ) - } - innerTextField() - } - ) - - val normalized = customGeohash.trim().lowercase().replace("#", "") - - // Map picker button - IconButton(onClick = { - val initial = when { - normalized.isNotBlank() -> normalized - selectedChannel is ChannelID.Location -> (selectedChannel as ChannelID.Location).channel.geohash - else -> "" - } - val intent = Intent(context, GeohashPickerActivity::class.java).apply { - putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial) - } - mapPickerLauncher.launch(intent) - }) { - Icon( - imageVector = Icons.Filled.Map, - contentDescription = stringResource(R.string.cd_open_map), - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) - ) - } - - val isValid = validateGeohash(normalized) - - // iOS-style teleport button - Button( - onClick = { - if (isValid) { - val level = levelForLength(normalized.length) - val channel = GeohashChannel(level = level, geohash = normalized) - // Mark this selection as a manual teleport - locationManager.setTeleported(true) - locationManager.select(ChannelID.Location(channel)) - onDismiss() - } else { - customError = context.getString(R.string.invalid_geohash) - } - }, - enabled = isValid, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), - contentColor = MaterialTheme.colorScheme.onSurface - ) + contentPadding = PaddingValues(0.dp) ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.teleport), - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace - ) - Icon( - imageVector = Icons.Filled.PinDrop, - contentDescription = stringResource(R.string.cd_teleport), - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurface - ) - } + Text( + text = stringResource(R.string.open_settings), + fontSize = 12.sp, + fontFamily = BitchatFontFamily + ) } } } } - // Error message for custom geohash - if (customError != null) { - item(key = "geohash_error") { - Text( - text = customError!!, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = Color.Red, + // Nearby location channels and the control for teleporting somewhere new. + item(key = "channels_card") { + Column { + AboutSectionLabel( + text = stringResource(R.string.location_channels_nearby) + ) + Surface( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) - ) - } - } - - // Location services toggle button - item(key = "location_toggle") { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 8.dp) - ) { - Button( - onClick = { - if (locationServicesEnabled) { - locationManager.disableLocationServices() - } else { - locationManager.enableLocationServices() - } - }, - colors = ButtonDefaults.buttonColors( - containerColor = if (locationServicesEnabled) { - Color.Red.copy(alpha = 0.08f) - } else { - standardGreen.copy(alpha = 0.12f) - }, - contentColor = if (locationServicesEnabled) { - Color(0xFFBF1A1A) - } else { - standardGreen - } - ), - modifier = Modifier.fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape ) { + Column { + if (locationServicesEnabled) { + if (nearbyChannels.isNotEmpty()) { + nearbyChannels.forEachIndexed { index, channel -> + if (index > 0) SheetCardDivider() + val coverage = coverageString(channel.geohash.length) + val nameBase = locationNames[channel.level] + val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } + val subtitlePrefix = "#${channel.geohash} • $coverage" + val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 + val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) + + ChannelOptionRow( + title = geohashTitleWithCount(channel, participantCount), + subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""), + isSelected = isChannelSelected(channel, selectedChannel), + participantCount = participantCount, + titleColor = standardGreen, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = isBookmarked, + onClick = { bookmarksStore.toggle(channel.geohash) } + ) + }, + onClick = { + if (locationManager.selectNearby(channel)) { + onDismiss() + } + } + ) + } + SheetCardDivider() + } else if (showNearbyLoading) { + ChannelLoadingRow() + SheetCardDivider() + } + } + + CustomGeohashRow( + customGeohash = customGeohash, + onGeohashChange = { value -> + val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() + customGeohash = value + .lowercase() + .replace("#", "") + .filter { it in allowed } + .take(12) + customError = null + }, + onFocusGained = { + coroutineScope.launch { sheetState.expand() } + }, + onOpenMap = { + val normalized = customGeohash.trim().lowercase().replace("#", "") + val initial = when { + normalized.isNotBlank() -> normalized + selectedChannel is ChannelID.Location -> + (selectedChannel as ChannelID.Location).channel.geohash + else -> "" + } + val intent = Intent(context, GeohashPickerActivity::class.java).apply { + putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial) + } + mapPickerLauncher.launch(intent) + }, + onTeleport = { + val normalized = customGeohash.trim().lowercase().replace("#", "") + if (validateGeohash(normalized)) { + val level = levelForLength(normalized.length) + val channel = GeohashChannel(level = level, geohash = normalized) + locationManager.selectManual(channel) + onDismiss() + } else { + customError = context.getString(R.string.invalid_geohash) + } + } + ) + } + } + + AnimatedVisibility( + visible = customError != null, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandVertically( + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkVertically( + tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ) + ) { + // Held across the exit animation: by the time it plays, the error + // itself has already been cleared. + val shownError = remember(customError) { customError ?: "" } Text( - text = if (locationServicesEnabled) stringResource(R.string.disable_location_services) else stringResource(R.string.enable_location_services), + text = shownError, fontSize = 12.sp, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily, + color = colorScheme.error, + modifier = Modifier.padding( + start = AboutHorizontalPadding + ChannelRowHorizontal, + top = 8.dp + ) ) } } } + + item(key = "tor_routing") { + val torProvider = remember { ArtiTorManager.getInstance() } + val torAvailable = remember { torProvider.isTorAvailable() } + var torMode by remember { mutableStateOf(TorPreferenceManager.get(context)) } + + Column { + AboutSectionLabel(text = stringResource(R.string.about_network)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + ChannelSettingsToggleRow( + icon = Icons.Filled.Security, + title = stringResource(R.string.location_tor_routing_title), + subtitle = stringResource(R.string.location_tor_routing_desc), + checked = torMode == TorMode.ON, + enabled = torAvailable, + statusIndicator = { + BitchatBadge(text = stringResource(R.string.badge_recommended)) + }, + onCheckedChange = { enabled -> + if (torAvailable) { + torMode = if (enabled) TorMode.ON else TorMode.OFF + TorPreferenceManager.set(context, torMode) + } + } + ) + } + if (!torAvailable) { + Text( + text = stringResource(R.string.tor_not_available_in_this_build), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary, + modifier = Modifier.padding( + start = AboutHorizontalPadding + ChannelRowHorizontal, + top = 8.dp + ) + ) + } + } + } + + item(key = "location_toggle") { + SheetDestructiveButton( + text = if (appLocationEnabled) { + stringResource(R.string.disable_location_services) + } else { + stringResource(R.string.enable_location_services) + }, + isDestructive = appLocationEnabled, + onClick = { + if (appLocationEnabled) { + locationManager.disableLocationServices() + } else { + locationManager.enableLocationServices() + } + }, + modifier = Modifier.padding( + start = AboutHorizontalPadding, + end = AboutHorizontalPadding, + top = 24.dp + ) + ) + } } - // TopBar (animated) BitchatSheetTopBar( onClose = onDismiss, modifier = modifier.align(Alignment.TopCenter), @@ -524,130 +598,430 @@ fun LocationChannelsSheet( } } - // Lifecycle management: when presented, manage location updates - DisposableEffect(isPresented, permissionState, locationServicesEnabled) { - if (isPresented && permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { - locationManager.refreshChannels() - locationManager.beginLiveRefresh() + 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() + } } - onDispose { + 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() } } - // Ensure cleanup when the composable is destroyed (e.g. removed from parent composition) DisposableEffect(Unit) { - onDispose { - viewModel.endGeohashSampling() + onDispose { viewModel.endGeohashSampling() } + } +} + +/** + * Single channel option — settings-row geometry: 22.dp leading slot, title + subtitle, trailing. + * Selected state is a 12.dp green dot centered in the leading slot (icon-sized footprint). + */ +@Composable +private fun ChannelOptionRow( + title: String, + subtitle: String, + isSelected: Boolean, + participantCount: Int, + titleColor: Color? = null, + titleBold: Boolean = false, + leadingIcon: ImageVector? = null, + trailingContent: (@Composable (() -> Unit))? = null, + onClick: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val (baseTitle, countSuffix) = splitTitleAndCount(title) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center + ) { + when { + isSelected -> { + Box( + modifier = Modifier + .size(ChannelSelectedDot) + .background(colorScheme.primary, CircleShape) + ) + } + leadingIcon != null -> { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } + } + } + + Spacer(modifier = Modifier.width(ChannelLeadingGutter)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = baseTitle, + fontSize = 14.sp, + fontFamily = BitchatFontFamily, + fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium, + color = titleColor ?: colorScheme.onSurface + ) + countSuffix?.let { count -> + AnimatedCountLabel( + count = participantCount, + text = count, + fontSize = 11.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onSurfaceVariant + ) + } + } + Text( + text = subtitle, + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + lineHeight = 17.sp, + color = colorScheme.onSurfaceVariant + ) + } + + if (trailingContent != null) { + Spacer(modifier = Modifier.width(8.dp)) + trailingContent() } } } @Composable -private fun ChannelRow( - title: String, - subtitle: String, - isSelected: Boolean, - titleColor: Color? = null, - titleBold: Boolean = false, - trailingContent: (@Composable (() -> Unit))? = null, +private fun ChannelBookmarkButton( + bookmarked: Boolean, onClick: () -> Unit ) { - // iOS-style list row (plain button, no card background) - Surface( - onClick = onClick, - color = if (isSelected) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - Color.Transparent - }, - shape = MaterialTheme.shapes.medium, + val colorScheme = MaterialTheme.colorScheme + Box( + modifier = Modifier + .size(36.dp) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource( + if (bookmarked) { + R.drawable.ic_spec_bookmark_filled + } else { + R.drawable.ic_spec_bookmark_outline + } + ), + contentDescription = stringResource( + if (bookmarked) R.string.cd_remove_bookmark else R.string.cd_add_bookmark + ), + tint = if (bookmarked) colorScheme.primary else colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } +} + +@Composable +private fun ChannelLoadingRow() { + val colorScheme = MaterialTheme.colorScheme + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + horizontalArrangement = Arrangement.spacedBy(ChannelLeadingGutter), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - // Split title to handle count part with smaller font (iOS style) - val (baseTitle, countSuffix) = splitTitleAndCount(title) + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = colorScheme.onSurfaceVariant + ) + } + Text( + text = stringResource(R.string.finding_nearby_channels), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onSurfaceVariant + ) + } +} - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = baseTitle, - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - fontWeight = if (titleBold) FontWeight.Bold else FontWeight.Normal, - color = titleColor ?: MaterialTheme.colorScheme.onSurface - ) +/** + * Teleport / custom geohash control unified into the same card as channel options. + */ +@Composable +private fun CustomGeohashRow( + customGeohash: String, + onGeohashChange: (String) -> Unit, + onFocusGained: () -> Unit, + onOpenMap: () -> Unit, + onTeleport: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + val density = LocalDensity.current + val imeInsets = WindowInsets.ime + val coroutineScope = rememberCoroutineScope() + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val normalized = customGeohash.trim().lowercase().replace("#", "") + val isValid = validateGeohash(normalized) + // Typing the last character of a valid geohash arms the button; cross-fading both the label + // and its container makes that the moment the row confirms the input is usable. + val teleportColor by animateColorAsState( + targetValue = if (isValid) colorScheme.primary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "teleportLabel" + ) + val teleportContainer by animateColorAsState( + targetValue = if (isValid) { + colorScheme.primary.copy(alpha = 0.16f) + } else { + colorScheme.surfaceVariant + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "teleportContainer" + ) + val teleportInteraction = remember { MutableInteractionSource() } + val teleportScale = rememberPressScale(teleportInteraction, pressedScale = 0.92f) - countSuffix?.let { count -> - Text( - text = count, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) + Row( + modifier = Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester) + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.PinDrop, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(ChannelLeadingGutter)) + + Text( + text = stringResource(R.string.hash_symbol), + fontSize = 14.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary + ) + + Spacer(modifier = Modifier.width(4.dp)) + + BasicTextField( + value = customGeohash, + onValueChange = onGeohashChange, + textStyle = TextStyle( + fontSize = 14.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.primary + ), + cursorBrush = SolidColor(colorScheme.primary), + singleLine = true, + modifier = Modifier + .weight(1f) + .onFocusChanged { focusState -> + if (!focusState.isFocused) return@onFocusChanged + onFocusGained() + // Wait until IME insets have landed so the LazyColumn has shrunk; bringIntoView + // against the full-height viewport leaves the field tucked under the keyboard. + // Re-request once the IME animation settles — early insets are still growing. + coroutineScope.launch { + withTimeoutOrNull(750) { + snapshotFlow { imeInsets.getBottom(density) } + .first { it > 0 } + } + bringIntoViewRequester.bringIntoView() + delay(BitchatMotion.STANDARD_MS.toLong()) + bringIntoViewRequester.bringIntoView() } - } - - Text( - text = subtitle, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - - Row(verticalAlignment = Alignment.CenterVertically) { - if (isSelected) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = stringResource(R.string.cd_selected), - tint = Color(0xFF32D74B), // iOS green for checkmark - modifier = Modifier.size(20.dp) + }, + decorationBox = { inner -> + if (customGeohash.isEmpty()) { + Text( + text = stringResource(R.string.geohash_placeholder), + fontSize = 14.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary ) } - - if (trailingContent != null) { - trailingContent() - } + inner() } + ) + + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .pressScaleClickable(onClick = onOpenMap), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Map, + contentDescription = stringResource(R.string.cd_open_map), + tint = colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } + + Surface( + onClick = onTeleport, + enabled = isValid, + shape = RoundedCornerShape(8.dp), + color = teleportContainer, + interactionSource = teleportInteraction, + modifier = Modifier.scale(teleportScale) + ) { + Text( + text = stringResource(R.string.teleport).uppercase(), + fontSize = 11.sp, + letterSpacing = 0.8.sp, + fontWeight = FontWeight.Medium, + fontFamily = BitchatFontFamily, + color = teleportColor, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp) + ) } } } -// MARK: - Helper Functions (matching iOS implementation) +/** Settings-toggle row geometry (icon + title/subtitle + switch), local copy for this sheet. */ +@Composable +private fun ChannelSettingsToggleRow( + icon: ImageVector, + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, + statusIndicator: (@Composable () -> Unit)? = null +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (enabled) colorScheme.primary else palette.textTertiary, + modifier = Modifier.size(ChannelLeadingSlot) + ) + Spacer(modifier = Modifier.width(ChannelLeadingGutter)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (enabled) colorScheme.onSurface else palette.textTertiary + ) + statusIndicator?.invoke() + } + Text( + text = subtitle, + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + lineHeight = 17.sp, + color = if (enabled) colorScheme.onSurfaceVariant else palette.textTertiary + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = checked, + onCheckedChange = { if (enabled) onCheckedChange(it) }, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = colorScheme.primary, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = colorScheme.surfaceVariant + ) + ) + } +} + +// MARK: - Helper Functions + +/** Separates the channel name from the animated people-count label in composed titles. */ +private const val TITLE_COUNT_SEP = '\u001F' private fun splitTitleAndCount(title: String): Pair { + val sep = title.indexOf(TITLE_COUNT_SEP) + if (sep != -1) { + return Pair(title.substring(0, sep), title.substring(sep + 1).ifEmpty { null }) + } + // Legacy "[n people]" form val lastBracketIndex = title.lastIndexOf('[') return if (lastBracketIndex != -1) { - val prefix = title.substring(0, lastBracketIndex).trim() - val suffix = title.substring(lastBracketIndex) - Pair(prefix, suffix) + val count = title.substring(lastBracketIndex + 1).removeSuffix("]") + Pair(title.substring(0, lastBracketIndex).trim(), count.ifEmpty { null }) } else { Pair(title, null) } @@ -656,56 +1030,48 @@ private fun splitTitleAndCount(title: String): Pair { @Composable private fun meshTitleWithCount(viewModel: ChatViewModel): String { val meshCount = meshCount(viewModel) - val ctx = androidx.compose.ui.platform.LocalContext.current - val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, meshCount, meshCount) - val meshLabel = stringResource(com.bitchat.android.R.string.mesh_label) - return "$meshLabel [$peopleText]" + val ctx = LocalContext.current + val peopleText = ctx.resources.getQuantityString(R.plurals.people_count, meshCount, meshCount) + val meshLabel = stringResource(R.string.mesh_title) + return "$meshLabel$TITLE_COUNT_SEP$peopleText" } private fun meshCount(viewModel: ChatViewModel): Int { val myID = viewModel.myPeerID - return viewModel.connectedPeers.value?.count { peerID -> - peerID != myID - } ?: 0 + return viewModel.connectedPeers.value?.count { it != myID } ?: 0 } @Composable private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String { - val ctx = androidx.compose.ui.platform.LocalContext.current - - // For high precision channels (Neighborhood, Block) where we don't broadcast presence, - // show "? people" instead of "0 people" to avoid misleading "nobody is here" indication. + val ctx = LocalContext.current val isHighPrecision = channel.level.precision > 5 val peopleText = if (isHighPrecision && participantCount == 0) { - ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?") + ctx.resources.getQuantityString(R.plurals.people_count, 0, 0).replace("0", "?") } else { - ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) + ctx.resources.getQuantityString(R.plurals.people_count, participantCount, participantCount) } - val levelName = when (channel.level) { - com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes - com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block) - com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD -> stringResource(com.bitchat.android.R.string.location_level_neighborhood) - com.bitchat.android.geohash.GeohashChannelLevel.CITY -> stringResource(com.bitchat.android.R.string.location_level_city) - com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE -> stringResource(com.bitchat.android.R.string.location_level_province) - com.bitchat.android.geohash.GeohashChannelLevel.REGION -> stringResource(com.bitchat.android.R.string.location_level_region) + GeohashChannelLevel.BUILDING -> "Building" + GeohashChannelLevel.BLOCK -> stringResource(R.string.location_level_block) + GeohashChannelLevel.NEIGHBORHOOD -> stringResource(R.string.location_level_neighborhood) + GeohashChannelLevel.CITY -> stringResource(R.string.location_level_city) + GeohashChannelLevel.PROVINCE -> stringResource(R.string.location_level_province) + GeohashChannelLevel.REGION -> stringResource(R.string.location_level_region) } - return "$levelName [$peopleText]" + return "$levelName$TITLE_COUNT_SEP$peopleText" } @Composable private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String { - val ctx = androidx.compose.ui.platform.LocalContext.current + val ctx = LocalContext.current val level = levelForLength(geohash.length) val isHighPrecision = level.precision > 5 - val peopleText = if (isHighPrecision && participantCount == 0) { - ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?") + ctx.resources.getQuantityString(R.plurals.people_count, 0, 0).replace("0", "?") } else { - ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) + ctx.resources.getQuantityString(R.plurals.people_count, participantCount, participantCount) } - - return "#$geohash [$peopleText]" + return "#$geohash$TITLE_COUNT_SEP$peopleText" } private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean { @@ -715,6 +1081,24 @@ private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelI } } +/** + * Returns the active location channel when it has no row in the nearby-channel list. + * + * This commonly happens after teleporting to a remote geohash. Keeping the selected channel in the + * main channel card makes its selection and bookmark action available even before it is bookmarked. + */ +internal fun selectedLocationChannelOutsideNearby( + selectedChannel: ChannelID?, + nearbyChannels: List +): GeohashChannel? { + val selected = (selectedChannel as? ChannelID.Location)?.channel ?: return null + return selected.takeUnless { active -> + nearbyChannels.any { nearby -> + nearby.geohash.equals(active.geohash, ignoreCase = true) + } + } +} + private fun validateGeohash(geohash: String): Boolean { if (geohash.isEmpty() || geohash.length > 12) return false val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() @@ -728,13 +1112,12 @@ private fun levelForLength(length: Int): GeohashChannelLevel { 5 -> GeohashChannelLevel.CITY 6 -> GeohashChannelLevel.NEIGHBORHOOD 7 -> GeohashChannelLevel.BLOCK - 8 -> GeohashChannelLevel.BUILDING // iOS: precision 8 for building-level + 8 -> GeohashChannelLevel.BUILDING else -> if (length > 8) GeohashChannelLevel.BUILDING else GeohashChannelLevel.BLOCK } } private fun coverageString(precision: Int): String { - // Approximate max cell dimension at equator for a given geohash length val maxMeters = when (precision) { 2 -> 1_250_000.0 3 -> 156_000.0 @@ -747,10 +1130,7 @@ private fun coverageString(precision: Int): String { 10 -> 1.19 else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble()) } - - // Use metric system for simplicity (could be made locale-aware) - val km = maxMeters / 1000.0 - return "~${formatDistance(km)} km" + return "~${formatDistance(maxMeters / 1000.0)} km" } private fun formatDistance(value: Double): String { @@ -761,11 +1141,6 @@ private fun formatDistance(value: Double): String { } } -private fun bluetoothRangeString(): String { - // Approximate Bluetooth LE range for typical mobile devices - return "~10–50 m" -} +private fun meshRangeString(): String = "~10–50m" -private fun formattedNamePrefix(level: GeohashChannelLevel): String { - return "~" -} +private fun formattedNamePrefix(level: GeohashChannelLevel): String = "~" diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 6ad9bdd8..489ddaf4 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -1,19 +1,21 @@ package com.bitchat.android.ui +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Description -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.bitchat.android.R import com.bitchat.android.geohash.ChannelID @@ -21,9 +23,9 @@ import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.nostr.LocationNotesManager /** - * Location Notes button component for MainHeader - * Shows in mesh mode when location permission granted AND services enabled - * Icon turns primary color when notes exist, gray otherwise + * Location Notes button for MainHeader. + * Mesh-only with location authorized. Tor health tints the glyph with muted colours + * and a slow glow while connecting (via [rememberTorConnectionVisual]). */ @Composable fun LocationNotesButton( @@ -33,18 +35,15 @@ fun LocationNotesButton( ) { val colorScheme = MaterialTheme.colorScheme val context = LocalContext.current - - // Get channel and permission state + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val locationManager = remember { LocationChannelManager.getInstance(context) } val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false) - // Check both permission AND location services enabled val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED val locationEnabled = locationPermissionGranted && locationServicesEnabled - - // Get notes count from LocationNotesManager + val notesManager = remember { LocationNotesManager.getInstance() } val notes by notesManager.notes.collectAsStateWithLifecycle() val notesCount = notes.size @@ -52,15 +51,22 @@ fun LocationNotesButton( // Only show in mesh mode when location is authorized (iOS pattern) if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) { val hasNotes = notesCount > 0 - IconButton( - onClick = onClick, - modifier = modifier.size(24.dp) + val contentDescription = stringResource(R.string.cd_location_notes) + val normalTint = if (hasNotes) colorScheme.primary else colorScheme.onSurfaceVariant + val torVisual = rememberTorConnectionVisual(normal = normalTint) + + Box( + modifier = modifier + .size(44.dp) + .clip(CircleShape) + .pressScaleClickable(onClick = onClick, onClickLabel = contentDescription), + contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.Outlined.Description, // "long.text.page.and.pencil" equivalent - contentDescription = stringResource(R.string.cd_location_notes), - modifier = Modifier.size(16.dp), - tint = if (hasNotes) colorScheme.primary else Color.Gray + TorAwareHeaderIcon( + painter = painterResource(R.drawable.ic_spec_chat_bubbles), + tint = torVisual.tint, + isProgress = torVisual.isProgress, + contentDescription = contentDescription ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index 6a224cf2..f3c49480 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -1,16 +1,15 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.getValue @@ -18,9 +17,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.pluralStringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -51,10 +50,8 @@ fun LocationNotesSheet( modifier: Modifier = Modifier ) { val context = LocalContext.current - val isDark = isSystemInDarkTheme() - - // iOS color scheme - val accentGreen = if (isDark) Color.Green else Color(0xFF008000) // dark: green, light: dark green (0, 0.5, 0) + val colorScheme = MaterialTheme.colorScheme + val accentGreen = colorScheme.primary // Managers val notesManager = remember { LocationNotesManager.getInstance() } @@ -251,7 +248,7 @@ private fun LocationNotesHeader( if (name.isNotEmpty()) { Text( text = name, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = accentGreen ) @@ -262,7 +259,7 @@ private fun LocationNotesHeader( // Description Text( text = stringResource(R.string.location_notes_description), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -272,7 +269,7 @@ private fun LocationNotesHeader( Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.location_notes_relays_unavailable), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -303,7 +300,7 @@ private fun NoteRow(note: LocationNotesManager.Note) { ) { Text( text = "@$baseName", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface @@ -312,7 +309,7 @@ private fun NoteRow(note: LocationNotesManager.Note) { Spacer(modifier = Modifier.width(6.dp)) Text( text = ts, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -324,7 +321,7 @@ private fun NoteRow(note: LocationNotesManager.Note) { // Second row: content Text( text = note.content, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface ) @@ -343,7 +340,7 @@ private fun NoRelaysRow(onRetry: () -> Unit) { ) { Text( text = stringResource(R.string.location_notes_no_relays_title), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface @@ -351,14 +348,14 @@ private fun NoRelaysRow(onRetry: () -> Unit) { Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.location_notes_no_relays_desc), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.retry), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, modifier = Modifier.clickable(onClick = onRetry) @@ -385,7 +382,7 @@ private fun LoadingRow() { Spacer(modifier = Modifier.width(10.dp)) Text( text = stringResource(R.string.loading_location_notes), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -404,7 +401,7 @@ private fun EmptyRow() { ) { Text( text = stringResource(R.string.location_notes_empty_title), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface @@ -412,7 +409,7 @@ private fun EmptyRow() { Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.location_notes_empty_desc), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -440,7 +437,7 @@ private fun ErrorRow(message: String, onDismiss: () -> Unit) { Spacer(modifier = Modifier.width(6.dp)) Text( text = message, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface ) @@ -448,7 +445,7 @@ private fun ErrorRow(message: String, onDismiss: () -> Unit) { Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.dismiss), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, modifier = Modifier.clickable(onClick = onDismiss) @@ -468,7 +465,6 @@ private fun LocationNotesInputSection( nickname: String?, onSend: () -> Unit ) { - val isDark = isSystemInDarkTheme() val colorScheme = MaterialTheme.colorScheme Column( @@ -482,7 +478,7 @@ private fun LocationNotesInputSection( Row(verticalAlignment = Alignment.CenterVertically) { Text( text = "@$baseName", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colorScheme.onSurface @@ -506,7 +502,7 @@ private fun LocationNotesInputSection( onValueChange = onDraftChange, textStyle = MaterialTheme.typography.bodyMedium.copy( color = colorScheme.primary, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( @@ -523,7 +519,7 @@ private fun LocationNotesInputSection( Text( text = stringResource(R.string.location_notes_input_placeholder), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = colorScheme.onSurface.copy(alpha = 0.5f), modifier = Modifier.fillMaxWidth() @@ -556,10 +552,8 @@ private fun LocationNotesInputSection( modifier = Modifier.size(20.dp), tint = if (!sendButtonEnabled) { colorScheme.onSurface.copy(alpha = 0.5f) - } else if (isDark) { - Color.Black // Black arrow on green in dark theme } else { - Color.White // White arrow on green in light theme + colorScheme.onPrimary } ) } diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt deleted file mode 100644 index 4ca8674f..00000000 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ /dev/null @@ -1,179 +0,0 @@ -package com.bitchat.android.ui - -import androidx.compose.material3.ColorScheme -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bitchat.android.mesh.MeshService -import com.bitchat.android.model.BitchatMessage -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import kotlin.random.Random - -/** - * Animation state for individual characters - */ -private enum class CharacterAnimationState { - ENCRYPTED, // Showing random encrypted characters - FINAL // Showing final decrypted character -} - -/** - * Check if a message should be animated based on its mining state - */ -@Composable -fun shouldAnimateMessage(messageId: String): Boolean { - val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle() - return miningMessages.contains(messageId) -} - -/** - * Tracks which messages are currently being mined for PoW - * Provides reactive state for UI animations - */ -object PoWMiningTracker { - private val _miningMessages = MutableStateFlow>(emptySet()) - val miningMessages: StateFlow> = _miningMessages.asStateFlow() - - /** - * Start tracking a message as mining - */ - fun startMiningMessage(messageId: String) { - _miningMessages.value = _miningMessages.value + messageId - } - - /** - * Stop tracking a message as mining - */ - fun stopMiningMessage(messageId: String) { - _miningMessages.value = _miningMessages.value - messageId - } - - /** - * Check if a message is currently mining - */ - fun isMiningMessage(messageId: String): Boolean { - return _miningMessages.value.contains(messageId) - } - - /** - * Clear all mining messages (for cleanup) - */ - fun clearAllMining() { - _miningMessages.value = emptySet() - } -} - -/** - * Shows the active PoW animation inside the same two-row layout used by static text messages. - */ -@Composable -fun MessageWithMatrixAnimation( - message: BitchatMessage, - currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme, - timeFormatter: SimpleDateFormat, - onNicknameClick: ((String) -> Unit)?, - onMessageLongPress: ((BitchatMessage) -> Unit)?, - modifier: Modifier = Modifier, -) { - AnimatedMessageDisplay( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress, - modifier = modifier, - ) -} - -/** - * Animates only the body content; sender, metadata, gestures, and spacing remain stable. - */ -@Composable -private fun AnimatedMessageDisplay( - message: BitchatMessage, - currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme, - timeFormatter: SimpleDateFormat, - onNicknameClick: ((String) -> Unit)?, - onMessageLongPress: ((BitchatMessage) -> Unit)?, - modifier: Modifier = Modifier, -) { - var animatedContent by remember(message.id, message.content) { - mutableStateOf(message.content) - } - - // Character-by-character animation state like the JavaScript version - var characterStates by remember(message.id, message.content) { - mutableStateOf(message.content.map { char -> - if (char == ' ') CharacterAnimationState.FINAL else CharacterAnimationState.ENCRYPTED - }) - } - - LaunchedEffect(message.id, message.content) { - if (message.content.isEmpty()) return@LaunchedEffect - - val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray() - - // Start character animations with staggered delays (like JS version). - message.content.forEachIndexed { index, targetChar -> - if (targetChar != ' ') { - launch { - delay(index * 50L) - - while (true) { - while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) { - val newContent = animatedContent.toCharArray() - if (index < newContent.size) { - newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)] - animatedContent = String(newContent) - } - - delay(100L) - - if (Random.nextFloat() < 0.1f) { - val finalContent = animatedContent.toCharArray() - if (index < finalContent.size) { - finalContent[index] = targetChar - animatedContent = String(finalContent) - } - - val finalStates = characterStates.toMutableList() - finalStates[index] = CharacterAnimationState.FINAL - characterStates = finalStates - break - } - } - - delay(2000L) - - val resetStates = characterStates.toMutableList() - resetStates[index] = CharacterAnimationState.ENCRYPTED - characterStates = resetStates - } - } - } - } - } - - TextMessageLayout( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress, - modifier = modifier, - bodyContent = animatedContent, - ) -} diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index ec137437..d9a8d035 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -1,26 +1,35 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -29,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.core.ui.component.button.CloseButton import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.favorites.FavoriteRelationship @@ -36,6 +46,9 @@ import com.bitchat.android.favorites.FavoritesPersistenceService import com.bitchat.android.geohash.ChannelID import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.ui.theme.colorForPeer import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.services.ContactDirectory @@ -68,6 +81,8 @@ fun MeshPeerListSheet( val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() + val geohashPeopleCount = geohashPeople.size val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } @@ -98,74 +113,83 @@ fun MeshPeerListSheet( LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp) + contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp) ) { + val peopleCount = when (selectedLocationChannel) { + is ChannelID.Location -> geohashPeopleCount + else -> connectedPeers.count { it != viewModel.myPeerID } + } + // Channels section if (joinedChannels.isNotEmpty()) { - item(key = "channels_header") { - Text( - text = stringResource(id = R.string.channels).uppercase(), - style = MaterialTheme.typography.labelLarge, - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontWeight = FontWeight.Bold, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 8.dp, bottom = 4.dp) - ) - } - - items( - items = joinedChannels.toList(), - key = { "channel_$it" } - ) { channel -> - val isSelected = channel == currentChannel - val unreadCount = unreadChannelMessages[channel] ?: 0 - - ChannelRow( - channel = channel, - isSelected = isSelected, - unreadCount = unreadCount, - colorScheme = colorScheme, - onChannelClick = { - // Check if this is a DM channel (starts with @) - if (channel.startsWith("@")) { - // Extract peer name and find the peer ID - val peerName = channel.removePrefix("@") - val peerID = - peerNicknames.entries.firstOrNull { it.value == peerName }?.key - if (peerID != null) { - viewModel.showPrivateChatSheet(peerID) - onDismiss() + item(key = "channels_section") { + Column { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_chat_bubbles, + title = stringResource(R.string.channels), + modifier = Modifier.padding(top = 8.dp) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = MaterialTheme.colorScheme.surface, + shape = AboutCardShape + ) { + Column { + joinedChannels.toList().forEachIndexed { index, channel -> + if (index > 0) SheetCardDivider() + val isSelected = channel == currentChannel + val unreadCount = unreadChannelMessages[channel] ?: 0 + ChannelRow( + channel = channel, + isSelected = isSelected, + unreadCount = unreadCount, + colorScheme = colorScheme, + onChannelClick = { + if (channel.startsWith("@")) { + val peerName = channel.removePrefix("@") + val peerID = + peerNicknames.entries.firstOrNull { it.value == peerName }?.key + if (peerID != null) { + viewModel.showPrivateChatSheet(peerID) + onDismiss() + } + } else { + viewModel.switchToChannel(channel) + onDismiss() + } + }, + onLeaveChannel = { + viewModel.leaveChannel(channel) + }, + ) } - } else { - // Regular channel switch - viewModel.switchToChannel(channel) - onDismiss() } - }, - onLeaveChannel = { - viewModel.leaveChannel(channel) - }, - ) + } + } } } - // People section - switch between mesh and geohash lists (iOS-compatible) + // People / geohash participants item(key = "people_section") { when (selectedLocationChannel) { is ChannelID.Location -> { - // Show geohash people list when in location channel GeohashPeopleList( viewModel = viewModel, - onTapPerson = onDismiss + onTapPerson = onDismiss, + modifier = Modifier.padding( + top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp + ) ) } else -> { - // Show mesh peer list when in mesh channel (default) PeopleSection( - modifier = Modifier.padding(top = if (joinedChannels.isNotEmpty()) 16.dp else 0.dp), + modifier = Modifier.padding( + top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp + ), connectedPeers = connectedPeers, peerNicknames = peerNicknames, peerRSSI = peerRSSI, @@ -173,6 +197,7 @@ fun MeshPeerListSheet( colorScheme = colorScheme, selectedPrivatePeer = selectedPrivatePeer, wifiAwarePeerIDs = wifiAwarePeerIDs, + peopleCount = peopleCount, viewModel = viewModel, onPrivateChatStart = { peerID -> viewModel.showPrivateChatSheet(peerID) @@ -194,13 +219,13 @@ fun MeshPeerListSheet( if (selectedLocationChannel !is ChannelID.Location) { IconButton( onClick = onShowVerification, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(44.dp) ) { Icon( imageVector = Icons.Outlined.QrCode, contentDescription = stringResource(R.string.verify_title), - tint = colorScheme.onSurface.copy(alpha = 0.8f), - modifier = Modifier.size(18.dp) + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) ) } } @@ -213,6 +238,9 @@ fun MeshPeerListSheet( } } +/** Icon size for trailing actions on peer rows (matches settings glyph scale). */ +private val PeerRowIconSize = 22.dp + @Composable private fun ChannelRow( channel: String, @@ -222,59 +250,52 @@ private fun ChannelRow( onChannelClick: () -> Unit, onLeaveChannel: () -> Unit, ) { - Surface( - onClick = onChannelClick, - color = if (isSelected) { - colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - Color.Transparent - }, - shape = MaterialTheme.shapes.medium, + val palette = LocalBitchatPalette.current + + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .clickable(onClick = onChannelClick) + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Box( + modifier = Modifier.size(SheetRowLeadingSlot), + contentAlignment = Alignment.Center ) { - Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Unread badge - if (unreadCount > 0) { - UnreadBadge( - count = unreadCount, - colorScheme = colorScheme - ) - } - - Text( - text = channel, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = if (isSelected) colorScheme.primary else colorScheme.onSurface, - fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal + if (isSelected) { + Box( + modifier = Modifier + .size(SheetRowSelectedDot) + .background(colorScheme.primary, CircleShape) ) - } - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Leave channel button - CloseButton( - onClick = onLeaveChannel, + } else if (unreadCount > 0) { + UnreadBadge(count = unreadCount, colorScheme = colorScheme) + } else { + Text( + text = "#", + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = palette.textTertiary ) } } + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + + Text( + text = channel, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + color = if (isSelected) colorScheme.primary else colorScheme.onSurface, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + CloseButton(onClick = onLeaveChannel) } } @@ -290,6 +311,7 @@ fun PeopleSection( colorScheme: ColorScheme, selectedPrivatePeer: String?, wifiAwarePeerIDs: Set = emptySet(), + peopleCount: Int = 0, viewModel: ChatViewModel, onPrivateChatStart: (String) -> Unit ) { @@ -298,31 +320,34 @@ fun PeopleSection( SecureIdentityStateManager(context.applicationContext) } + val palette = LocalBitchatPalette.current + Column(modifier = modifier) { - Text( - text = stringResource(id = R.string.people).uppercase(), - style = MaterialTheme.typography.labelLarge, - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontWeight = FontWeight.Bold, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 8.dp, bottom = 4.dp) + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_people, + title = stringResource(R.string.people_count_title, peopleCount) ) - if (connectedPeers.isEmpty()) { - Text( - text = stringResource(id = R.string.no_one_connected), - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = 12.sp - ), - color = colorScheme.onSurface.copy(alpha = 0.5f), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 40.dp, vertical = 12.dp) - ) - } + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column { + if (connectedPeers.isEmpty()) { + Text( + text = stringResource(id = R.string.no_one_connected), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = palette.textTertiary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical) + ) + } // Observe reactive state for favorites and fingerprints val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() @@ -426,7 +451,21 @@ fun PeopleSection( if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 } - sortedPeers.forEach { peerID -> + // Every row this card will show, in final order, so the animated list can key on identity + // and animate reordering. Offline favourites are appended after the connected peers. + // Collected once for the whole card rather than once per row. + val directMap by viewModel.peerDirect.collectAsStateWithLifecycle() + + val offlineFavoriteRows = offlineFavorites.filterNot { isFavoriteMappedToConnected(it) } + val rowKeys: List = sortedPeers + + offlineFavoriteRows.map { ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) } + + AnimatedRowColumn(items = rowKeys, key = { it }) { rowIndex, rowKey -> + Column { + if (rowIndex > 0) SheetCardDivider() + val connectedPeerForRow = sortedPeers.firstOrNull { it == rowKey } + if (connectedPeerForRow != null) { + val peerID = connectedPeerForRow val conversationID = ContactDirectory.canonicalConversationId(peerID) val isFavorite = peerFavoriteStates[peerID] ?: false val isVerified = peerVerifiedStates[peerID] ?: false @@ -446,7 +485,6 @@ fun PeopleSection( val (bName, _) = splitSuffix(displayName) val showHash = (baseNameCounts[bName] ?: 0) > 1 - val directMap by viewModel.peerDirect.collectAsStateWithLifecycle() val isDirectLive = directMap[peerID] ?: try { viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false } PeerItem( peerID = peerID, @@ -468,12 +506,12 @@ fun PeopleSection( showNostrGlobe = false, showHashSuffix = showHash ) - } - - // Append offline favorites we actively favorite (and not currently connected) - offlineFavorites.forEach { fav -> - val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey) - if (isFavoriteMappedToConnected(fav)) return@forEach + } else { + // Offline favourite: still worth showing, reachable over Nostr. + val fav = offlineFavoriteRows.first { + ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) == rowKey + } + val favPeerID = rowKey val nostrConvKey: String? = try { FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey) @@ -518,6 +556,10 @@ fun PeopleSection( showHashSuffix = showHash ) } + } + } + } + } } } @@ -547,143 +589,123 @@ private fun PeerItem( val isMe = displayName == "You" || peerID == currentNickname // Get consistent peer color (iOS-compatible) - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val assignedColor = viewModel.colorForMeshPeer(peerID, isDark) - val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor + val palette = LocalBitchatPalette.current + val assignedColor = colorForPeer( + viewModel.peerIdentityForMeshPeer(peerID), + palette + ) + val baseColor = if (isMe) palette.accentOrange else assignedColor - Surface( - onClick = onItemClick, - color = if (isSelected) { - colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - Color.Transparent - }, - shape = MaterialTheme.shapes.medium, + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .clickable(onClick = onItemClick) + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Box( + modifier = Modifier.size(SheetRowLeadingSlot), + contentAlignment = Alignment.Center ) { - Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Connection/status indicator - if (hasUnreadDM) { - // Show mail icon for unread DMs (iOS orange) - Icon( - imageVector = Icons.Filled.Email, - contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(16.dp), - tint = Color(0xFFFF9500) // iOS orange - ) - } else if (showNostrGlobe) { - // Purple globe to indicate Nostr availability - Icon( - imageVector = Icons.Filled.Public, - contentDescription = stringResource(R.string.cd_reachable_via_nostr), - modifier = Modifier.size(16.dp), - tint = Color(0xFF9C27B0) // Purple - ) - } else if (!isDirect && isFavorite) { - // Offline favorited user: show outlined circle icon - Icon( - imageVector = Icons.Outlined.Circle, - contentDescription = stringResource(R.string.cd_offline_favorite), - modifier = Modifier.size(16.dp), - tint = Color.Gray - ) - } else { - Icon( - imageVector = when { - isWifiAware -> Icons.Filled.Wifi - isDirect -> Icons.Outlined.Bluetooth - else -> Icons.Filled.Route - }, - contentDescription = when { - isWifiAware -> "Direct Wi-Fi Aware" - isDirect -> "Direct Bluetooth" - else -> "Routed" - }, - modifier = Modifier.size(16.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - - // Display name with iOS-style color and hashtag suffix support - Row(verticalAlignment = Alignment.CenterVertically) { - // Base name with peer-specific color - Text( - text = baseName, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal - ), - color = baseColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - // Hashtag suffix in lighter shade (iOS-style) - if (suffix.isNotEmpty()) { - Text( - text = suffix, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = baseColor.copy(alpha = 0.6f) - ) - } - - if (isWifiAware && hasUnreadDM) { - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.Filled.Wifi, - contentDescription = "Direct Wi-Fi Aware", - modifier = Modifier.size(13.dp), - tint = colorScheme.onSurface.copy(alpha = 0.8f) - ) - } - } - } - - if (isVerified) { - Spacer(modifier = Modifier.width(4.dp)) + if (isSelected) { + Box( + modifier = Modifier + .size(SheetRowSelectedDot) + .background(colorScheme.primary, CircleShape) + ) + } else if (hasUnreadDM) { Icon( - imageVector = Icons.Filled.Verified, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = Color(0xFF32D74B) // iOS Green + painter = painterResource(R.drawable.ic_spec_envelope), + contentDescription = stringResource(R.string.cd_unread_message), + modifier = Modifier.size(PeerRowIconSize), + tint = palette.accentOrange + ) + } else if (showNostrGlobe) { + Icon( + painter = painterResource(R.drawable.ic_spec_globe), + contentDescription = stringResource(R.string.cd_reachable_via_nostr), + modifier = Modifier.size(PeerRowIconSize), + tint = palette.accentPurple + ) + } else if (!isDirect && isFavorite) { + Icon( + imageVector = Icons.Outlined.Circle, + contentDescription = stringResource(R.string.cd_offline_favorite), + modifier = Modifier.size(PeerRowIconSize), + tint = palette.textTertiary + ) + } else { + Icon( + painter = painterResource( + conversationTransportIcon( + isReachedOverInternet = false, + isWifiAware = isWifiAware, + isDirect = isDirect + ) + ), + contentDescription = when { + isWifiAware -> "Direct Wi-Fi Aware" + isDirect -> "Direct Bluetooth" + else -> "Routed" + }, + modifier = Modifier.size(PeerRowIconSize), + tint = colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = baseName, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, + color = baseColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (suffix.isNotEmpty()) { + Text( + text = suffix, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, + color = baseColor.copy(alpha = SUFFIX_ALPHA) ) } - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Favorite star with proper filled/outlined states - IconButton( - onClick = onToggleFavorite, - modifier = Modifier.size(32.dp) - ) { - Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, - contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", - modifier = Modifier.size(16.dp), - tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50) - ) - } + if (isVerified) { + Icon( + painter = painterResource(R.drawable.ic_spec_check), + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(16.dp), + tint = colorScheme.primary + ) } } + + Box( + modifier = Modifier + .size(36.dp) + .clickable(onClick = onToggleFavorite), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource( + if (isFavorite) R.drawable.ic_spec_star_filled else R.drawable.ic_spec_star + ), + contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", + modifier = Modifier.size(PeerRowIconSize), + tint = if (isFavorite) palette.accentOrange else palette.textTertiary + ) + } } } @@ -696,25 +718,41 @@ private fun UnreadBadge( colorScheme: ColorScheme, modifier: Modifier = Modifier ) { - if (count > 0) { + val palette = LocalBitchatPalette.current + // Scale/fade in and out so a badge appearing while the sheet is open is noticed, and one + // clearing does not just blink away. + AnimatedVisibility( + visible = count > 0, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + scaleIn( + initialScale = 0.5f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + scaleOut( + targetScale = 0.5f, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ), + modifier = modifier + ) { Box( - modifier = modifier + modifier = Modifier .background( - color = Color(0xFFFFD700), // Yellow color + color = palette.accentOrange, shape = RoundedCornerShape(10.dp) ) .padding(horizontal = 6.dp, vertical = 2.dp) .defaultMinSize(minWidth = 18.dp, minHeight = 18.dp), contentAlignment = Alignment.Center ) { - Text( + AnimatedCountLabel( + count = count, text = if (count > 99) "99+" else count.toString(), - style = MaterialTheme.typography.labelSmall.copy( - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace - ), - color = Color.Black // Black text on yellow background + style = MaterialTheme.typography.labelSmall, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = BitchatFontFamily, + color = Color.Black ) } } @@ -804,7 +842,7 @@ fun PrivateChatSheet( val name = if (fullPubkey.isNotEmpty()) { viewModel.geohashViewModel.displayNameForGeohashConversation(fullPubkey, gh) } else { - peerNicknames[peerID] ?: "unknown" + peerNicknames[peerID] ?: "Unknown" } "#$gh/@$name" } else { @@ -826,12 +864,7 @@ fun PrivateChatSheet( viewModel.isPeerVerified(peerID, verifiedFingerprints) } - val securityModifier = if (!isNostrPeer && !isNostrReachableFavorite) { - Modifier.clickable { viewModel.showSecurityVerificationSheet() } - } else { - Modifier - } - + val palette = LocalBitchatPalette.current val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true ) @@ -845,9 +878,9 @@ fun PrivateChatSheet( Column( modifier = Modifier.fillMaxSize() ) { - Spacer(modifier = Modifier.height(64.dp)) + Spacer(modifier = Modifier.height(ChatHeaderHeight)) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) // Messages list var forceScrollToBottom by remember { mutableStateOf(false) } @@ -858,6 +891,7 @@ fun PrivateChatSheet( currentUserNickname = nickname, meshService = viewModel.meshServiceFacade, modifier = Modifier.weight(1f), + conversationKey = "dm:$peerID", forceScrollToBottom = forceScrollToBottom, onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, onNicknameClick = { /* handle mention */ }, @@ -866,9 +900,8 @@ fun PrivateChatSheet( onImageClick = { _, _, _ -> /* handle image click */ } ) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) - - // Input section + // Input section. No divider here: ChatInputSection draws its own fade and + // hairline. var messageText by remember { mutableStateOf( androidx.compose.ui.text.input.TextFieldValue( @@ -913,112 +946,97 @@ fun PrivateChatSheet( ) } - // TopBar (fixed at top, iOS-style) - BitchatSheetCenterTopBar( - onClose = onDismiss, + // Header. Built from the same tokens as the main chat header rather than a + // TopAppBar, so moving between the timeline and a conversation does not shift the + // bar's height, insets or type. + Surface( modifier = Modifier.align(Alignment.TopCenter), - navigationIcon = { - IconButton( - onClick = onDismiss, - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = 16.dp) - .size(32.dp) + color = colorScheme.background + ) { + ConversationHeader( + leadingIconRes = conversationTransportIcon( + isReachedOverInternet = isNostrPeer || isNostrReachableFavorite, + isWifiAware = isWifiAware, + isDirect = isDirect + ), + leadingIconTint = colorScheme.primary, + leadingContentDescription = when { + isNostrPeer || isNostrReachableFavorite -> + stringResource(R.string.cd_nostr_reachable) + else -> null + }, + title = titleText + ) { + ConversationHeaderAction( + onClick = { viewModel.toggleFavorite(peerID) }, + contentDescription = if (isFavorite) { + stringResource(R.string.cd_remove_favorite) + } else { + stringResource(R.string.cd_add_favorite) + } ) { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.chat_back), - tint = colorScheme.onSurface + painter = painterResource( + if (isFavorite) { + R.drawable.ic_spec_star_filled + } else { + R.drawable.ic_spec_star + } + ), + contentDescription = null, + modifier = Modifier.size(HeaderIconSize), + tint = if (isFavorite) { + palette.accentOrange + } else { + colorScheme.onSurfaceVariant + } ) } - }, - title = { - // Center content: connection status + name + encryption - Row( - modifier = Modifier.align(Alignment.Center), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - when { - isNostrPeer || isNostrReachableFavorite -> { - Icon( - imageVector = Icons.Filled.Public, - contentDescription = stringResource(R.string.cd_nostr_reachable), - modifier = Modifier.size(14.dp), - tint = Color(0xFF9C27B0) - ) - } - isWifiAware -> { - Icon( - imageVector = Icons.Filled.Wifi, - contentDescription = "Direct Wi-Fi Aware", - modifier = Modifier.size(14.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - isDirect -> { - Icon( - imageVector = Icons.Outlined.Bluetooth, - contentDescription = "Direct Bluetooth", - modifier = Modifier.size(14.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - isConnected -> { - Icon( - imageVector = Icons.Filled.Route, - contentDescription = "Routed", - modifier = Modifier.size(14.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - } - Text( - text = titleText, - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace - ), - color = if (isNostrPeer || isNostrReachableFavorite) Color(0xFFFF9500) else colorScheme.onSurface - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.then(securityModifier) + // Encryption state, and the verification badge that qualifies it. Both are + // read-only for Nostr peers, which have no Noise session at all. + if (!isNostrPeer && !isNostrReachableFavorite) { + ConversationHeaderAction( + onClick = { viewModel.showSecurityVerificationSheet() }, + contentDescription = stringResource(R.string.verify_title) ) { - if (!isNostrPeer && !isNostrReachableFavorite) { + Box(contentAlignment = Alignment.Center) { NoiseSessionIcon( sessionState = sessionState, - modifier = Modifier.size(14.dp) - ) - } - - if (isVerified) { - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.Filled.Verified, - contentDescription = stringResource(R.string.verify_title), - modifier = Modifier.size(14.dp), - tint = Color(0xFF32D74B) // iOS Green + modifier = Modifier.size(HeaderIconSize) ) } } + } - IconButton( - onClick = { viewModel.toggleFavorite(peerID) }, - modifier = Modifier.size(28.dp) + if (isVerified) { + Box( + modifier = Modifier.size(HeaderIconSize), + contentAlignment = Alignment.Center ) { Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, - contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite), - modifier = Modifier.size(16.dp), - tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f) + painter = painterResource(R.drawable.ic_spec_check), + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(HeaderIconSize), + tint = colorScheme.primary ) } } + + val dismiss = LocalSheetDismiss.current + ConversationHeaderAction( + onClick = { dismiss?.invoke() ?: onDismiss() }, + contentDescription = stringResource(R.string.close_plain) + ) { + Icon( + painter = painterResource(R.drawable.ic_spec_close), + contentDescription = null, + modifier = Modifier.size(HeaderIconSize), + tint = colorScheme.onSurfaceVariant + ) + } } - ) + } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 73fac04d..16c7f63d 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -1,6 +1,18 @@ package com.bitchat.android.ui - +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -9,16 +21,16 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.ColorScheme import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -29,20 +41,25 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.component.text.AnnotatedClickableText import com.bitchat.android.mesh.MeshService @@ -50,6 +67,13 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.ui.media.FileMessageItem +import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.ChatVisualTokens +import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.ui.theme.MessageBodyTextStyle +import com.bitchat.android.ui.theme.MessageSenderTextStyle +import kotlinx.coroutines.delay import java.text.SimpleDateFormat import java.util.Locale @@ -61,12 +85,119 @@ import java.util.Locale * Extracted from ChatScreen.kt for better organization */ +/** How far a newly arrived message travels up into place. */ +private val MessageEntrySlide = 14.dp + +/** + * Entry motion for a new message: quick, with just enough damping to settle rather than snap. + * Runs entirely on a graphics layer, so it costs a transform and nothing else. + */ +private val MessageEntrySpec: AnimationSpec = + spring(dampingRatio = 0.85f, stiffness = 1200f) + +/** + * Motion for messages being pushed out of the way by an arrival. Softer than the entry so the + * conversation glides up while the new message itself lands crisply. + */ +private val MessagePlacementSpec: FiniteAnimationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = IntOffset.VisibilityThreshold +) + +/** Removals are not worth dwelling on. */ +private val MessageFadeOutSpec: FiniteAnimationSpec = tween(BitchatMotion.QUICK_MS) + +/** + * How long placement animation stays armed after the list gains or loses a message. + * + * Comfortably longer than [MessagePlacementSpec] takes to settle, so an arrival's push is never cut + * short. + */ +private const val PlacementArmWindowMs = 600L + +/** + * Above this many simultaneous arrivals, entry animations are skipped. + * + * A history sync or a channel switch can append hundreds of messages in one frame. Animating each + * would spend the entire frame budget on motion nobody asked to see, so a burst is adopted + * silently and only conversational-pace arrivals animate. + */ +internal const val MaxAnimatedArrivals = 6 + +/** + * Remembers which message ids have already been seen, so genuine arrivals can be told apart from + * items merely scrolling back into view. + * + * This distinction is the whole reason the entry animation is usable: `LazyColumn` composes items + * on demand, so animating on first composition would replay the animation for every old message + * the user scrolled back to. + */ +internal class MessageArrivalTracker { + val known = HashSet() + var seeded = false +} + +/** + * Ids that should animate in on this composition pass. + * + * Deliberately computed during composition rather than in a `LaunchedEffect`: effects run *after* + * the frame's composition, by which point a new message's item has already composed and would + * have missed its cue. + */ +internal fun MessageArrivalTracker.arrivals(messages: List): Set { + if (!seeded) { + // First load adopts everything silently. A whole screenful animating on open reads as a + // glitch, not a flourish. + messages.forEach { known.add(it.id) } + seeded = true + return emptySet() + } + + // A list with nothing in common with the last one is a different conversation, not a burst of + // arrivals — /clear, or a switch the caller did not give us a distinct key for. Adopt it + // silently rather than sliding in every message at once. + val isWholesaleReplacement = + messages.isNotEmpty() && known.isNotEmpty() && messages.none { it.id in known } + + // `HashSet.add` reports whether the id was new, so this both diffs and updates in one pass. + val added = messages.filter { known.add(it.id) } + + if (known.size > messages.size) { + // Messages disappeared (/clear, channel switch). Drop the stale ids so the set cannot + // grow without bound and so re-added messages animate again. + known.retainAll(messages.mapTo(HashSet(messages.size)) { it.id }) + } + + return when { + isWholesaleReplacement -> emptySet() + added.isEmpty() || added.size > MaxAnimatedArrivals -> emptySet() + else -> added.mapTo(HashSet(added.size)) { it.id } + } +} + @Composable fun MessagesList( messages: List, currentUserNickname: String, meshService: MeshService, modifier: Modifier = Modifier, + mentionPeerIdentities: Map? = null, + /** + * Extra inset on top of the list's own gutters. + * + * The chat screen's bars are translucent and the list scrolls underneath them, so the caller + * has to reserve room for their heights here rather than by shrinking the viewport. + */ + contentPadding: PaddingValues = PaddingValues(0.dp), + /** + * Identity of the conversation being shown — a channel, a geohash, a peer. + * + * Everything below that is per-conversation state is keyed on this. Without it, switching + * channels reused the previous conversation's scroll offset, follow flag and seen-message set, + * so the new channel opened at a stale position and then animated itself into place. + */ + conversationKey: Any? = null, forceScrollToBottom: Boolean = false, onScrolledUpChanged: ((Boolean) -> Unit)? = null, onNicknameClick: ((String) -> Unit)? = null, @@ -74,11 +205,23 @@ fun MessagesList( onCancelTransfer: ((BitchatMessage) -> Unit)? = null, onImageClick: ((String, List, Int) -> Unit)? = null ) { - val listState = rememberLazyListState() - + val resolvedMentionPeerIdentities = remember(messages, mentionPeerIdentities) { + mentionPeerIdentities ?: buildMentionPeerIdentityMap(messages) + } + + // A fresh scroll position per conversation. Sharing one state meant a switch inherited the + // previous channel's offset and then had to correct itself, which is what the jump was. + // + // Passing the key as an *input* rather than as `key =` is deliberate: it discards the saved + // offset on every switch, so a conversation always opens on its newest message instead of + // wherever the reader happened to be some time ago, with unseen messages below them. + val listState = rememberSaveable(conversationKey, saver = LazyListState.Saver) { + LazyListState() + } + // Track if this is the first time messages are being loaded - var hasScrolledToInitialPosition by remember { mutableStateOf(false) } - var followIncomingMessages by remember { mutableStateOf(true) } + var hasScrolledToInitialPosition by remember(conversationKey) { mutableStateOf(false) } + var followIncomingMessages by remember(conversationKey) { mutableStateOf(true) } // Smart scroll: auto-scroll to bottom for initial load, then follow unless user scrolls away LaunchedEffect(messages.size) { @@ -94,7 +237,7 @@ fun MessagesList( } // Track whether user has scrolled away from the latest messages - val isAtLatest by remember { + val isAtLatest by remember(listState) { derivedStateOf { val firstVisibleIndex = listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1 firstVisibleIndex <= 2 @@ -114,27 +257,107 @@ fun MessagesList( } } + // Recomputed only when the list actually gains or loses a message, and synchronously, so the + // arriving item can read its cue during the same composition pass in which it first appears. + // Reset per conversation, so a switch adopts the incoming messages silently instead of + // treating a whole channel's backlog as brand-new arrivals and sliding each one in. + val arrivalTracker = remember(conversationKey) { MessageArrivalTracker() } + val enteringIds = remember(conversationKey, messages.size, messages.lastOrNull()?.id) { + arrivalTracker.arrivals(messages) + } + + // Placement animation exists to soften insertions and removals. But *any* relayout moves every + // item — the keyboard opening behind a bottom sheet, that sheet closing again, the composer + // growing a line — and animating those made the whole conversation lurch. So it is armed only + // briefly around a genuine change to the list, and is otherwise off, letting items track the + // viewport exactly. + var placementArmed by remember(conversationKey) { mutableStateOf(false) } + var previousMessageCount by remember(conversationKey) { mutableStateOf(null) } + LaunchedEffect(conversationKey, messages.size) { + val previous = previousMessageCount + previousMessageCount = messages.size + // Skip the first composition: the list settling into its initial padding is not a change + // worth animating. + if (previous == null || previous == messages.size) return@LaunchedEffect + placementArmed = true + delay(PlacementArmWindowMs) + placementArmed = false + } + + val layoutDirection = LocalLayoutDirection.current LazyColumn( state = listState, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + // Wider side gutters than the old 12.dp: the redesign trades a little line length for + // a much calmer edge, and long monospace lines were running into the screen bezel. + contentPadding = PaddingValues( + start = 16.dp + contentPadding.calculateStartPadding(layoutDirection), + end = 16.dp + contentPadding.calculateEndPadding(layoutDirection), + top = 8.dp + contentPadding.calculateTopPadding(), + bottom = 12.dp + contentPadding.calculateBottomPadding() + ), + // Spacing is owned by each item. The exported transcript uses a consistent 8.dp rhythm; + // a new speaker gets additional separation from the visible sender row's top inset. + verticalArrangement = Arrangement.spacedBy(0.dp), modifier = modifier, reverseLayout = true ) { - items( - items = messages.asReversed(), - key = { it.id } - ) { message -> - MessageItem( - message = message, - messages = messages, - currentUserNickname = currentUserNickname, - meshService = meshService, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress, - onCancelTransfer = onCancelTransfer, - onImageClick = onImageClick - ) + val reversed = messages.asReversed() + itemsIndexed( + items = reversed, + key = { _, message -> message.id } + ) { reversedIndex, message -> + // reverseLayout renders index 0 at the bottom, so the chronological predecessor of + // this row lives at a *higher* original index offset. Resolve against the original + // list rather than the reversed view to keep the grouping logic readable. + val originalIndex = messages.lastIndex - reversedIndex + val previous = messages.getOrNull(originalIndex - 1) + val isGrouped = MessageGrouping.shouldGroup(previous, message) + + // Decided once per item instance, so an item recycling back into view during a scroll + // never re-animates. Items that are not arriving skip the animation machinery + // entirely: no Animatable, no coroutine, and no extra render layer per row. + val isArriving = remember(message.id) { message.id in enteringIds } + val entryModifier = if (isArriving) { + val entry = remember(message.id) { Animatable(0f) } + LaunchedEffect(message.id) { entry.animateTo(1f, MessageEntrySpec) } + // A draw-time transform only: no measure, no layout, and no recomposition of the + // message content on any frame of the animation. + Modifier.graphicsLayer { + val progress = entry.value + alpha = progress + translationY = (1f - progress) * MessageEntrySlide.toPx() + } + } else { + Modifier + } + + MessageItem( + message = message, + messages = messages, + currentUserNickname = currentUserNickname, + meshService = meshService, + mentionPeerIdentities = resolvedMentionPeerIdentities, + showSender = !isGrouped, + topSpacing = MessageGrouping.topSpacingFor( + isGrouped = isGrouped, + isFirstInList = originalIndex == 0 + ), + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + onCancelTransfer = onCancelTransfer, + onImageClick = onImageClick, + modifier = Modifier + // Animates the shift when a neighbour is inserted or removed: this is what + // makes the conversation glide up instead of jumping. + .animateItem( + // Entry fade is handled by entryModifier, together with the slide, so the + // two cannot drift out of step. + fadeInSpec = null, + placementSpec = if (placementArmed) MessagePlacementSpec else null, + fadeOutSpec = MessageFadeOutSpec + ) + .then(entryModifier) + ) } } } @@ -146,16 +369,22 @@ fun MessageItem( currentUserNickname: String, meshService: MeshService, messages: List = emptyList(), + mentionPeerIdentities: Map = emptyMap(), + showSender: Boolean = true, + topSpacing: Dp = 0.dp, onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, onCancelTransfer: ((BitchatMessage) -> Unit)? = null, - onImageClick: ((String, List, Int) -> Unit)? = null + onImageClick: ((String, List, Int) -> Unit)? = null, + modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) } - + val timeFormatter = remember { SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) } + Column( - modifier = Modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .padding(top = topSpacing), verticalArrangement = Arrangement.spacedBy(0.dp) ) { Box(modifier = Modifier.fillMaxWidth()) { @@ -172,8 +401,10 @@ fun MessageItem( messages = messages, currentUserNickname = currentUserNickname, meshService = meshService, + mentionPeerIdentities = mentionPeerIdentities, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -209,14 +440,18 @@ fun MessageItem( messages: List, currentUserNickname: String, meshService: MeshService, + mentionPeerIdentities: Map, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, + showSender: Boolean, onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, onImageClick: ((String, List, Int) -> Unit)?, modifier: Modifier = Modifier ) { + val palette = LocalBitchatPalette.current + // Image special rendering if (message.type == BitchatMessageType.Image) { com.bitchat.android.ui.media.ImageMessageItem( @@ -226,6 +461,7 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -243,6 +479,7 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -268,9 +505,11 @@ fun MessageItem( val headerText = formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter + myPeerID = meshService.myPeerID, + palette = palette, + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + includeSender = showSender ) val haptic = LocalHapticFeedback.current AnnotatedClickableText( @@ -286,7 +525,7 @@ fun MessageItem( } }, onLongPress = { onMessageLongPress?.invoke(message) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface, ) @@ -335,15 +574,24 @@ fun MessageItem( .align(Alignment.TopEnd) .padding(4.dp) .size(22.dp) - .background(Color.Gray.copy(alpha = 0.6f), CircleShape) + .background(colorScheme.surfaceVariant.copy(alpha = 0.85f), CircleShape) .clickable { onCancelTransfer?.invoke(message) }, contentAlignment = Alignment.Center ) { - Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp)) + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.cd_cancel), + tint = colorScheme.onSurface, + modifier = Modifier.size(14.dp) + ) } } } else { - Text(text = stringResource(R.string.file_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray) + Text( + text = stringResource(R.string.file_unavailable), + fontFamily = BitchatFontFamily, + color = palette.textTertiary + ) } } } @@ -351,31 +599,15 @@ fun MessageItem( return } - // Check if this message should be animated during PoW mining - val shouldAnimate = shouldAnimateMessage(message.id) - - // If animation is needed, use the matrix animation component for content only - if (shouldAnimate) { - // Display message with matrix animation for content - MessageWithMatrixAnimation( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress, - modifier = modifier - ) - } else if (message.sender == "system") { - // Keep system messages on the compact legacy line. - val annotatedText = formatMessageAsAnnotatedString( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter - ) + if (message.sender == "system") { + // Background narration: `// Tor started. Routing all chats…` + val annotatedText = remember(message, colorScheme.onSurface) { + formatSystemMessage( + message = message, + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter + ) + } val haptic = LocalHapticFeedback.current Text( @@ -388,11 +620,11 @@ fun MessageItem( } ) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, softWrap = true, overflow = TextOverflow.Visible, - style = androidx.compose.ui.text.TextStyle( - color = colorScheme.onSurface + style = ChatVisualTokens.SystemActionStyle.copy( + color = colorScheme.onSurface.copy(alpha = ChatVisualTokens.MutedTextAlpha), ) ) } else { @@ -400,8 +632,10 @@ fun MessageItem( message = message, currentUserNickname = currentUserNickname, meshService = meshService, + mentionPeerIdentities = mentionPeerIdentities, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, modifier = modifier, @@ -414,37 +648,47 @@ internal fun TextMessageLayout( message: BitchatMessage, currentUserNickname: String, meshService: MeshService, + mentionPeerIdentities: Map = emptyMap(), colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, modifier: Modifier = Modifier, + showSender: Boolean = true, bodyContent: String = message.content, ) { + val palette = LocalBitchatPalette.current val myPeerId = meshService.myPeerID val displayMessage = remember(message, bodyContent) { if (bodyContent == message.content) message else message.copy(content = bodyContent) } - val senderText = remember(message, currentUserNickname, myPeerId, colorScheme) { + val senderText = remember(message, currentUserNickname, myPeerId, palette) { formatTextMessageSender( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, + myPeerID = myPeerId, + palette = palette, ) } - val metadataText = remember(message.timestamp, message.powDifficulty, timeFormatter) { - formatTextMessageMetadata( - message = message, - timeFormatter = timeFormatter, - ) - } - val bodyText = remember(displayMessage, currentUserNickname, myPeerId, colorScheme) { + // The timestamp trails the body rather than occupying its own column, so a short message + // no longer reserves a full-width row for eight grey characters. + val bodyText = remember( + displayMessage, + currentUserNickname, + palette, + colorScheme.onSurface, + colorScheme.secondary, + mentionPeerIdentities, + timeFormatter + ) { formatTextMessageBody( message = displayMessage, currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + mentionPeerIdentities = mentionPeerIdentities, + timeFormatter = timeFormatter, ) } val isSelf = message.isFromSelf(currentUserNickname, myPeerId) @@ -457,12 +701,9 @@ internal fun TextMessageLayout( Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(MessageGrouping.SENDER_TO_BODY_SPACING), ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { + if (showSender) { AnnotatedClickableText( text = senderText, annotationTags = listOf("nickname_click"), @@ -476,18 +717,13 @@ internal fun TextMessageLayout( } }, onLongPress = handleLongPress, - modifier = Modifier.weight(1f), - fontFamily = FontFamily.Monospace, + modifier = Modifier + .fillMaxWidth() + .padding(top = MessageGrouping.SENDER_TOP_PADDING), + fontFamily = BitchatFontFamily, softWrap = false, overflow = TextOverflow.Ellipsis, - ) - AnnotatedClickableText( - text = metadataText, - annotationTags = emptyList(), - onAnnotationClick = { _, _ -> false }, - onLongPress = handleLongPress, - fontFamily = FontFamily.Monospace, - softWrap = false, + style = MessageSenderTextStyle, ) } @@ -512,10 +748,10 @@ internal fun TextMessageLayout( } }, onLongPress = handleLongPress, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, softWrap = true, overflow = TextOverflow.Visible, - style = androidx.compose.ui.text.TextStyle(color = colorScheme.onSurface), + style = MessageBodyTextStyle.copy(color = colorScheme.onSurface), ) } } @@ -523,53 +759,40 @@ internal fun TextMessageLayout( @Composable fun DeliveryStatusIcon(status: DeliveryStatus) { val colorScheme = MaterialTheme.colorScheme - - when (status) { - is DeliveryStatus.Sending -> { - Text( - text = stringResource(R.string.status_sending), - fontSize = 10.sp, - color = colorScheme.primary.copy(alpha = 0.6f) - ) - } - is DeliveryStatus.Sent -> { - // Use a subtle hollow marker for Sent; single check is reserved for Delivered (iOS parity) - Text( - text = stringResource(R.string.status_pending), - fontSize = 10.sp, - color = colorScheme.primary.copy(alpha = 0.6f) - ) - } - is DeliveryStatus.Delivered -> { - // Single check for Delivered (matches iOS expectations) - Text( - text = stringResource(R.string.status_sent), - fontSize = 10.sp, - color = colorScheme.primary.copy(alpha = 0.8f) - ) - } - is DeliveryStatus.Read -> { - Text( - text = stringResource(R.string.status_delivered), - fontSize = 10.sp, - color = Color(0xFF007AFF), // Blue - fontWeight = FontWeight.Bold - ) - } - is DeliveryStatus.Failed -> { - Text( - text = stringResource(R.string.status_failed), - fontSize = 10.sp, - color = Color.Red.copy(alpha = 0.8f) - ) - } - is DeliveryStatus.PartiallyDelivered -> { - // Show a single subdued check without numeric label - Text( - text = stringResource(R.string.status_sent), - fontSize = 10.sp, - color = colorScheme.primary.copy(alpha = 0.6f) - ) + + // Status advances on its own as acks come back, so a hard glyph swap reads as a flicker. + // Keyed on the status *type* rather than the instance, because Delivered/Read carry a + // timestamp that would otherwise retrigger the transition on every identical update. + AnimatedContent( + targetState = status::class, + transitionSpec = { + fadeIn(tween(BitchatMotion.STANDARD_MS)) togetherWith + fadeOut(tween(BitchatMotion.QUICK_MS)) + }, + label = "deliveryStatus" + ) { statusClass -> + val (text, color, weight) = when (statusClass) { + DeliveryStatus.Sending::class -> + Triple(R.string.status_sending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) + // Subtle hollow marker for Sent; a single check is reserved for Delivered (iOS parity). + DeliveryStatus.Sent::class -> + Triple(R.string.status_pending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) + DeliveryStatus.Delivered::class -> + Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.8f), FontWeight.Normal) + DeliveryStatus.Read::class -> + Triple(R.string.status_delivered, colorScheme.secondary, FontWeight.Bold) + DeliveryStatus.Failed::class -> + Triple(R.string.status_failed, colorScheme.error, FontWeight.Normal) + // A single subdued check, without the numeric label. + else -> + Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) } + + Text( + text = stringResource(text), + fontSize = 10.sp, + color = color, + fontWeight = weight + ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt new file mode 100644 index 00000000..9b3278c4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt @@ -0,0 +1,84 @@ +package com.bitchat.android.ui + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.theme.ChatVisualTokens + +/** + * Consecutive-message grouping for the chat surface. + * + * The redesign suppresses the `@sender` label on runs of messages from the same author so the + * eye only has to register a name when the speaker actually changes. The transcript uses one + * consistent item rhythm; new groups get their additional separation from the visible sender + * row's own top padding. + */ +object MessageGrouping { + + /** Space above the first message of a group (i.e. one that renders its sender label). */ + val NEW_GROUP_SPACING: Dp = ChatVisualTokens.MessageItemSpacing + + /** Space above a continuation message inside an existing group. */ + val GROUPED_SPACING: Dp = ChatVisualTokens.MessageItemSpacing + + /** Gap between the sender label and the first line of the body. */ + val SENDER_TO_BODY_SPACING: Dp = ChatVisualTokens.SenderToBodySpacing + + /** Top inset inside a visible sender row. */ + val SENDER_TOP_PADDING: Dp = ChatVisualTokens.SenderTopPadding + + /** + * Messages further apart than this always start a new group, even from the same sender: + * a reply an hour later is a new thought, not a continuation. + */ + const val GROUPING_WINDOW_MS: Long = 5 * 60 * 1000L + + private const val SYSTEM_SENDER = "system" + + /** + * Whether [current] should be rendered as a continuation of [previous], hiding its sender + * label. + * + * [previous] is the message immediately *before* [current] in chronological order. Note the + * message list renders with `reverseLayout = true`, so callers must be careful to resolve + * the chronological predecessor rather than the visually preceding item. + */ + fun shouldGroup(previous: BitchatMessage?, current: BitchatMessage): Boolean { + if (previous == null) return false + + // System/action lines never participate in grouping in either direction: they are + // narration, and folding a real message into them would attribute it to "system". + if (previous.sender == SYSTEM_SENDER || current.sender == SYSTEM_SENDER) return false + + // Never group across the public/private boundary or between different channels. + if (previous.isPrivate != current.isPrivate) return false + if (previous.channel != current.channel) return false + + if (!isSameSender(previous, current)) return false + + val elapsed = current.timestamp.time - previous.timestamp.time + return elapsed in 0..GROUPING_WINDOW_MS + } + + /** + * Identity comparison. Peer IDs are authoritative when both messages carry one, because two + * different peers can share a nickname. Falls back to the display name (which includes the + * `#abcd` suffix) when peer IDs are unavailable, e.g. for locally injected messages. + */ + private fun isSameSender(previous: BitchatMessage, current: BitchatMessage): Boolean { + val previousPeerID = previous.senderPeerID + val currentPeerID = current.senderPeerID + return if (previousPeerID != null && currentPeerID != null) { + previousPeerID.equals(currentPeerID, ignoreCase = true) + } else { + previous.sender == current.sender + } + } + + /** Top padding for a message given whether it continues the previous author's run. */ + fun topSpacingFor(isGrouped: Boolean, isFirstInList: Boolean): Dp = when { + isFirstInList -> 0.dp + isGrouped -> GROUPED_SPACING + else -> NEW_GROUP_SPACING + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt b/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt index 275dbaf8..2caff0fe 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt index 69e84ff5..f15ada44 100644 --- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -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") } /** diff --git a/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt b/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt new file mode 100644 index 00000000..47dd5629 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt @@ -0,0 +1,71 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import java.util.Locale + +/** + * Canonical, presentation-neutral identity used to derive a peer hue. + * + * Callers cannot construct arbitrary color seeds. Every identity is normalized and namespaced + * here so the same mesh or Nostr user resolves to the same color on every surface. + */ +@JvmInline +value class PeerIdentity private constructor(internal val stableKey: String) { + companion object { + fun mesh(peerID: String): PeerIdentity = + PeerIdentity("noise:${normalize(peerID)}") + + /** + * Preserve the established geohash-chat color mapping while accepting a full public key. + * + * Older chat rendering hashed `nostr:nostr:`. Keeping that visual + * key here avoids recoloring existing conversations; the important change is that every + * surface now derives it from the same canonical Nostr identity. + */ + fun nostr(pubkeyHex: String): PeerIdentity { + val normalized = normalizeNostrIdentifier(pubkeyHex) + return PeerIdentity("nostr:nostr:${normalized.take(8)}") + } + + /** + * Last-resort identity for legacy messages and mentions that carry no stable peer ID. + */ + fun nickname(nickname: String): PeerIdentity = + PeerIdentity(normalize(nickname)) + + private fun normalize(value: String): String = + value.trim().lowercase(Locale.ROOT) + + private fun normalizeNostrIdentifier(value: String): String { + var normalized = normalize(value) + while (normalized.startsWith("nostr:") || normalized.startsWith("nostr_")) { + normalized = normalized.removePrefix("nostr:").removePrefix("nostr_") + } + return normalized + } + } +} + +/** + * Resolve the canonical identity attached to a rendered message. + * + * Full Nostr keys take precedence over routing aliases such as `nostr_abcd…`; aliases remain a + * compatibility fallback for messages saved by older app versions. + */ +fun peerIdentityForMessage(message: BitchatMessage): PeerIdentity { + message.senderNostrPubkey?.takeIf { it.isNotBlank() }?.let { + return PeerIdentity.nostr(it) + } + + val senderPeerID = message.senderPeerID + return when { + senderPeerID?.startsWith("nostr:", ignoreCase = true) == true || + senderPeerID?.startsWith("nostr_", ignoreCase = true) == true -> { + PeerIdentity.nostr(senderPeerID) + } + senderPeerID?.length == 16 || senderPeerID?.length == 64 -> { + PeerIdentity.mesh(senderPeerID) + } + else -> PeerIdentity.nickname(message.sender) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt b/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt deleted file mode 100644 index 883b2b45..00000000 --- a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.bitchat.android.ui - -import androidx.compose.animation.core.* -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Security -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.bitchat.android.nostr.NostrProofOfWork -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bitchat.android.R -import com.bitchat.android.nostr.PoWPreferenceManager - -/** - * Shows the current Proof of Work status and settings - */ -@Composable -fun PoWStatusIndicator( - modifier: Modifier = Modifier, - style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT -) { - val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle() - val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle() - val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle() - val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - - if (!powEnabled) return - - when (style) { - PoWIndicatorStyle.COMPACT -> { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // PoW icon with animation if mining - if (isMining) { - val rotation by rememberInfiniteTransition(label = "pow-rotation").animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "pow-icon-rotation" - ) - - Icon( - imageVector = Icons.Filled.Security, - contentDescription = stringResource(R.string.cd_mining_pow), - tint = Color(0xFFFF9500), // Orange for mining - modifier = Modifier - .size(12.dp) - .graphicsLayer { rotationZ = rotation } - ) - } else { - Icon( - imageVector = Icons.Filled.Security, - contentDescription = stringResource(R.string.cd_pow_enabled), - tint = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), // Green when ready - modifier = Modifier.size(12.dp) - ) - } - } - } - - PoWIndicatorStyle.DETAILED -> { - Surface( - modifier = modifier, - color = colorScheme.surfaceVariant.copy(alpha = 0.3f), - shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // PoW icon - Icon( - imageVector = Icons.Filled.Security, - contentDescription = stringResource(R.string.cd_proof_of_work), - tint = if (isMining) Color(0xFFFF9500) else { - if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - }, - modifier = Modifier.size(14.dp) - ) - - // Status text - Text( - text = if (isMining) { - stringResource(R.string.pow_mining_ellipsis) - } else { - stringResource(R.string.pow_label_format, powDifficulty) - }, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = if (isMining) Color(0xFFFF9500) else { - colorScheme.onSurface.copy(alpha = 0.7f) - } - ) - - // Time estimate - if (!isMining && powDifficulty > 0) { - Text( - text = stringResource(R.string.pow_time_estimate, NostrProofOfWork.estimateMiningTime(powDifficulty)), - fontSize = 9.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f) - ) - } - } - } - } - } -} - -/** - * Style options for the PoW status indicator - */ -enum class PoWIndicatorStyle { - COMPACT, // Small icon + difficulty number - DETAILED // Icon + status text + time estimate -} diff --git a/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt b/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt new file mode 100644 index 00000000..01c9c6ca --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale + +/** + * Press feedback for the app's icon buttons. + * + * Terminal-style chrome has no elevation and no fills to lean on, so a Material ripple has almost + * nothing to show. A brief scale dip is legible on any background and reads as physical. Springs + * rather than tweens, so releasing overshoots very slightly instead of stopping dead. + */ +@Composable +fun rememberPressScale( + interactionSource: InteractionSource, + pressedScale: Float = 0.86f +): Float { + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) pressedScale else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "pressScale" + ) + return scale +} + +/** + * Convenience wrapper for the common case: a clickable that scales while held. + * + * Returns the modifier chain to apply, having disabled the default indication — the scale *is* the + * indication, and a ripple underneath it just muddies the edges of these small glyphs. + */ +@Composable +fun Modifier.pressScaleClickable( + onClick: () -> Unit, + enabled: Boolean = true, + onClickLabel: String? = null, + pressedScale: Float = 0.86f +): Modifier { + val interactionSource = remember { MutableInteractionSource() } + val scale = rememberPressScale(interactionSource, pressedScale) + return this + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + onClickLabel = onClickLabel, + onClick = onClick + ) + .scale(scale) +} diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index 2633f888..40da30be 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -1,9 +1,15 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.NoEncryption +import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material.icons.outlined.Warning as OutlinedWarning import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,13 +18,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Verified -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material.icons.outlined.NoEncryption -import androidx.compose.material.icons.outlined.Sync -import androidx.compose.material.icons.outlined.Warning as OutlinedWarning import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenu @@ -39,14 +38,15 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet private data class SecurityStatusInfo( @@ -69,9 +69,9 @@ fun SecurityVerificationSheet( val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() - val isDark = isSystemInDarkTheme() - val accent = if (isDark) Color.Green else Color(0xFF008000) - val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f) + val colorScheme = MaterialTheme.colorScheme + val accent = colorScheme.primary + val boxColor = colorScheme.surfaceVariant val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") } BitchatBottomSheet( @@ -92,7 +92,7 @@ fun SecurityVerificationSheet( if (peerID == null) { Text( text = stringResource(R.string.fingerprint_no_peer), - style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) } else { @@ -155,13 +155,14 @@ private fun SecurityVerificationHeader( Text( text = stringResource(R.string.security_verification_title), style = MaterialTheme.typography.titleSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), color = accent ) Spacer(modifier = Modifier.weight(1f)) - CloseButton(onClick = onClose) + val dismiss = LocalSheetDismiss.current + CloseButton(onClick = { dismiss?.invoke() ?: onClose() }) } } @@ -219,7 +220,7 @@ private fun SecurityStatusCard( Text( text = displayName, style = MaterialTheme.typography.titleMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), color = accent @@ -227,7 +228,7 @@ private fun SecurityStatusCard( Text( text = statusInfo.text, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = accent.copy(alpha = 0.8f) ) @@ -257,7 +258,7 @@ private fun SecurityVerificationActions( ) { Text( text = stringResource(R.string.fingerprint_start_handshake), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -273,7 +274,7 @@ private fun SecurityVerificationActions( Text( text = stringResource(R.string.fingerprint_verified_message), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = accent.copy(alpha = 0.7f), modifier = Modifier.fillMaxWidth(), @@ -289,7 +290,7 @@ private fun SecurityVerificationActions( ) { Text( text = stringResource(R.string.verify_remove), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -303,7 +304,7 @@ private fun SecurityVerificationActions( Text( text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = accent.copy(alpha = 0.7f), modifier = Modifier.fillMaxWidth(), @@ -320,7 +321,7 @@ private fun SecurityVerificationActions( ) { Text( text = stringResource(R.string.fingerprint_mark_verified), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -349,7 +350,7 @@ private fun VerificationStatusRow( Text( text = text, style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), color = textTint @@ -375,7 +376,7 @@ private fun FingerprintBlock( Text( text = title, style = MaterialTheme.typography.labelSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), color = accent.copy(alpha = 0.8f) @@ -385,7 +386,7 @@ private fun FingerprintBlock( Text( text = formatFingerprint(fingerprint), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp ), color = accent, @@ -417,7 +418,7 @@ private fun FingerprintBlock( } else { Text( text = stringResource(R.string.fingerprint_pending), - style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily), color = Color(0xFFFF9500), modifier = Modifier.padding(16.dp) ) diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index 97392eae..a84c3c94 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.QrCodeScanner import android.graphics.Bitmap import android.os.Handler import android.os.Looper @@ -17,7 +19,6 @@ import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,8 +32,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.QrCodeScanner import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -60,7 +59,6 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -70,8 +68,10 @@ import androidx.core.graphics.createBitmap import androidx.core.graphics.set import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.services.VerificationService import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -98,8 +98,7 @@ fun VerificationSheet( ) { if (!isPresented) return - val isDark = isSystemInDarkTheme() - val accent = if (isDark) Color.Green else Color(0xFF008000) + val accent = MaterialTheme.colorScheme.primary var selectedTab by remember { mutableStateOf(0) } // 0 = My Code, 1 = Scan val nickname by viewModel.nickname.collectAsStateWithLifecycle() @@ -144,7 +143,7 @@ fun VerificationSheet( text = { Text( text = "My QR", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp ) } @@ -155,7 +154,7 @@ fun VerificationSheet( text = { Text( text = "Scan", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp ) } @@ -208,7 +207,7 @@ fun VerificationSheet( ) { Text( text = stringResource(R.string.verify_remove), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -232,10 +231,11 @@ private fun VerificationHeader( Text( text = stringResource(R.string.verify_title).uppercase(), fontSize = 14.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = accent ) - CloseButton(onClick = onClose) + val dismiss = LocalSheetDismiss.current + CloseButton(onClick = { dismiss?.invoke() ?: onClose() }) } } @@ -258,7 +258,7 @@ private fun MyQrTabContent( Text( text = stringResource(R.string.verify_my_qr_title), style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = accent ) @@ -283,7 +283,7 @@ private fun MyQrTabContent( ) { Text( text = stringResource(R.string.verify_qr_unavailable), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = Color.Black.copy(alpha = 0.6f) ) @@ -296,7 +296,7 @@ private fun MyQrTabContent( Text( text = nickname, style = MaterialTheme.typography.headlineSmall, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center ) @@ -307,7 +307,7 @@ private fun MyQrTabContent( Text( text = stringResource(R.string.app_name).lowercase(), style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), textAlign = TextAlign.Center ) @@ -355,7 +355,7 @@ private fun ScanTabContent( Text( text = stringResource(R.string.verify_scan_prompt_friend), color = Color.White, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, modifier = Modifier .align(Alignment.BottomCenter) @@ -386,7 +386,7 @@ private fun ScanTabContent( Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.verify_camera_permission), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurface ) @@ -397,7 +397,7 @@ private fun ScanTabContent( ) { Text( text = stringResource(R.string.verify_request_camera), - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index c61f2f9a..c9922bf1 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -1,24 +1,18 @@ package com.bitchat.android.ui -import android.Manifest -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic +import android.Manifest +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.unit.dp import com.bitchat.android.features.voice.VoiceRecorder import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus @@ -27,21 +21,59 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** + * How long the button must be held before a recording starts. + * + * Push-to-talk should require intent. Firing on raw pointer-down meant a stray touch started a + * recording, and it also made the control impossible to defend against pointer events it should + * never have seen (see [ArmDelayMs]). + */ +private const val HoldToRecordMs = 220L + +/** + * How long the button ignores presses after entering composition. + * + * The action cluster swaps send out for camera+microphone the instant the field is cleared, which + * puts the microphone exactly where the send button was a frame earlier. Tapping send quickly + * could hand the microphone a pointer-down whose matching pointer-up had already been delivered + * to the send button that no longer exists — leaving the gesture waiting for a release that will + * never come, stuck in "recording" until the composable is disposed. Refusing presses until the + * swap animation has settled removes that whole class of failure. + */ +private const val ArmDelayMs = 350L + +/** Hard cap on a single recording. */ +private const val MaxRecordingMs = 10_000L + +/** Tail kept after release so the last syllable is not clipped. */ +private const val ReleaseTailMs = 500L @OptIn(ExperimentalPermissionsApi::class) @Composable fun VoiceRecordButton( modifier: Modifier = Modifier, - backgroundColor: Color, + /** + * Recording state as the composer sees it. Drives the active tint so the button and the + * pill's border change together instead of one lagging the other. + */ + isRecording: Boolean = false, onStart: () -> Unit, onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit, - onFinish: (filePath: String) -> Unit + onFinish: (filePath: String) -> Unit, + /** + * Invoked whenever a recording ends without producing a file — permission denied, recorder + * failure, or the button being torn down mid-capture. The caller needs this to clear its own + * recording state; without it a failed capture left the composer stuck in recording mode. + */ + onCancel: () -> Unit = {} ) { val context = LocalContext.current val haptic = LocalHapticFeedback.current val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO) - var isRecording by remember { mutableStateOf(false) } + var isCapturing by remember { mutableStateOf(false) } var recorder by remember { mutableStateOf(null) } var recordedFilePath by remember { mutableStateOf(null) } var recordingStart by remember { mutableStateOf(0L) } @@ -53,71 +85,127 @@ fun VoiceRecordButton( val latestOnStart = rememberUpdatedState(onStart) val latestOnAmplitude = rememberUpdatedState(onAmplitude) val latestOnFinish = rememberUpdatedState(onFinish) + val latestOnCancel = rememberUpdatedState(onCancel) - Box( + // Set when this instance was composed, so presses inherited from whatever occupied this spot + // beforehand can be rejected. + val composedAt = remember { System.currentTimeMillis() } + + fun buzz() { + try { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } catch (_: Exception) { + } + } + + // Last line of defence: if the button is removed while capturing — the cluster swapping to + // send, the sheet closing, the screen going away — release the recorder and tell the caller, + // so nothing is left holding the microphone or showing a recording UI. + DisposableEffect(Unit) { + onDispose { + ampJob?.cancel() + ampJob = null + if (isCapturing) { + isCapturing = false + runCatching { recorder?.stop() } + recorder = null + recordedFilePath = null + latestOnCancel.value() + } + } + } + + // Same disc, same sizing and the same press feedback as the camera and send buttons. + ComposerActionSurface( + isActive = isRecording || isCapturing, + isPressed = isCapturing, modifier = modifier - .size(32.dp) - .background(backgroundColor, CircleShape) .pointerInput(Unit) { detectTapGestures( onPress = { - if (!isRecording) { - if (micPermission.status !is PermissionStatus.Granted) { - micPermission.launchPermissionRequest() - return@detectTapGestures - } - val rec = VoiceRecorder(context) - val f = rec.start() - recorder = rec - isRecording = f != null - recordedFilePath = f?.absolutePath - recordingStart = System.currentTimeMillis() - if (isRecording) { - latestOnStart.value() - // Haptic "knock" when recording starts - try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {} - // Start amplitude polling loop - ampJob?.cancel() - ampJob = scope.launch { - while (isActive && isRecording) { - val amp = recorder?.pollAmplitude() ?: 0 - val elapsedMs = (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L) - latestOnAmplitude.value(amp, elapsedMs) - // Auto-stop after 10 seconds - if (elapsedMs >= 10_000 && isRecording) { - val file = recorder?.stop() - isRecording = false - recorder = null - val path = file?.absolutePath - if (!path.isNullOrBlank()) { - // Haptic "knock" on auto stop - try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {} - latestOnFinish.value(path) - } - break - } - delay(80) + // Guard 1: ignore anything arriving before the swap animation settled. + if (System.currentTimeMillis() - composedAt < ArmDelayMs) { + return@detectTapGestures + } + // Guard 2: never start a second capture on top of a live one. + if (isCapturing) return@detectTapGestures + + if (micPermission.status !is PermissionStatus.Granted) { + micPermission.launchPermissionRequest() + return@detectTapGestures + } + + // Guard 3: require a deliberate hold. `tryAwaitRelease` returns true on + // release and false on cancellation; either way the press was not a hold, + // so nothing should happen. Only a timeout means the finger is still down. + val stillHeld = withTimeoutOrNull(HoldToRecordMs) { + tryAwaitRelease() + } == null + if (!stillHeld) return@detectTapGestures + + val rec = VoiceRecorder(context) + val startedFile = rec.start() + if (startedFile == null) { + // Recorder refused to start; make sure the caller does not sit in a + // recording state that never began. + runCatching { rec.stop() } + latestOnCancel.value() + return@detectTapGestures + } + + recorder = rec + recordedFilePath = startedFile.absolutePath + recordingStart = System.currentTimeMillis() + isCapturing = true + latestOnStart.value() + buzz() + + ampJob?.cancel() + ampJob = scope.launch { + while (isActive && isCapturing) { + val amp = recorder?.pollAmplitude() ?: 0 + val elapsed = + (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L) + latestOnAmplitude.value(amp, elapsed) + + if (elapsed >= MaxRecordingMs && isCapturing) { + val file = recorder?.stop() + isCapturing = false + recorder = null + val path = file?.absolutePath ?: recordedFilePath + recordedFilePath = null + buzz() + // Always report the outcome, even when the file is unusable, + // or the caller stays stuck showing the waveform. + if (!path.isNullOrBlank()) { + latestOnFinish.value(path) + } else { + latestOnCancel.value() } + break } + delay(80) } } + try { - awaitRelease() + tryAwaitRelease() } finally { - if (isRecording) { - // Extend recording for 500ms after release to avoid clipping - delay(500) + if (isCapturing) { + // Keep going briefly past the release so the tail is not clipped. + delay(ReleaseTailMs) } - if (isRecording) { + if (isCapturing) { val file = recorder?.stop() - isRecording = false + isCapturing = false recorder = null - val path = (file?.absolutePath ?: recordedFilePath) + val path = file?.absolutePath ?: recordedFilePath recordedFilePath = null + buzz() if (!path.isNullOrBlank()) { - // Haptic "knock" when recording stops - try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {} latestOnFinish.value(path) + } else { + latestOnCancel.value() } } ampJob?.cancel() @@ -125,14 +213,13 @@ fun VoiceRecordButton( } } ) - }, - contentAlignment = Alignment.Center - ) { + } + ) { tint -> Icon( imageVector = Icons.Filled.Mic, contentDescription = stringResource(com.bitchat.android.R.string.cd_record_voice), - tint = Color.Black, - modifier = Modifier.size(20.dp) + tint = tint, + modifier = Modifier.size(ComposerIconSize) ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 32427c44..9c8c48b5 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -3,6 +3,14 @@ package com.bitchat.android.ui.debug import android.content.ClipData import android.content.ClipboardManager import android.widget.Toast +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.WifiTethering +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.SettingsEthernet import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -12,25 +20,17 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bluetooth -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material.icons.filled.WifiTethering -import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material.icons.filled.Devices -import androidx.compose.material.icons.filled.PowerSettingsNew -import androidx.compose.material.icons.filled.SettingsEthernet import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.draw.rotate +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.services.meshgraph.MeshGraphService import kotlinx.coroutines.launch @@ -67,13 +67,13 @@ fun MeshTopologySection( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93)) - Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Mesh topology", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } val nodes = snapshot.nodes val edges = snapshot.edges val empty = nodes.isEmpty() if (empty) { - Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("No gossip yet", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { ForceDirectedMeshGraph( nodes = nodes, @@ -94,10 +94,10 @@ fun MeshTopologySection( verticalArrangement = Arrangement.spacedBy(4.dp) ) { nodes.forEach { node -> - val label = "${node.peerID.take(8)} • ${node.nickname ?: "unknown"}" + val label = "${node.peerID.take(8)} • ${node.nickname ?: "Unknown"}" Text( text = label, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.85f) ) @@ -128,7 +128,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF5856D6)) Text( "Distribution info", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium ) @@ -137,7 +137,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI if (info == null) { Text( "Inspecting installed package…", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -170,7 +170,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI }, contentPadding = PaddingValues(horizontal = 0.dp) ) { - Text("Copy certificate fingerprint", fontFamily = FontFamily.Monospace) + Text("Copy certificate fingerprint", fontFamily = BitchatFontFamily) } } } @@ -184,13 +184,13 @@ private fun DistributionInfoRow(label: String, value: String) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( label, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.55f) ) Text( value, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.9f) ) @@ -347,7 +347,7 @@ fun DebugSettingsSheet( item { Text( text = stringResource(R.string.debug_tools_desc), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -361,13 +361,13 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF00C851)) - Text(stringResource(R.string.debug_verbose_logging), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_verbose_logging), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) Switch(checked = verboseLogging, onCheckedChange = { manager.setVerboseLoggingEnabled(it) }) } Text( stringResource(R.string.debug_verbose_hint), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -392,10 +392,10 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) - Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_gatt_server), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text(stringResource(R.string.debug_gatt_server), fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) Switch(checked = gattServerEnabled, onCheckedChange = { manager.setGattServerEnabled(it) scope.launch { @@ -404,9 +404,9 @@ fun DebugSettingsSheet( }) } val serverCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_SERVER } - Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_max_server), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp)) + Text(stringResource(R.string.debug_max_server), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp)) Slider( value = maxServer.toFloat(), onValueChange = { manager.setMaxServerConnections(it.toInt().coerceAtLeast(1)) }, @@ -415,7 +415,7 @@ fun DebugSettingsSheet( ) } Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_gatt_client), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text(stringResource(R.string.debug_gatt_client), fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) Switch(checked = gattClientEnabled, onCheckedChange = { manager.setGattClientEnabled(it) scope.launch { @@ -424,9 +424,9 @@ fun DebugSettingsSheet( }) } val clientCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_CLIENT } - Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_max_client), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp)) + Text(stringResource(R.string.debug_max_client), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp)) Slider( value = maxClient.toFloat(), onValueChange = { manager.setMaxClientConnections(it.toInt().coerceAtLeast(1)) }, @@ -435,9 +435,9 @@ fun DebugSettingsSheet( ) } val overallCount = connectedDevices.size - Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_max_overall), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp)) + Text(stringResource(R.string.debug_max_overall), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp)) Slider( value = maxOverall.toFloat(), onValueChange = { manager.setMaxConnectionsOverall(it.toInt().coerceAtLeast(1)) }, @@ -447,7 +447,7 @@ fun DebugSettingsSheet( } Text( stringResource(R.string.debug_roles_hint), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -461,12 +461,12 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50)) - Text("Transports", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Transports", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) Spacer(Modifier.width(8.dp)) - Text("BLE", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text("BLE", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) Switch(checked = bleEnabled, onCheckedChange = { manager.setBleEnabled(it) }) @@ -474,7 +474,7 @@ fun DebugSettingsSheet( Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Filled.Wifi, contentDescription = null, tint = Color(0xFF9C27B0)) Spacer(Modifier.width(8.dp)) - Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text("Wi‑Fi Aware", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) val wifiSwitchEnabled = wifiAwareSupported Text( when { @@ -482,7 +482,7 @@ fun DebugSettingsSheet( wifiAwareAvailable -> "available" else -> "unavailable" }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -497,7 +497,7 @@ fun DebugSettingsSheet( } Row(verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.width(24.dp)) - Text("Wi‑Fi Aware verbose", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text("Wi‑Fi Aware verbose logging", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) Switch(checked = wifiAwareVerbose, onCheckedChange = { manager.setWifiAwareVerbose(it) }) } } @@ -512,7 +512,7 @@ fun DebugSettingsSheet( Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500)) - Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_packet_relay), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) Switch(checked = packetRelayed, onCheckedChange = { manager.setPacketRelayEnabled(it) }) } @@ -648,10 +648,10 @@ fun DebugSettingsSheet( // Helper functions moved to top-level composable below to avoid scope issues // Render two blocks: Incoming and Outgoing - Text("Incoming", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text("Incoming", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Text( "${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}", - fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) DrawGraphBlock( title = "Incoming", @@ -704,10 +704,10 @@ fun DebugSettingsSheet( if (graphMode != GraphMode.OVERALL && stackedKeysIncoming.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ } Spacer(Modifier.height(8.dp)) - Text("Outgoing", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text("Outgoing", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Text( "${relayStats.lastSecondOutgoing}/s • ${relayStats.lastMinuteOutgoing}/m • ${relayStats.last15MinuteOutgoing}/15m • total ${relayStats.totalOutgoingCount}", - fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) DrawGraphBlock( title = "Outgoing", @@ -770,7 +770,7 @@ fun DebugSettingsSheet( val running by com.bitchat.android.wifiaware.WifiAwareController.running.collectAsState() Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.WifiTethering, contentDescription = null, tint = Color(0xFF9C27B0)) - Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Wi‑Fi Aware", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) val wifiStatusText = when { !wifiAwareSupported -> "unsupported" @@ -778,12 +778,12 @@ fun DebugSettingsSheet( !wifiAwareAvailable -> "unavailable" else -> "stopped" } - Text(wifiStatusText, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(wifiStatusText, fontFamily = BitchatFontFamily, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } if (!wifiAwareSupported) { Text( wifiAwareSupportStatus?.reason ?: "Wi-Fi Aware is not supported on this device", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -801,21 +801,21 @@ fun DebugSettingsSheet( label = { Text("Announce") } ) } - Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = BitchatFontFamily, fontSize = 12.sp) if (wifiAwareDiscovered.isEmpty()) { - Text("No discoveries yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("No discoveries yet", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { wifiAwareDiscovered.entries.take(50).forEach { (peer, nick) -> - Text("• ${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("• ${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = BitchatFontFamily, fontSize = 12.sp) } } Divider() - Text("Connected: ${wifiAwareConnected.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("Connected: ${wifiAwareConnected.size}", fontFamily = BitchatFontFamily, fontSize = 12.sp) if (wifiAwareConnected.isEmpty()) { - Text("No active sockets", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("No active sockets", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { wifiAwareConnected.entries.take(50).forEach { (peer, ip) -> - Text("• ${peer.take(8)}… @ $ip", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("• ${peer.take(8)}… @ $ip", fontFamily = BitchatFontFamily, fontSize = 12.sp) } } } @@ -828,17 +828,17 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF9C27B0)) - Text("sync settings", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Sync settings", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } - Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Slider(value = seenCapacity.toFloat(), onValueChange = { manager.setSeenPacketCapacity(it.toInt()) }, valueRange = 10f..1000f, steps = 99) - Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Slider(value = gcsMaxBytes.toFloat(), onValueChange = { manager.setGcsMaxBytes(it.toInt()) }, valueRange = 128f..1024f, steps = 0) - Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Slider(value = gcsFpr.toFloat(), onValueChange = { manager.setGcsFprPercent(it.toDouble()) }, valueRange = 0.1f..5.0f, steps = 49) val p = remember(gcsFpr) { com.bitchat.android.sync.GCSFilter.deriveP(gcsFpr / 100.0) } val nmax = remember(gcsFpr, gcsMaxBytes) { com.bitchat.android.sync.GCSFilter.estimateMaxElementsForSize(gcsMaxBytes, p) } - Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } } } @@ -849,22 +849,22 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50)) - Text(stringResource(R.string.debug_connected_devices), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_connected_devices), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } val localAddr = remember { meshService.connectionManager.getLocalAdapterAddress() } - Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) if (connectedDevices.isEmpty()) { - Text("none", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("None", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { connectedDevices.forEach { dev -> Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) { Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { - Text((dev.peerID ?: stringResource(R.string.unknown)) + " • ${dev.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text((dev.peerID ?: stringResource(R.string.unknown)) + " • ${dev.deviceAddress}", fontFamily = BitchatFontFamily, fontSize = 12.sp) val roleLabel = if (dev.connectionType == ConnectionType.GATT_SERVER) stringResource(R.string.debug_role_server) else stringResource(R.string.debug_role_client) - Text("${dev.nickname ?: ""} • " + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + " • $roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text("${dev.nickname ?: ""} • " + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + " • $roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } - Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable { + Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = BitchatFontFamily, modifier = Modifier.clickable { meshService.connectionManager.disconnectAddress(dev.deviceAddress) }) } @@ -881,19 +881,19 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) - Text(stringResource(R.string.debug_recent_scan_results), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_recent_scan_results), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } if (scanResults.isEmpty()) { - Text(stringResource(R.string.debug_none), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text(stringResource(R.string.debug_none), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { scanResults.forEach { res -> Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) { Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { - Text((res.peerID ?: stringResource(R.string.unknown)) + " • ${res.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) - Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text((res.peerID ?: stringResource(R.string.unknown)) + " • ${res.deviceAddress}", fontFamily = BitchatFontFamily, fontSize = 12.sp) + Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } - Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable { + Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = BitchatFontFamily, modifier = Modifier.clickable { meshService.connectionManager.connectToAddress(res.deviceAddress) }) } @@ -910,15 +910,15 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500)) - Text(stringResource(R.string.debug_debug_console), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_debug_console), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) - Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable { + Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = BitchatFontFamily, modifier = Modifier.clickable { manager.clearDebugMessages() }) } Column(Modifier.heightIn(max = 260.dp).background(colorScheme.surface.copy(alpha = 0.5f)).padding(8.dp)) { debugMessages.takeLast(100).reversed().forEach { msg -> - Text("${msg.content}", fontFamily = FontFamily.Monospace, fontSize = 11.sp) + Text("${msg.content}", fontFamily = BitchatFontFamily, fontSize = 11.sp) } } } @@ -1039,7 +1039,7 @@ private fun DrawGraphBlock( Box(Modifier.width(leftGutter).fillMaxHeight()) { Text( "p/s", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f) @@ -1053,14 +1053,14 @@ private fun DrawGraphBlock( } Text( topLabel, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp) ) Text( "0", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp) @@ -1085,8 +1085,8 @@ private fun DrawGraphBlock( ) { Box(Modifier.size(10.dp).background(swatchColor, RoundedCornerShape(2.dp))) Column { - Text(legendTitleFor(key), fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f)) - Text(legendMetricsFor(key), fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f)) + Text(legendTitleFor(key), fontFamily = BitchatFontFamily, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f)) + Text(legendMetricsFor(key), fontFamily = BitchatFontFamily, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f)) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt index 44c7ef55..3254c565 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt @@ -1,14 +1,14 @@ package com.bitchat.android.ui.debug +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.Bluetooth import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material.icons.outlined.Bluetooth import androidx.compose.material3.Icon import androidx.compose.runtime.* import androidx.compose.ui.Modifier diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 7c0a1ac6..c646d570 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -1,11 +1,11 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -13,14 +13,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.component.text.AnnotatedClickableText import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import androidx.compose.material3.ColorScheme +import com.bitchat.android.ui.theme.LocalBitchatPalette import java.text.SimpleDateFormat @Composable @@ -33,8 +34,10 @@ fun AudioMessageItem( onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showSender: Boolean = true ) { + val palette = LocalBitchatPalette.current val path = message.content.trim() // Derive sending progress if applicable val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) { @@ -50,9 +53,11 @@ fun AudioMessageItem( val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter + myPeerID = meshService.myPeerID, + palette = palette, + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + includeSender = showSender ) val haptic = LocalHapticFeedback.current AnnotatedClickableText( @@ -68,7 +73,7 @@ fun AudioMessageItem( } }, onLongPress = { onMessageLongPress?.invoke(message) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface, ) diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt index ca57931b..7a289686 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -12,8 +14,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Description import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon diff --git a/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt index 16d92d7a..bd5f30e0 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt @@ -1,11 +1,11 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Attachment import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Attachment import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt b/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt index 2f40dec0..51e65108 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -11,8 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Description import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.dp import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import kotlinx.coroutines.delay @@ -96,7 +97,7 @@ fun FileSendingAnimation( androidx.compose.material3.Text( text = revealedText, style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = Color.White ), modifier = Modifier.padding(end = 2.dp) @@ -107,7 +108,7 @@ fun FileSendingAnimation( androidx.compose.material3.Text( text = stringResource(R.string.underscore), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = Color.White ) ) @@ -149,7 +150,7 @@ private fun FileProgressBars( androidx.compose.material3.Text( text = progressString, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = Color(0xFF00FF7F) // Matrix green ), modifier = modifier diff --git a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt index 2964f9c0..3544b637 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt @@ -1,5 +1,8 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Download import android.content.ContentValues import android.os.Build import android.provider.MediaStore @@ -10,9 +13,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Download import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import java.io.File @@ -101,7 +102,7 @@ fun FullScreenImageViewer(imagePaths: List, initialIndex: Int = 0, onClo text = stringResource(R.string.image_counter, (pagerState.currentPage ?: 0) + 1, imagePaths.size), color = Color.White, fontSize = 14.sp, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 35b0bbea..5d9908fc 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -1,12 +1,12 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.* @@ -21,12 +21,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp -import androidx.compose.ui.text.font.FontFamily +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import androidx.compose.material3.ColorScheme import com.bitchat.android.core.ui.component.text.AnnotatedClickableText +import com.bitchat.android.ui.theme.LocalBitchatPalette import java.text.SimpleDateFormat @Composable @@ -41,16 +42,20 @@ fun ImageMessageItem( onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, onImageClick: ((String, List, Int) -> Unit)?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showSender: Boolean = true ) { + val palette = LocalBitchatPalette.current val path = message.content.trim() Column(modifier = modifier.fillMaxWidth()) { val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter + myPeerID = meshService.myPeerID, + palette = palette, + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + includeSender = showSender ) val haptic = LocalHapticFeedback.current AnnotatedClickableText( @@ -66,7 +71,7 @@ fun ImageMessageItem( } }, onLongPress = { onMessageLongPress?.invoke(message) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface, ) @@ -138,7 +143,7 @@ fun ImageMessageItem( } } } else { - Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray) + Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = BitchatFontFamily, color = Color.Gray) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt index eb1f5d86..97ee01a4 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt @@ -1,5 +1,9 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.Photo +import androidx.compose.material.icons.filled.PhotoCamera import android.Manifest import android.content.pm.PackageManager import android.net.Uri @@ -7,23 +11,20 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Camera -import androidx.compose.material.icons.filled.Photo -import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material3.Icon import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import com.bitchat.android.features.media.ImageUtils +import com.bitchat.android.ui.ComposerActionSurface +import com.bitchat.android.ui.ComposerIconSize import java.io.File @OptIn(ExperimentalFoundationApi::class) @@ -86,26 +87,31 @@ fun ImagePickerButton( } } - Box( - modifier = modifier - .size(32.dp) - .combinedClickable( - onClick = { imagePicker.launch("image/*") }, - onLongClick = { - if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { - startCameraCapture() - } else { - permissionLauncher.launch(Manifest.permission.CAMERA) - } + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + // Shares the composer's button treatment so camera, microphone and send read as one set. + ComposerActionSurface( + isActive = false, + isPressed = isPressed, + modifier = modifier.combinedClickable( + interactionSource = interactionSource, + indication = null, + onClick = { imagePicker.launch("image/*") }, + onLongClick = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + startCameraCapture() + } else { + permissionLauncher.launch(Manifest.permission.CAMERA) } - ), - contentAlignment = Alignment.Center - ) { + } + ) + ) { tint -> Icon( imageVector = Icons.Filled.PhotoCamera, contentDescription = stringResource(com.bitchat.android.R.string.pick_image), - tint = Color.Gray, - modifier = Modifier.size(20.dp) + tint = tint, + modifier = Modifier.size(ComposerIconSize) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt b/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt index 47883499..6909a52d 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt @@ -1,5 +1,8 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Description import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -9,9 +12,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Description import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt index 67719d87..10109194 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt @@ -1,13 +1,14 @@ package com.bitchat.android.ui.media +import com.bitchat.android.ui.theme.BitchatFontFamily +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow import android.media.MediaPlayer import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -18,7 +19,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.text.font.FontFamily @Composable fun VoiceNotePlayer( @@ -110,7 +110,7 @@ fun VoiceNotePlayer( onSeek = seekTo ) val durText = if (durationMs > 0) String.format("%02d:%02d", (durationMs / 1000) / 60, (durationMs / 1000) % 60) else "--:--" - Text(text = durText, fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text(text = durText, fontFamily = BitchatFontFamily, fontSize = 12.sp) } } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt new file mode 100644 index 00000000..96ef57d4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -0,0 +1,91 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +/** + * Bitchat-specific color tokens that do not have a faithful Material 3 semantic role. + * + * Standard backgrounds, surfaces, text, outlines, primary/secondary accents, and errors belong + * to [androidx.compose.material3.MaterialTheme.colorScheme]. Keeping only the extra app semantics + * here lets Material components inherit correct defaults without losing Bitchat's identity. + */ +@Immutable +data class BitchatPalette( + // MARK: - Form controls + /** + * Resting border for text inputs. Deliberately a neutral grey rather than the green-tinted + * Material outline: the composer is the one surface the user stares at while typing. + */ + val inputOutline: Color, + /** Border for a focused text input. A step brighter, still neutral. */ + val inputOutlineFocused: Color, + /** + * Fill for text inputs. Near-black / near-white and completely untinted, for the same reason + * as [inputOutline] — and because the composer sits on top of a green-tinted scrim, so any + * tint of its own compounds into something muddy. + */ + val inputSurface: Color, + /** Fill for a focused text input. A barely perceptible lift. */ + val inputSurfaceFocused: Color, + /** Resting disc behind the composer's action glyphs. Neutral grey. */ + val inputButton: Color, + + // MARK: - Extra semantics + /** Timestamps, placeholders, section labels, disabled states. */ + val textTertiary: Color, + /** Self, mentions targeting you, unread DMs. */ + val accentOrange: Color, + /** Nostr reachability. */ + val accentPurple: Color, + + // MARK: - Deterministic peer colors + /** Chroma applied after deriving a peer's stable hue. */ + val peerColorSaturation: Float, + /** Brightness applied after deriving a peer's stable hue. */ + val peerColorValue: Float, +) + +val DarkBitchatPalette = BitchatPalette( + inputOutline = Color(0xFF333635), + inputOutlineFocused = Color(0xFF5A605D), + inputSurface = Color(0xFF0B0B0B), + inputSurfaceFocused = Color(0xFF151515), + inputButton = Color(0xFF1E1E1E), + textTertiary = Color(0xFF6B776B), + accentOrange = Color(0xFFFF9F0A), + accentPurple = Color(0xFFBF5AF2), + peerColorSaturation = 1f, + peerColorValue = 1f, +) + +val LightBitchatPalette = BitchatPalette( + inputOutline = Color(0xFFCFD3D1), + inputOutlineFocused = Color(0xFF8E9490), + inputSurface = Color(0xFFFAFAFA), + inputSurfaceFocused = Color(0xFFF2F2F2), + inputButton = Color(0xFFE8E8E8), + textTertiary = Color(0xFF757F75), + accentOrange = Color(0xFFFF9500), + accentPurple = Color(0xFFAF52DE), + peerColorSaturation = 0.85f, + peerColorValue = 0.45f, +) + +val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette } + +/** + * Motion tokens. The redesign leans on short, snappy transitions: long durations read as + * sluggish on a chat surface where the user is scanning quickly. + */ +object BitchatMotion { + /** Icon tints, text colors, small fills. */ + const val QUICK_MS = 120 + + /** Tab indicators, pill growth, chip reveals. */ + const val STANDARD_MS = 180 + + /** Sheet-level fades and scroll-driven top bars. */ + const val EMPHASIZED_MS = 240 +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt new file mode 100644 index 00000000..cdd4d418 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitchat.android.R + +/** + * The bundled Geist Mono family used throughout the app. + * + * Keeping the fonts in the APK preserves offline behavior and guarantees that the design-spec + * metrics do not depend on which monospace family a device happens to provide. + */ +internal val BitchatFontFamily = FontFamily( + Font(R.font.geist_mono_regular, FontWeight.Normal), + Font(R.font.geist_mono_medium, FontWeight.Medium), + Font(R.font.geist_mono_semibold, FontWeight.SemiBold), + Font(R.font.geist_mono_bold, FontWeight.Bold), +) + +/** Exact typography, spacing, and opacity values exported for the chat transcript. */ +internal object ChatVisualTokens { + val MessageBodyFontSize: TextUnit = 14.sp + val MessageBodyLineHeight: TextUnit = 20.sp + val SenderFontSize: TextUnit = 14.sp + val SenderLineHeight: TextUnit = 16.sp + val SystemActionFontSize: TextUnit = 12.sp + val SystemActionLineHeight: TextUnit = 16.sp + val SystemTimeFontSize: TextUnit = 10.sp + + val MessageItemSpacing: Dp = 8.dp + val SenderTopPadding: Dp = 8.dp + val SenderToBodySpacing: Dp = 4.dp + + const val SenderSuffixAlpha: Float = 0.60f + const val HighlightAlpha: Float = 0.20f + const val MutedTextAlpha: Float = 0.50f + + val MessageBodyStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Normal, + fontSize = MessageBodyFontSize, + lineHeight = MessageBodyLineHeight, + ) + + val SenderStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = SenderFontSize, + lineHeight = SenderLineHeight, + ) + + val SystemActionStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + fontSize = SystemActionFontSize, + lineHeight = SystemActionLineHeight, + ) +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt new file mode 100644 index 00000000..b0f1ed7d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt @@ -0,0 +1,30 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.ui.graphics.Color +import com.bitchat.android.ui.PeerIdentity +import kotlin.math.abs + +/** + * The single identity-to-color boundary used by chat, people sheets, and mentions. + * + * The djb2 hash and hue adjustment are byte-identical to the iOS implementation. Orange is + * avoided because it is reserved for the current user. + */ +fun colorForPeer(identity: PeerIdentity, palette: BitchatPalette): Color { + var hash = 5381UL + for (byte in identity.stableKey.toByteArray()) { + hash = ((hash shl 5) + hash) + byte.toUByte().toULong() + } + + var hue = (hash % 360UL).toDouble() / 360.0 + val orange = 30.0 / 360.0 + if (abs(hue - orange) < 0.05) { + hue = (hue + 0.12) % 1.0 + } + + return Color.hsv( + hue = (hue * 360).toFloat(), + saturation = palette.peerColorSaturation, + value = palette.peerColorValue + ) +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt index 76ef3130..f2c4b0b7 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -16,30 +17,51 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalView -// Colors that match the iOS bitchat theme -private val DarkColorScheme = darkColorScheme( - primary = Color(0xFF39FF14), // Bright green (terminal-like) +// Standard UI semantics live in Material so stock components and custom Bitchat composables +// share one source of truth. LocalBitchatPalette below only supplies app-specific extra colors. +internal val DarkBitchatColorScheme = darkColorScheme( + primary = Color(0xFF32D74B), onPrimary = Color.Black, - secondary = Color(0xFF2ECB10), // Darker green + primaryContainer = Color(0xFF163D1D), + onPrimaryContainer = Color(0xFFB8F5C1), + secondary = Color(0xFF0A84FF), onSecondary = Color.Black, - background = Color.Black, - onBackground = Color(0xFF39FF14), // Green on black - surface = Color(0xFF111111), // Very dark gray - onSurface = Color(0xFF39FF14), // Green text - error = Color(0xFFFF5555), // Red for errors + secondaryContainer = Color(0xFF082E54), + onSecondaryContainer = Color(0xFFC2E0FF), + tertiary = DarkBitchatPalette.accentOrange, + onTertiary = Color.Black, + background = Color(0xFF000000), + onBackground = Color(0xFFF5F5F5), + surface = Color(0xFF0E150E), + onSurface = Color(0xFFF5F5F5), + surfaceVariant = Color(0xFF182118), + onSurfaceVariant = Color(0xFF9AA69A), + outline = Color(0xFF2A3A2A), + outlineVariant = Color(0xFF1C271C), + error = Color(0xFFFF453A), onError = Color.Black ) -private val LightColorScheme = lightColorScheme( - primary = Color(0xFF008000), // Dark green +internal val LightBitchatColorScheme = lightColorScheme( + primary = Color(0xFF248A3D), onPrimary = Color.White, - secondary = Color(0xFF006600), // Even darker green + primaryContainer = Color(0xFFD5F1D8), + onPrimaryContainer = Color(0xFF0A3212), + secondary = Color(0xFF007AFF), onSecondary = Color.White, - background = Color.White, - onBackground = Color(0xFF008000), // Dark green on white - surface = Color(0xFFF8F8F8), // Very light gray - onSurface = Color(0xFF008000), // Dark green text - error = Color(0xFFCC0000), // Dark red for errors + secondaryContainer = Color(0xFFD6E9FF), + onSecondaryContainer = Color(0xFF002C5C), + tertiary = LightBitchatPalette.accentOrange, + onTertiary = Color.Black, + background = Color(0xFFFFFFFF), + onBackground = Color(0xFF131A13), + surface = Color(0xFFF2F6F2), + onSurface = Color(0xFF131A13), + surfaceVariant = Color(0xFFE7EDE7), + onSurfaceVariant = Color(0xFF4C574C), + outline = Color(0xFFCBD6CB), + outlineVariant = Color(0xFFDEE6DE), + error = Color(0xFFD70015), onError = Color.White ) @@ -60,7 +82,8 @@ fun BitchatTheme( } } - val colorScheme = if (shouldUseDark) DarkColorScheme else LightColorScheme + val colorScheme = if (shouldUseDark) DarkBitchatColorScheme else LightBitchatColorScheme + val palette = if (shouldUseDark) DarkBitchatPalette else LightBitchatPalette val view = LocalView.current SideEffect { @@ -83,9 +106,11 @@ fun BitchatTheme( } } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) + CompositionLocalProvider(LocalBitchatPalette provides palette) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) + } } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt index a8c68a4b..2002a44a 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt @@ -2,53 +2,81 @@ package com.bitchat.android.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp // Base font size for consistent scaling across the app -internal const val BASE_FONT_SIZE = com.bitchat.android.util.AppConstants.UI.BASE_FONT_SIZE_SP // sp - increased from 14sp for better readability +internal const val BASE_FONT_SIZE = com.bitchat.android.util.AppConstants.UI.BASE_FONT_SIZE_SP + +/** + * Message body style. The generous leading (1.4x) is what gives the redesigned chat surface + * its readable rhythm; without an explicit lineHeight, Compose falls back to the font's own + * metrics and lines sit too tightly for long paragraphs. + */ +val MessageBodyTextStyle = ChatVisualTokens.MessageBodyStyle + +/** Sender label above a message group. Single line, never wraps. */ +val MessageSenderTextStyle = ChatVisualTokens.SenderStyle // Typography matching the iOS monospace design - using BASE_FONT_SIZE for consistency val Typography = Typography( bodyLarge = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = (BASE_FONT_SIZE + 1).sp, lineHeight = (BASE_FONT_SIZE + 7).sp ), bodyMedium = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = BASE_FONT_SIZE.sp, - lineHeight = (BASE_FONT_SIZE + 3).sp + lineHeight = (BASE_FONT_SIZE + 6).sp ), bodySmall = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = (BASE_FONT_SIZE - 3).sp, lineHeight = (BASE_FONT_SIZE + 1).sp ), headlineSmall = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE + 3).sp, lineHeight = (BASE_FONT_SIZE + 9).sp ), + // Previously unset, which leaked the Roboto default into onboarding + sheet titles. + headlineLarge = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold, + fontSize = (BASE_FONT_SIZE + 13).sp, + lineHeight = (BASE_FONT_SIZE + 21).sp + ), + titleLarge = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + fontSize = (BASE_FONT_SIZE + 5).sp, + lineHeight = (BASE_FONT_SIZE + 13).sp + ), titleMedium = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE + 1).sp, lineHeight = (BASE_FONT_SIZE + 7).sp ), + labelLarge = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + fontSize = (BASE_FONT_SIZE - 1).sp, + lineHeight = (BASE_FONT_SIZE + 5).sp + ), labelMedium = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE - 2).sp, lineHeight = (BASE_FONT_SIZE + 3).sp ), labelSmall = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = (BASE_FONT_SIZE - 4).sp, lineHeight = (BASE_FONT_SIZE + 1).sp diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 11df5b08..4905c927 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -120,7 +120,7 @@ object AppConstants { object UI { const val MAX_NICKNAME_LENGTH: Int = 15 - const val BASE_FONT_SIZE_SP: Int = 15 + const val BASE_FONT_SIZE_SP: Int = 14 const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L diff --git a/app/src/main/res/drawable/ic_spec_bluetooth.xml b/app/src/main/res/drawable/ic_spec_bluetooth.xml new file mode 100644 index 00000000..8c1ed7ae --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_bluetooth.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_bookmark_filled.xml b/app/src/main/res/drawable/ic_spec_bookmark_filled.xml new file mode 100644 index 00000000..2e73b458 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_bookmark_filled.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_bookmark_outline.xml b/app/src/main/res/drawable/ic_spec_bookmark_outline.xml new file mode 100644 index 00000000..988fe44c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_bookmark_outline.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_chat_bubbles.xml b/app/src/main/res/drawable/ic_spec_chat_bubbles.xml new file mode 100644 index 00000000..76109058 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_chat_bubbles.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_check.xml b/app/src/main/res/drawable/ic_spec_check.xml new file mode 100644 index 00000000..16eb9e1c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_check.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_close.xml b/app/src/main/res/drawable/ic_spec_close.xml new file mode 100644 index 00000000..e0fbc4ea --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_close.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/ic_spec_command.xml b/app/src/main/res/drawable/ic_spec_command.xml new file mode 100644 index 00000000..366173cd --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_command.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_envelope.xml b/app/src/main/res/drawable/ic_spec_envelope.xml new file mode 100644 index 00000000..15552d37 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_envelope.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/ic_spec_eye_off.xml b/app/src/main/res/drawable/ic_spec_eye_off.xml new file mode 100644 index 00000000..2e852cf4 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_eye_off.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_globe.xml b/app/src/main/res/drawable/ic_spec_globe.xml new file mode 100644 index 00000000..b56bb3a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_globe.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_lock.xml b/app/src/main/res/drawable/ic_spec_lock.xml new file mode 100644 index 00000000..42713a11 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_lock.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_lock_open.xml b/app/src/main/res/drawable/ic_spec_lock_open.xml new file mode 100644 index 00000000..11d69821 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_lock_open.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_mention.xml b/app/src/main/res/drawable/ic_spec_mention.xml new file mode 100644 index 00000000..a57bf9af --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_mention.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_on_location_person.xml b/app/src/main/res/drawable/ic_spec_on_location_person.xml new file mode 100644 index 00000000..507d47d8 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_on_location_person.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_panic.xml b/app/src/main/res/drawable/ic_spec_panic.xml new file mode 100644 index 00000000..7c876da3 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_panic.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_people.xml b/app/src/main/res/drawable/ic_spec_people.xml new file mode 100644 index 00000000..3b2d46f5 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_people.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_person.xml b/app/src/main/res/drawable/ic_spec_person.xml new file mode 100644 index 00000000..33c42dcb --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_person.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_range.xml b/app/src/main/res/drawable/ic_spec_range.xml new file mode 100644 index 00000000..5ba47d6c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_range.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_routed.xml b/app/src/main/res/drawable/ic_spec_routed.xml new file mode 100644 index 00000000..36e271d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_routed.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_shuffle.xml b/app/src/main/res/drawable/ic_spec_shuffle.xml new file mode 100644 index 00000000..9f431732 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_shuffle.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_star.xml b/app/src/main/res/drawable/ic_spec_star.xml new file mode 100644 index 00000000..c48025ab --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_star.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_star_filled.xml b/app/src/main/res/drawable/ic_spec_star_filled.xml new file mode 100644 index 00000000..d29d62d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_star_filled.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_sync.xml b/app/src/main/res/drawable/ic_spec_sync.xml new file mode 100644 index 00000000..1ab434e5 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_sync.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_teleport.xml b/app/src/main/res/drawable/ic_spec_teleport.xml new file mode 100644 index 00000000..6d215dea --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_teleport.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_warning.xml b/app/src/main/res/drawable/ic_spec_warning.xml new file mode 100644 index 00000000..ffb64e3c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_warning.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_waveform.xml b/app/src/main/res/drawable/ic_spec_waveform.xml new file mode 100644 index 00000000..2e0070b6 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_waveform.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_wifi.xml b/app/src/main/res/drawable/ic_spec_wifi.xml new file mode 100644 index 00000000..4757653f --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_wifi.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_wifi_off.xml b/app/src/main/res/drawable/ic_spec_wifi_off.xml new file mode 100644 index 00000000..4a716e57 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_wifi_off.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/src/main/res/font/geist_mono_bold.ttf b/app/src/main/res/font/geist_mono_bold.ttf new file mode 100644 index 00000000..90eb8a86 Binary files /dev/null and b/app/src/main/res/font/geist_mono_bold.ttf differ diff --git a/app/src/main/res/font/geist_mono_medium.ttf b/app/src/main/res/font/geist_mono_medium.ttf new file mode 100644 index 00000000..ff49ece5 Binary files /dev/null and b/app/src/main/res/font/geist_mono_medium.ttf differ diff --git a/app/src/main/res/font/geist_mono_regular.ttf b/app/src/main/res/font/geist_mono_regular.ttf new file mode 100644 index 00000000..50c9d5a6 Binary files /dev/null and b/app/src/main/res/font/geist_mono_regular.ttf differ diff --git a/app/src/main/res/font/geist_mono_semibold.ttf b/app/src/main/res/font/geist_mono_semibold.ttf new file mode 100644 index 00000000..1b21724d Binary files /dev/null and b/app/src/main/res/font/geist_mono_semibold.ttf differ diff --git a/app/src/main/res/raw/geist_mono_ofl.txt b/app/src/main/res/raw/geist_mono_ofl.txt new file mode 100644 index 00000000..34ffeb6f --- /dev/null +++ b/app/src/main/res/raw/geist_mono_ofl.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index c81bdb26..13c8d51c 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -71,12 +71,8 @@ إضافة إشارة مرجعية - رجوع - القناة: %1$s - مغادرة يمكن الوصول عبر Nostr رسائل خاصة غير مقروءة - تبديل الإشارة المرجعية تم الانتقال حالة Tor الأقران المتصلون @@ -96,14 +92,12 @@ اختيار صورة - دردشة شبكية لامركزية مع تشفير طرف إلى طرف دردشة شبكية دون اتصال تواصل مباشرة عبر Bluetooth LE بدون إنترنت أو خوادم. تُعاد توجيه الرسائل عبر الأجهزة القريبة لتوسيع النطاق. قنوات geohash عبر الإنترنت تواصل مع أشخاص بالقرب منك عبر قنوات تعتمد على geohash. وسّع الشبكة عبر مرحلات الإنترنت العامة. تشفير طرف إلى طرف الرسائل الخاصة مشفّرة. رسائل القنوات عامة. - المظهر النظام فاتح داكن @@ -127,7 +121,6 @@ حالة Tor: %1$s، التمهيد %2$d%% آخر: %1$s مسح البيانات الطارئ - تلميح: انقر ثلاث مرات على عنوان التطبيق لمسح كل البيانات المحفوظة – بما في ذلك الرسائل والمفاتيح والإعدادات. إعدادات التصحيح مفتوح المصدر • الخصوصية أولاً • لامركزي إغلاق @@ -147,7 +140,6 @@ فتح الخريطة إزالة الإشارة المرجعية انتقال - محدد مغادرة القناة يمكن الوصول عبر Nostr مفضل (غير متصل) @@ -286,14 +278,12 @@ الأذونات منح الأذونات bitchat لا يتتبع موقعك - الأشخاص لا أحد هنا… (أنت) حرّك وقرّب لاختيار geohash اختيار اكتب رسالة… إشارة - (~%1$s) v%1$s image/* صورة @@ -307,11 +297,6 @@ تشغيل إيقاف مؤقت - تعدين PoW - PoW مفعّل - برهان العمل - يتم التعدين… - pow: %1$dbit مطلوب لاكتشاف مستخدمي bitchat عبر البلوتوث diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 95b3662a..7c2617a4 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -71,12 +71,8 @@ বুকমার্ক যোগ করুন - ফিরে যান - চ্যানেল: %1$s - ছেড়ে দিন Nostr এর মাধ্যমে পৌঁছানো যায় অপঠিত ব্যক্তিগত বার্তা - বুকমার্ক টগল করুন টেলিপোর্ট করা হয়েছে Tor অবস্থা সংযুক্ত পিয়ার @@ -96,14 +92,12 @@ ছবি নির্বাচন করুন - এন্ড-টু-এন্ড এনক্রিপশন সহ বিকেন্দ্রীভূত মেশ চ্যাট অফলাইন মেশ চ্যাট ইন্টারনেট বা সার্ভার ছাড়াই Bluetooth LE এর মাধ্যমে সরাসরি যোগাযোগ করুন। পরিসর বাড়ানোর জন্য কাছাকাছি ডিভাইসের মাধ্যমে বার্তা রিলে করা হয়। অনলাইন জিওহ্যাশ চ্যানেল জিওহ্যাশ-ভিত্তিক চ্যানেলের মাধ্যমে আপনার এলাকার মানুষদের সাথে সংযুক্ত হন। পাবলিক ইন্টারনেট রিলেগুলির মাধ্যমে মেশ বিস্তৃত করুন। এন্ড-টু-এন্ড এনক্রিপশন ব্যক্তিগত বার্তাগুলি এনক্রিপ্ট করা। চ্যানেল বার্তাগুলি পাবলিক। - চেহারা সিস্টেম হালকা অন্ধকার @@ -127,7 +121,6 @@ Tor অবস্থা: %1$s, বুটস্ট্র্যাপ %2$d%% শেষ: %1$s জরুরী ডেটা ক্লিয়ার - টিপ: সংরক্ষিত সমস্ত ডেটা – বার্তা, কী এবং সেটিংস সহ – সাফ করতে অ্যাপ শিরোনামে তিনবার ট্যাপ করুন। ডিবাগ সেটিংস ওপেন সোর্স • গোপনীয়তা প্রথম • বিকেন্দ্রীভূত বন্ধ করুন @@ -147,7 +140,6 @@ মানচিত্র খুলুন বুকমার্ক সরান টেলিপোর্ট - নির্বাচিত চ্যানেল ছেড়ে দিন Nostr এর মাধ্যমে পৌঁছানো যায় প্রিয় (অফলাইন) @@ -286,14 +278,12 @@ অনুমতি অনুমতি দিন bitchat আপনার অবস্থান ট্র্যাক করে না - মানুষ কেউ আশেপাশে নেই … (আপনি) জিওহ্যাশ নির্বাচন করতে প্যান এবং জুম করুন নির্বাচন করুন বার্তা টাইপ করুন … উল্লেখ - (~%1$s) v%1$s image/* ছবি @@ -307,11 +297,6 @@ প্লে করুন বিরাম - মাইনিং PoW - PoW সক্রিয় - প্রুফ অফ ওয়ার্ক - মাইনিং … - pow: %1$dbit ব্লুটুথের মাধ্যমে bitchat ব্যবহারকারী আবিষ্কার করতে প্রয়োজন diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a80dc4c4..b13fb001 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -71,12 +71,8 @@ Lesezeichen hinzufügen - zurück - Kanal: %1$s - verlassen Über Nostr erreichbar Ungelesene private Nachrichten - Lesezeichen umschalten Teleportiert Tor‑Status Verbundenen Peers @@ -96,14 +92,12 @@ Bild auswählen - dezentraler Mesh‑Chat mit Ende‑zu‑Ende‑Verschlüsselung Offline‑Mesh‑Chat Kommuniziere direkt über Bluetooth LE ohne Internet oder Server. Nachrichten werden über nahe Geräte weitergeleitet, um die Reichweite zu erhöhen. Online‑Geohash‑Kanäle Vernetze dich mit Menschen in deiner Umgebung über geohash‑basierte Kanäle. Erweitere das Mesh über öffentliche Internet‑Relays. Ende‑zu‑Ende‑Verschlüsselung Private Nachrichten sind verschlüsselt. Kanal‑Nachrichten sind öffentlich. - Darstellung System Hell Dunkel @@ -127,7 +121,6 @@ Tor‑Status: %1$s, Bootstrap %2$d%% Zuletzt: %1$s Notfall‑Datenlöschung - Tipp: Dreifach auf den App‑Titel tippen, um alle gespeicherten Daten – einschließlich Nachrichten, Schlüssel und Einstellungen – zu löschen. Debug‑Einstellungen Open Source • Privacy First • Dezentral Schließen @@ -147,7 +140,6 @@ Karte öffnen Lesezeichen entfernen Teleportieren - Ausgewählt Kanal verlassen Über Nostr erreichbar Favorit (offline) @@ -286,14 +278,12 @@ Berechtigungen Berechtigungen erteilen bitchat verfolgt NICHT deinen Standort - PERSONEN niemand da … (du) schwenken und zoomen, um einen Geohash auszuwählen auswählen Nachricht eingeben … erwähnen - (~%1$s) v%1$s image/* Bild @@ -307,11 +297,6 @@ Abspielen Pause - Mining‑PoW - PoW aktiviert - Proof of Work - Mining … - pow: %1$dbit Erforderlich, um bitchat‑Benutzer über Bluetooth zu entdecken diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 157ea1b4..e1ac2c2b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -71,12 +71,8 @@ Agregar marcador - atrás - Canal: %1$s - salir Accesible vía Nostr Mensajes privados sin leer - Alternar marcador Teletransportado Estado de Tor Pares conectados @@ -96,14 +92,12 @@ Elegir imagen - chat mesh descentralizado con cifrado de extremo a extremo Chat mesh sin conexión Comunícate directamente a través de Bluetooth LE sin internet o servidores. Los mensajes se retransmiten a través de dispositivos cercanos para extender el alcance. Canales geohash en línea Conéctate con personas cerca de ti a través de canales basados en geohash. Extiende la red mesh a través de repetidores de internet público. Cifrado de extremo a extremo Los mensajes privados están cifrados. Los mensajes de canal son públicos. - Apariencia Sistema Claro Oscuro @@ -127,7 +121,6 @@ Estado de Tor: %1$s, bootstrap %2$d%% Último: %1$s Borrado de datos de emergencia - Consejo: Toca tres veces en el título de la app para borrar todos los datos almacenados, incluyendo mensajes, claves y configuraciones. Configuraciones de depuración Código abierto • Privacidad primero • Descentralizado Cerrar @@ -147,7 +140,6 @@ Abrir mapa Quitar marcador Teletransportar - Seleccionado Salir del canal Accesible vía Nostr Favorito (sin conexión) @@ -286,14 +278,12 @@ Permisos Otorgar permisos bitchat NO rastrea tu ubicación - PERSONAS nadie por aquí… (tú) desplaza y hace zoom para seleccionar un geohash seleccionar Escribe un mensaje… mencionar - (~%1$s) v%1$s image/* Imagen @@ -307,11 +297,6 @@ Reproducir Pausar - Minando PoW - PoW habilitado - Prueba de trabajo - Minando… - pow: %1$dbit Requerido para descubrir usuarios bitchat a través de Bluetooth diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 8d7d6ae9..a3e81d29 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -71,12 +71,8 @@ افزودن نشانک - بازگشت - کانال: %1$s - خروج قابل دسترس از طریق Nostr پیام‌های خصوصی خوانده‌نشده - تغییر وضعیت نشانک انتقال مکانی وضعیت Tor همتایان متصل @@ -96,14 +92,12 @@ انتخاب تصویر - گفت‌وگوی مِش غیرمتمرکز با رمزگذاری سرتاسری گفت‌وگوی مِش آفلاین بدون اینترنت یا سرور، مستقیماً از طریق Bluetooth LE ارتباط برقرار کنید. پیام‌ها از طریق دستگاه‌های نزدیک رله می‌شوند تا بُرد افزایش یابد. کانال‌های geohash آنلاین از طریق کانال‌های مبتنی بر geohash با افراد منطقهٔ خود ارتباط برقرار کنید. مِش را از طریق رله‌های عمومی اینترنت گسترش دهید. رمزگذاری سرتاسری پیام‌های خصوصی رمزگذاری می‌شوند. پیام‌های کانال عمومی هستند. - نما سامانه روشن تیره @@ -127,7 +121,6 @@ وضعیت Tor: %1$s، بوت‌استرپ %2$d%% آخرین: %1$s پاکسازی اضطراری داده - نکته: برای پاک کردن همهٔ داده‌های ذخیره‌شده — شامل پیام‌ها، کلیدها و تنظیمات — سه بار روی عنوان برنامه ضربه بزنید. تنظیمات اشکال‌زدایی متن‌باز • حریم خصوصی در اولویت • غیرمتمرکز بستن @@ -147,7 +140,6 @@ باز کردن نقشه حذف نشانک انتقال - انتخاب شد خروج از کانال قابل دسترس از طریق Nostr دلخواه (آفلاین) @@ -286,14 +278,12 @@ مجوزها اعطای مجوزها bitchat موقعیت شما را پیگیری نمی‌کند - افراد کسی در اطراف نیست … (شما) برای انتخاب geohash، جابه‌جا و زوم کنید انتخاب نوشتن پیام … ذکر - (~%1$s) v%1$s image/* تصویر @@ -307,11 +297,6 @@ پخش توقف - استخراج PoW - PoW فعال است - گواه کار - در حال استخراج … - pow: %1$dbit برای یافتن کاربران bitchat از طریق بلوتوث لازم است diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 829079a6..1da03f06 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -71,12 +71,8 @@ Mag‑bookmark - bumalik - channel: %1$s - umalis Maabot sa Nostr Hindi nabasang pribadong mensahe - I‑toggle ang bookmark Naiteleport Katayuan ng Tor Nakakonektang peers @@ -96,14 +92,12 @@ Pumili ng larawan - desentralisadong mesh messaging na may end‑to‑end encryption Offline Mesh Chat Makipag‑usap direkta sa Bluetooth LE nang walang internet o server. Ang mga mensahe ay nire‑relay ng mga kalapit na device. Online na Geohash na mga Channel Makipag‑ugnayan sa mga tao sa paligid gamit ang geohash‑based na mga channel. Palawakin ang mesh sa pampublikong internet relays. End‑to‑End Encryption Ang mga pribadong mensahe ay naka‑encrypt. Ang mga mensahe sa channel ay publiko. - hitsura system light dark @@ -127,7 +121,6 @@ katayuan ng Tor: %1$s, bootstrap %2$d%% Huli: %1$s Emergency Data Deletion - Tip: i‑tap ang pamagat ng app nang 3 beses para burahin ang lahat ng data (mga mensahe, susi, setting). Debug Settings Open Source • Privacy First • Desentralisado Isara @@ -147,7 +140,6 @@ Buksan ang mapa Alisin ang bookmark Teleport - Napili Umalis sa channel Maabot sa Nostr Paborito (offline) @@ -281,7 +273,6 @@ HINDI sinusubaybayan ng bitchat ang lokasyon mo @ · ⧉ - TAO walang tao sa paligid… (ikaw) i‑pan at i‑zoom para pumili ng geohash @@ -290,7 +281,6 @@ @%1$s banggit %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 46e9c023..e632bdb8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -71,12 +71,8 @@ Ajouter un signet - retour - canal : %1$s - quitter Joignable via Nostr Messages privés non lus - Basculer le signet Téléporté Statut Tor Pairs connectés @@ -96,14 +92,12 @@ Choisir une image - messagerie mesh décentralisée avec chiffrement de bout en bout Chat Mesh hors‑ligne Communique directement via Bluetooth LE sans internet ni serveurs. Les messages sont relayés par les appareils proches pour étendre la portée. Canaux Geohash en ligne Discute avec des personnes proches via des canaux basés sur geohash. Étends le mesh grâce à des relais internet publics. Chiffrement de bout en bout Les messages privés sont chiffrés. Les messages de canal sont publics. - apparence système clair sombre @@ -127,7 +121,6 @@ statut Tor : %1$s, bootstrap %2$d%% Dernier : %1$s Suppression d’urgence des données - Astuce : tapote 3 fois le titre de l’app pour supprimer toutes les données (messages, clés, paramètres). Paramètres de debug Open Source • Privacy First • Décentralisé Fermer @@ -147,7 +140,6 @@ Ouvrir la carte Retirer le signet Téléporter - Sélectionné Quitter le canal Joignable via Nostr Favori (hors‑ligne) @@ -282,7 +274,6 @@ bitchat ne suit PAS ta position @ · ⧉ - PERSONNES personne aux alentours… (toi) déplacer et zoomer pour choisir un geohash @@ -291,7 +282,6 @@ @%1$s mention %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index ffb4dac0..1a393f38 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -71,12 +71,8 @@ बुकमार्क जोड़ें - वापस - चैनल: %1$s - छोड़ें Nostr के माध्यम से उपलब्ध अपठित निजी संदेश - बुकमार्क टॉगल करें टेलीपोर्ट हुआ Tor स्थिति कनेक्टेड पीयर्स @@ -96,14 +92,12 @@ छवि चुनें - विकेन्द्रीकृत मेष चैट • एंड‑टू‑एंड एन्क्रिप्शन ऑफ़लाइन मेष चैट इंटरनेट या सर्वर के बिना Bluetooth LE के माध्यम से सीधे संवाद करें। संदेशों को पास के उपकरणों के माध्यम से रिले किया जाता है ताकि पहुँच बढ़े। ऑनलाइन geohash चैनल geohash आधारित चैनलों के माध्यम से अपने आस‑पास के लोगों से जुड़ें। सार्वजनिक इंटरनेट रिले के माध्यम से मेष का विस्तार करें। एंड‑टू‑एंड एन्क्रिप्शन निजी संदेश एन्क्रिप्टेड हैं। चैनल संदेश सार्वजनिक हैं। - रूप सिस्टम लाइट डार्क @@ -127,7 +121,6 @@ Tor स्थिति: %1$s, बूटस्ट्रैप %2$d%% अंतिम: %1$s आपातकालीन डेटा मिटाना - संकेत: सभी सहेजे डेटा – संदेश, कुंजियाँ और सेटिंग्स सहित – मिटाने के लिए ऐप शीर्षक पर तीन बार टैप करें। डिबग सेटिंग्स ओपन सोर्स • गोपनीयता पहले • विकेन्द्रीकृत बंद करें @@ -147,7 +140,6 @@ मानचित्र खोलें बुकमार्क हटाएँ टेलीपोर्ट - चयनित चैनल छोड़ें Nostr के माध्यम से उपलब्ध पसंदीदा (ऑफ़लाइन) @@ -286,14 +278,12 @@ अनुमतियाँ अनुमतियाँ दें bitchat आपकी लोकेशन ट्रैक नहीं करती - लोग आस‑पास कोई नहीं… (आप) geohash चुनने के लिए पैन/ज़ूम करें चयन करें संदेश लिखें… उल्लेख - (~%1$s) v%1$s image/* छवि @@ -307,11 +297,6 @@ चलाएँ रोकें - PoW माइनिंग - PoW सक्षम - प्रूफ़ ऑफ़ वर्क - माइनिंग… - pow: %1$dbit ब्लूटूथ के माध्यम से bitchat उपयोगकर्ताओं की खोज के लिए आवश्यक diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index e67caded..934556e4 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -71,12 +71,8 @@ Tambah bookmark - kembali - Channel: %1$s - keluar Dapat dijangkau melalui Nostr Pesan pribadi yang belum dibaca - Alihkan bookmark Diteleportasi Status Tor Peer yang terhubung @@ -96,14 +92,12 @@ Pilih gambar - chat mesh terdesentralisasi dengan enkripsi end-to-end Chat Mesh Offline Berkomunikasi langsung melalui Bluetooth LE tanpa Internet atau server. Pesan diteruskan melalui perangkat terdekat untuk memperluas jangkauan. Channel Geohash Online Terhubung dengan orang-orang di area Anda melalui channel berbasis geohash. Perluas mesh melalui relay Internet publik. Enkripsi end-to-end Pesan pribadi dienkripsi. Pesan channel bersifat publik. - Tampilan Sistem Terang Gelap @@ -127,7 +121,6 @@ Status Tor: %1$s, Bootstrap %2$d%% Terakhir: %1$s Hapus data darurat - Tips: Ketuk tiga kali pada judul aplikasi untuk menghapus semua data tersimpan – termasuk pesan, kunci, dan pengaturan. Pengaturan debug Sumber Terbuka • Privasi Pertama • Terdesentralisasi Tutup @@ -147,7 +140,6 @@ Buka peta Hapus bookmark Teleportasi - Dipilih Keluar dari channel Dapat dijangkau melalui Nostr Favorit (offline) @@ -286,14 +278,12 @@ Izin Berikan izin bitchat TIDAK melacak lokasi Anda - ORANG tidak ada orang di sekitar … (Anda) geser dan zoom untuk memilih geohash pilih Ketik pesan … sebut - (~%1$s) v%1$s image/* Gambar @@ -307,11 +297,6 @@ Putar Jeda - Mining PoW - PoW diaktifkan - Proof of Work - Mining … - pow: %1$dbit Diperlukan untuk menemukan pengguna bitchat melalui Bluetooth diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 92bf4ad0..7e67de6d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -71,12 +71,8 @@ Aggiungi segnalibro - indietro - canale: %1$s - esci Raggiungibile via Nostr Messaggi privati non letti - Attiva/disattiva segnalibro Teletrasportato Stato Tor Peer connessi @@ -96,14 +92,12 @@ Scegli immagine - messaggistica mesh decentralizzata con crittografia end-to-end Chat mesh offline Comunica direttamente tramite Bluetooth LE senza internet o server. I messaggi vengono inoltrati attraverso i dispositivi vicini per estendere la portata. Canali geohash online Connettiti con persone nella tua area usando canali basati su geohash. Estendi la rete mesh usando relay internet pubblici. Crittografia end-to-end I messaggi privati sono crittografati. I messaggi dei canali sono pubblici. - aspetto sistema chiaro scuro @@ -127,7 +121,6 @@ Stato Tor: %1$s, bootstrap %2$d%% Ultimo: %1$s Eliminazione dati di emergenza - Suggerimento: fai triplo clic sul titolo dell\'app per eliminare in emergenza tutti i dati memorizzati, inclusi messaggi, chiavi e impostazioni. Impostazioni di debug Open Source • Privacy First • Decentralizzato Chiudi @@ -147,7 +140,6 @@ Apri mappa Rimuovi segnalibro Teletrasporta - Selezionato Esci dal canale Raggiungibile via Nostr Preferito offline @@ -286,14 +278,12 @@ permessi Concedi permessi bitchat NON traccia la tua posizione - PERSONE nessuno nei dintorni… (tu) scorri e ingrandisci per selezionare un geohash seleziona scrivi un messaggio… menzione - (~%1$s) v%1$s image/* Immagine @@ -307,11 +297,6 @@ Riproduci Pausa - Mining PoW - PoW abilitato - Proof of Work - mining… - pow: %1$dbit Necessario per scoprire utenti bitchat tramite Bluetooth diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 1b25aa95..1e7da71d 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -71,12 +71,8 @@ ブックマークに追加 - 戻る - チャンネル: %1$s - 退出 Nostr 経由で到達可能 未読のプライベートメッセージ - ブックマークを切り替え テレポート済み Tor ステータス 接続中のピア @@ -96,14 +92,12 @@ 画像を選択 - 分散型メッシュチャット・エンドツーエンド暗号化 オフライン・メッシュチャット インターネットやサーバーなしで、Bluetooth LE 経由で直接通信します。近隣のデバイスを経由してメッセージを中継し、到達範囲を広げます。 オンライン Geohash チャンネル Geohash ベースのチャンネルで近くの人々とつながります。公開インターネットのリレーを介してメッシュを拡張します。 エンドツーエンド暗号化 プライベートメッセージは暗号化されます。チャンネルメッセージは公開です。 - 表示 システム ライト ダーク @@ -127,7 +121,6 @@ Tor ステータス: %1$s、ブートストラップ %2$d%% 最終: %1$s 緊急データ消去 - ヒント: アプリタイトルを 3 回タップすると、メッセージ・鍵・設定を含む保存データをすべて消去します。 デバッグ設定 オープンソース • プライバシーファースト • 分散型 閉じる @@ -147,7 +140,6 @@ 地図を開く ブックマークを削除 テレポート - 選択済み チャンネルから退出 Nostr 経由で到達可能 お気に入り (オフライン) @@ -286,14 +278,12 @@ 権限 権限を付与 bitchat はあなたの位置情報を追跡しません - 人々 誰もいません… (あなた) パン/ズームして geohash を選択 選択 メッセージを入力… メンション - (~%1$s) v%1$s image/* 画像 @@ -307,11 +297,6 @@ 再生 一時停止 - PoW をマイニング中 - PoW 有効 - Proof of Work - マイニング中… - pow: %1$dbit Bluetooth で bitchat ユーザーを検出するために必要です diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index c7620e19..4994f9cc 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -71,12 +71,8 @@ სანიშნეს დამატება - უკან - არხი: %1$s - გასვლა მისაწვდომია Nostr-ით წაუკითხავი პირადი შეტყობინებები - სანიშნეს გადართვა ტელეპორტირებული Tor-ის სტატუსი დაკავშირებული პირები @@ -96,14 +92,12 @@ სურათის არჩევა - დეცენტრალიზებული mesh-ჩათი ბოლო-მდე დაშიფვრით ოფლაინ mesh-ჩათი ესაუბრეთ პირდაპირ Bluetooth LE-ის საშუალებით ინტერნეტის ან სერვერების გარეშე. შეტყობინებები გადაიგზავნება ახლომდებარე მოწყობილობებზე, რათა გაიზარდოს დაფარვა. ონლაინ geohash არხები დაუკავშირდით ახლომდებარე ხალხს geohash-ზე დაფუძნებული არხებით. გააფართოვეთ mesh საჯარო ინტერნეტ რილეებით. ბოლო-მდე დაშიფვრა პირადი შეტყობინებები დაშიფრულია. არხის შეტყობინებები საჯაროა. - გარეგნობა სისტემა ღია მუქი @@ -127,7 +121,6 @@ Tor სტატუსი: %1$s, ჩატვირთვა %2$d%% ბოლო: %1$s სასწრაფო მონაცემების წაშლა - რჩევა: დააჭირეთ აპის სათაურს სამჯერ ყველა შენახული მონაცემის – შეტყობინებების, გასაღებებისა და პარამეტრების – წასაშლელად. დebug პარამეტრები ღია კოდი • კონფიდენციალობა პირველია • დეცენტრალიზებული დახურვა @@ -147,7 +140,6 @@ რუკის გახსნა სანიშნეს წაშლა ტელეპორტი - მონიშნული არხიდან გამოსვლა მისაწვდომია Nostr-ით რჩეული (ოფლაინ) @@ -286,14 +278,12 @@ ნებართვები ნებართვების მინიჭება bitchat არ აკონტროლებს თქვენს მდებარეობას - ხალხი არავინ არის ახლოს… (თქვენ) პანორამირება/ზუმი geohash-ის ასარჩევად არჩევა შეიყვანეთ შეტყობინება… ხსენება - (~%1$s) v%1$s image/* სურათი @@ -307,11 +297,6 @@ დაკვრა პაუზა - PoW მაინინგი - PoW ჩართულია - Proof of Work - მაინინგი… - pow: %1$dbit საჭიროა bitchat მომხმარებლების Bluetooth-ით აღმოსაჩენად diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 8c0cc624..7327257a 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -71,12 +71,8 @@ 북마크 추가 - 뒤로 - 채널: %1$s - 나가기 Nostr로 연결 가능 읽지 않은 개인 메시지 - 북마크 전환 텔레포트됨 Tor 상태 연결된 피어 @@ -96,14 +92,12 @@ 이미지 선택 - 종단간 암호화를 갖춘 탈중앙 메쉬 채팅 오프라인 메쉬 채팅 인터넷이나 서버 없이 Bluetooth LE로 직접 소통합니다. 범위를 확장하기 위해 근처 기기를 통해 메시지가 중계됩니다. 온라인 geohash 채널 geohash 기반 채널을 통해 주변 사람들과 연결하세요. 공용 인터넷 릴레이로 메쉬를 확장합니다. 종단간 암호화 개인 메시지는 암호화됩니다. 채널 메시지는 공개입니다. - 모양 시스템 라이트 다크 @@ -127,7 +121,6 @@ Tor 상태: %1$s, 부트스트랩 %2$d%% 마지막: %1$s 긴급 데이터 삭제 - 팁: 저장된 모든 데이터(메시지, 키, 설정 포함)를 삭제하려면 앱 제목을 세 번 탭하세요. 디버그 설정 오픈 소스 • 프라이버시 우선 • 탈중앙 닫기 @@ -147,7 +140,6 @@ 지도 열기 북마크 제거 텔레포트 - 선택됨 채널 나가기 Nostr로 연결 가능 즐겨찾기 (오프라인) @@ -286,14 +278,12 @@ 권한 권한 허용 bitchat은 사용자의 위치를 추적하지 않습니다 - 사람들 주변에 아무도 없음 … (나) geohash를 선택하려면 이동하고 확대/축소하세요 선택 메시지 입력 … 멘션 - (~%1$s) v%1$s image/* 이미지 @@ -307,11 +297,6 @@ 재생 일시정지 - PoW 채굴 - PoW 사용 - 작업증명 - 채굴 중 … - pow: %1$dbit 블루투스를 통해 bitchat 사용자를 발견하는 데 필요 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index ddff38ad..168eebe7 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -71,12 +71,8 @@ Hanampy bookmark - miverina - fantsona: %1$s - hiala Azo tratrarina amin\'ny alalan\'ny Nostr Hafatra manokana tsy voavaky - Hanova bookmark Nafindra toerana Toe-javatra Tor Mpiara-miasa mifandray @@ -96,14 +92,12 @@ Hifidy sary - hafatra mesh tsy misy foibe misy fandokoana farany amin\'ny farany Mesh Chat tsy an-tserasera Mifandraisa mivantana amin\'ny alalan\'ny Bluetooth LE tsy misy Internet na mpizara. Ny hafatra dia mamindra amin\'ny alalan\'ny fitaovana akaiky mba hanitarana ny elanelana. Fantsona Geohash an-tserasera Mifandray amin\'ny olona eo amin\'ny faritra misy anao amin\'ny fampiasana fantsona mifototra amin\'ny geohash. Manitatra ny mesh amin\'ny fampiasana relay Internet ho an\'ny daholobe. Fandokoana Farany amin\'ny Farany Voafono ny hafatra manokana. Hafatra ho an\'ny daholobe ny hafatra fantsona. - endrika rafitra mazava maizina @@ -127,7 +121,6 @@ Toe-javatra Tor: %1$s, bootstrap %2$d%% Farany: %1$s Famafana Angona Maika - Toro-hevitra: Tsindrio in-telo ny anaran\'ny fampiharana mba hamafana maika ny angona rehetra voatahiry ao anatin\'izany ny hafatra, ny lakile ary ny fametrahana. Fametrahana Debug Loharanom-baovao misokatra • Fiainana manokana voalohany • Tsy misy foibe Hanakatona @@ -147,7 +140,6 @@ Hanokatra sarintany Hanesorana bookmark Hamindra toerana - Voafidy Hiala amin\'ny fantsona Azo tratrarina amin\'ny alalan\'ny Nostr Ankafizina tsy an-tserasera @@ -290,7 +282,6 @@ Ny bitchat dia TSY manaraka ny toerananao @ · ⧉ - OLONA tsy misy olona manodidina... (ianao) ahetsika sy ataovy lehibe mba hifidianana geohash @@ -299,7 +290,6 @@ @%1$s hanonona %1$d / %2$d - (~%1$s) @%1$s v%1$s # @@ -333,11 +323,6 @@ Hilalao Hijanona - Mihaingam-poana PoW - Alefa ny PoW - Porofo Asa - mihaingana... - pow: %1$dbit Ilaina mba hahitana mpampiasa bitchat amin\'ny alalan\'ny Bluetooth diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 3e50146a..48a7afbd 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -71,12 +71,8 @@ बुकमार्क थप्नुहोस् - पछाडि - च्यानल: %1$s - छोड्नुहोस् Nostr मार्फत पुग्न सकिने नपढिएका निजी सन्देश - बुकमार्क टगल टेलिपोर्ट भयो Tor स्थिति जडान भएका साथीहरू @@ -96,14 +92,12 @@ तस्वीर छान्नुहोस् - विकेन्द्रित मेष सन्देश अन्त्यदेखि अन्त्य इन्क्रिप्सनसहित अफलाइन मेष च्याट इन्टरनेट वा सर्भर बिना सिधै Bluetooth LE मार्फत सञ्चार गर्नुहोस्। नजिकका यन्त्रहरूबाट सन्देश रिल레이 हुन्छ। अनलाइन Geohash च्यानलहरू Geohash आधारित च्यानलमार्फत नजिकका मानिसहरूसँग जोडिनुहोस्। सार्वजनिक इन्टरनेट रिलेलाई प्रयोग गरेर मेष विस्तार गर्नुहोस्। एन्ड‑टु‑एन्ड इन्क्रिप्सन निजी सन्देश इन्क्रिप्टेड हुन्छन्। च्यानल सन्देश सार्वजनिक हुन्छन्। - देखावट सिस्टम हल्का गाढा @@ -127,7 +121,6 @@ Tor स्थिति: %1$s, बुटस्ट्र्याप %2$d%% अन्तिम: %1$s आपतकालीन डेटा मेटाउने - सुझाव: एप शीर्षक तीन पटक ट्याप गरी सबै डेटा (सन्देश, कुञ्जी, सेटिङ) मेटाउनुहोस्। डिबग सेटिङ Open Source • Privacy First • विकेन्द्रित बन्द गर्नुहोस् @@ -147,7 +140,6 @@ नक्सा खोल्नुहोस् बुकमार्क हटाउनुहोस् टेलिपोर्ट - छानिएको च्यानल छोड्नुहोस् Nostr बाट पहुँचयोग्य मनपर्ने (अफलाइन) @@ -281,7 +273,6 @@ bitchat ले तपाईंको स्थान पछ्याउँदैन @ · ⧉ - मानिसहरू वरिपरि कोही छैन… (तपाईं) geohash छान्न प्यान र जुम गर्नुहोस् @@ -290,7 +281,6 @@ @%1$s उल्लेख %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 38385885..0eeaec55 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -71,12 +71,8 @@ Bladwijzer toevoegen - terug - kanaal: %1$s - verlaten Bereikbaar via Nostr Ongelezen privéberichten - Bladwijzer omschakelen Geteleporteerd Tor-status Verbonden peers @@ -96,14 +92,12 @@ Afbeelding kiezen - gedecentraliseerde meshcommunicatie met end-to-end-versleuteling Offline meshchat Communiceer rechtstreeks via Bluetooth LE zonder internet of servers. Berichten worden doorgestuurd via apparaten in de buurt om het bereik te vergroten. Online geohash-kanalen Maak contact met mensen in uw omgeving met geohash-gebaseerde kanalen. Breid het mesh uit met openbare internetrelays. End-to-end-versleuteling Privéberichten zijn versleuteld. Kanaalberichten zijn openbaar. - uiterlijk systeem licht donker @@ -127,7 +121,6 @@ Tor-status: %1$s, bootstrap %2$d%% Laatste: %1$s Noodgegevensverwijdering - Tip: Driedubbel klik op de app-titel om alle opgeslagen gegevens inclusief berichten, sleutels en instellingen noodmatig te verwijderen. Debug-instellingen Open Source • Privacy First • Gedecentraliseerd Sluiten @@ -147,7 +140,6 @@ Kaart openen Bladwijzer verwijderen Teleporteren - Geselecteerd Kanaal verlaten Bereikbaar via Nostr Offline favoriet @@ -286,14 +278,12 @@ toestemmingen Toestemmingen verlenen bitchat volgt NIET uw locatie - PERSONEN niemand in de buurt… (jij) pan en zoom om een geohash te selecteren selecteren typ een bericht… vermelden - (~%1$s) v%1$s image/* Afbeelding @@ -307,11 +297,6 @@ Afspelen Pauzeren - Mining-PoW - PoW ingeschakeld - Proof of Work - minen… - pow: %1$dbit Vereist om bitchat-gebruikers via Bluetooth te ontdekken diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index e92d2d51..5f5f5f75 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -71,12 +71,8 @@ بُک مارک پاؤ - واپس - چینل: %1$s - باہر آؤ Nostr راہین پہنچ ممکن اے انہ پڑھیا ذاتی پیغام - بُک مارک ٹوگل کرو ٹیلپورٹ کیتا گیا Tor حالت جُڑے پیئر @@ -96,14 +92,12 @@ تصویر چُنو - اینڈ ٹو اینڈ انکرپشن نال غیر مرکزی میش چیٹ آف لائن میش چیٹ انٹرنیٹ یا سرور توں بغیر Bluetooth LE راہین سیدھا رابطہ کرو۔ رینج ودھاؤن لئی پیغام نیڑے ڈیوائساں راہین ریلے ہوندے نیں۔ آن لائن geohash چینل geohash بیسڈ چینلاں راہین اپنے علاقے دے لوکاں نال جڑو۔ پبلک انٹرنیٹ ریلے راہین میش نوں وسیع کرو۔ اینڈ ٹو اینڈ انکرپشن ذاتی پیغام انکرپٹڈ ہوندے نیں۔ چینل پیغام پبلک ہوندے نیں۔ - دِکھ سسٹم ہلکا گھُپ @@ -127,7 +121,6 @@ Tor حالت: %1$s، بوٹ اسٹرَیپ %2$d%% آخری: %1$s ایمرجنسی ڈیٹا صاف - ٹِپ: سارے محفوظ ڈیٹا (پیغام، کُنجیاں، سیٹنگا) مٹاؤن لئی ایپ دے عنوان تے تین وار ٹیپ کرو۔ ڈی بگ سیٹنگا اوپن سورس • پرائیویسی پہلے • غیر مرکزی بند کرو @@ -147,7 +140,6 @@ نقشہ کھولو بُک مارک ہٹاؤ ٹیلپورٹ - چُنے ہوۓ چینل چھوڑو Nostr راہین پہنچ ممکن اے پسندیدہ (آف لائن) @@ -286,14 +278,12 @@ اجازتاں اجازت دو bitchat توانڈی لوکیشن نئیں ٹریک کردا - لوگ کوئی وی نیڑے نئیں … (تُسی) geohash چُنن لئی پین تے زوم کرو چُنو پیغام لکھو … ذکر - (~%1$s) v%1$s image/* تصویر @@ -307,11 +297,6 @@ چلاؤ روکੋ - PoW مائننگ - PoW چالو - Proof of Work - مائننگ … - pow: %1$dbit بلوٹوتھ راہین bitchat یوزر لبھن لئی ضروری diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 67503cc6..fd4901ef 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -71,12 +71,8 @@ Adicionar favorito - voltar - Canal: %1$s - sair Acessível via Nostr Mensagens privadas não lidas - Alternar favorito Teletransportado Status do Tor Peers conectados @@ -96,14 +92,12 @@ Escolher imagem - chat mesh descentralizado com criptografia de ponta a ponta Chat mesh offline Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas por dispositivos próximos para ampliar o alcance. Canais geohash online Conecte-se com pessoas próximas através de canais baseados em geohash. Expanda a mesh por meio de relays públicos na internet. Criptografia de ponta a ponta Mensagens privadas são criptografadas. Mensagens de canal são públicas. - Aparência Sistema Claro Escuro @@ -127,7 +121,6 @@ Status do Tor: %1$s, bootstrap %2$d%% Último: %1$s Limpeza de dados de emergência - Dica: Toque 3 vezes no título do app para apagar todos os dados salvos – incluindo mensagens, chaves e configurações. Configurações de depuração Código aberto • Privacidade em primeiro lugar • Descentralizado Fechar @@ -147,7 +140,6 @@ Abrir mapa Remover favorito Teletransportar - Selecionado Sair do canal Acessível via Nostr Favorito (offline) @@ -286,14 +278,12 @@ Permissões Conceder permissões O bitchat NÃO rastreia sua localização - PESSOAS ninguém por perto… (você) arraste/amplie para selecionar um geohash selecionar Digite uma mensagem… mencionar - (~%1$s) v%1$s image/* Imagem @@ -307,11 +297,6 @@ Reproduzir Pausar - Minerando PoW - PoW ativado - Prova de Trabalho - Minerando… - pow: %1$dbit Necessário para descobrir usuários do bitchat via Bluetooth diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3bcad4ab..5247916e 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -71,12 +71,8 @@ Adicionar marcador - voltar - Canal: %1$s - sair Alcançável via Nostr Mensagens privadas não lidas - Alternar marcador Teletransportado Estado do Tor Pares ligados @@ -96,14 +92,12 @@ Escolher imagem - chat mesh descentralizado com encriptação ponto a ponto Chat mesh offline Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas através de dispositivos próximos para estender o alcance. Canais geohash online Conecte-se com pessoas perto de si através de canais baseados em geohash. Estenda a mesh através de repetidores de internet pública. Encriptação ponto a ponto Mensagens privadas são encriptadas. Mensagens de canal são públicas. - Aparência Sistema Claro Escuro @@ -127,7 +121,6 @@ Estado do Tor: %1$s, bootstrap %2$d%% Último: %1$s Limpeza de dados de emergência - Dica: Toque três vezes no título da aplicação para apagar todos os dados guardados – incluindo mensagens, chaves e definições. Definições de depuração Código aberto • Privacidade primeiro • Descentralizado Fechar @@ -147,7 +140,6 @@ Abrir mapa Remover marcador Teletransportar - Selecionado Sair do canal Alcançável via Nostr Favorito (offline) @@ -286,14 +278,12 @@ Permissões Conceder permissões O bitchat NÃO rastreia a sua localização - PESSOAS ninguém por perto… (você) arraste e amplie para selecionar um geohash selecionar Digite uma mensagem… mencionar - (~%1$s) v%1$s image/* Imagem @@ -307,11 +297,6 @@ Reproduzir Pausar - A minerar PoW - PoW ativado - Prova de Trabalho - A minerar… - pow: %1$dbit Necessário para descobrir utilizadores bitchat via Bluetooth diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9ce4a560..4dc08928 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -63,12 +63,8 @@ Убрать из избранного Добавить закладку - назад - канал: %1$s - выйти Доступен через Nostr Непрочитанные личные - Переключить закладку Телепортировано Статус Tor Подключённые узлы @@ -86,14 +82,12 @@ ❌ Закрыть Выбрать изображение - децентрализованный mesh‑чат со сквозным шифрованием Оффлайн Mesh‑чат Общение напрямую по Bluetooth LE без интернета и серверов. Сообщения ретранслируются близлежащими устройствами. Онлайн‑каналы Geohash Общайся с людьми поблизости через каналы на основе geohash. Расширяй mesh через публичные ретрансляторы. Сквозное шифрование Личные сообщения — зашифрованы. Канальные — публичны. - оформление система светлая тёмная @@ -117,7 +111,6 @@ статус Tor: %1$s, загрузка %2$d%% Последнее: %1$s Экстренное удаление данных - Совет: тройной тап по заголовку — удалить все данные (сообщения, ключи, настройки). Настройки отладки Open Source • Privacy First • Децентрализация Закрыть @@ -136,7 +129,6 @@ Открыть карту Удалить закладку Телепорт - Выбрано Покинуть канал Доступен через Nostr Избранное (оффлайн) @@ -265,7 +257,6 @@ bitchat НЕ отслеживает вашу геопозицию @ · ⧉ - ЛЮДИ никого рядом… (вы) прокрутите/масштабируйте для выбора geohash @@ -274,7 +265,6 @@ @%1$s упоминание %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 566f940e..dcfda208 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -63,12 +63,8 @@ Ta bort favorit Lägg till bokmärke - tillbaka - kanal: %1$s - lämna Nås via Nostr Olästa privata meddelanden - Växla bokmärke Teleporterad Tor‑status Anslutna noder @@ -86,14 +82,12 @@ ❌ Stäng Välj bild - decentraliserad mesh‑meddelanden med ända‑till‑ända‑kryptering Offline Mesh‑chatt Kommunicera direkt via Bluetooth LE utan internet eller servrar. Meddelanden reläas av enheter i närheten. Online Geohash‑kanaler Anslut med personer i närheten via geohash‑kanaler. Utöka mesh via publika internetreläer. Ända‑till‑ända‑kryptering Privata meddelanden är krypterade. Kanalmeddelanden är publika. - utseende system ljus mörk @@ -117,7 +111,6 @@ Tor‑status: %1$s, bootstrap %2$d%% Senast: %1$s Nödradering av data - Tips: tryck tre gånger på appens titel för att radera alla data (meddelanden, nycklar, inställningar). Felsökningsinställningar Open Source • Privacy First • Decentraliserad Stäng @@ -136,7 +129,6 @@ Öppna karta Ta bort bokmärke Teleportera - Vald Lämna kanal Nås via Nostr Favorit (offline) @@ -265,7 +257,6 @@ bitchat spårar INTE din plats @ · ⧉ - PERSONER ingen i närheten… (du) panorera och zooma för att välja geohash @@ -274,7 +265,6 @@ @%1$s nämn %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 817cd335..7e1fc05a 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -71,12 +71,8 @@ เพิ่มที่คั่นหน้า - กลับ - ช่อง: %1$s - ออก ติดต่อได้ผ่าน Nostr ข้อความส่วนตัวยังไม่ได้อ่าน - สลับที่คั่นหน้า เทเลพอร์ตแล้ว สถานะ Tor เพียร์ที่เชื่อมต่อ @@ -96,14 +92,12 @@ เลือกภาพ - แชทเมชแบบกระจายศูนย์พร้อมการเข้ารหัสแบบปลายทางถึงปลายทาง แชทเมชออฟไลน์ สื่อสารโดยตรงผ่าน Bluetooth LE โดยไม่ใช้อินเทอร์เน็ตหรือเซิร์ฟเวอร์ ข้อความจะถูกส่งต่อผ่านอุปกรณ์ใกล้เคียงเพื่อขยายระยะ ช่อง geohash ออนไลน์ เชื่อมต่อกับผู้คนใกล้คุณผ่านช่องที่อ้างอิง geohash ขยายเมชผ่านรีเลย์อินเทอร์เน็ตสาธารณะ การเข้ารหัสแบบปลายทางถึงปลายทาง ข้อความส่วนตัวถูกเข้ารหัส ข้อความในช่องเป็นสาธารณะ - ลักษณะ ระบบ สว่าง มืด @@ -127,7 +121,6 @@ สถานะ Tor: %1$s, การบูต %2$d%% ล่าสุด: %1$s ลบข้อมูลฉุกเฉิน - เคล็ดลับ: แตะชื่อแอปสามครั้งเพื่อลบข้อมูลที่บันทึกทั้งหมด – รวมทั้งข้อความ กุญแจ และการตั้งค่า การตั้งค่าดีบัก โอเพนซอร์ส • ความเป็นส่วนตัวมาก่อน • กระจายศูนย์ ปิด @@ -147,7 +140,6 @@ เปิดแผนที่ เอาที่คั่นหน้าออก เทเลพอร์ต - ที่เลือก ออกจากช่อง ติดต่อได้ผ่าน Nostr รายการโปรด (ออฟไลน์) @@ -286,14 +278,12 @@ สิทธิ์ ให้สิทธิ์ bitchat ไม่ติดตามตำแหน่งของคุณ - ผู้คน ไม่มีใครอยู่ใกล้… (คุณ) ลาก/ซูมเพื่อเลือก geohash เลือก พิมพ์ข้อความ… กล่าวถึง - (~%1$s) v%1$s image/* รูปภาพ @@ -307,11 +297,6 @@ เล่น หยุดชั่วคราว - กำลังขุด PoW - เปิดใช้ PoW - Proof of Work - กำลังขุด… - pow: %1$dbit จำเป็นสำหรับการค้นหาผู้ใช้ bitchat ผ่านบลูทูธ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 93888483..b81fdd08 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -63,12 +63,8 @@ Sık kullanılandan kaldır Yer imi ekle - geri - kanal: %1$s - ayrıl Nostr üzerinden ulaşılabilir Okunmamış özel mesaj - Yer imini değiştir Teleport edildi Tor durumu Bağlı eşler @@ -86,14 +82,12 @@ ❌ Kapat Görsel seç - uçtan uca şifrelemeli merkezsiz mesh mesajlaşma Çevrimdışı Mesh Sohbeti İnternet/ sunucu olmadan doğrudan Bluetooth LE ile iletişim kur. Mesajlar yakındaki cihazlarca aktarılır. Çevrimiçi Geohash Kanalları Geohash tabanlı kanallarla yakınındaki insanlarla bağlantı kur. Herkese açık internet röleleriyle mesh’i genişlet. Uçtan Uca Şifreleme Özel mesajlar şifrelidir. Kanal mesajları herkese açıktır. - görünüm sistem açık koyu @@ -117,7 +111,6 @@ Tor durumu: %1$s, önyükleme %2$d%% Son: %1$s Acil veri silme - İpucu: tüm verileri silmek için uygulama başlığına üç kez dokun (mesajlar, anahtarlar, ayarlar). Hata ayıklama ayarları Open Source • Privacy First • Merkezsiz Kapat @@ -136,7 +129,6 @@ Haritayı aç Yer imini kaldır Teleport - Seçildi Kanaldan ayrıl Nostr üzerinden erişilebilir Favori (çevrimdışı) @@ -265,7 +257,6 @@ bitchat konumunu takip etmez @ · ⧉ - KİŞİLER yakında kimse yok… (sen) geohash seçmek için kaydır/zoom yap @@ -274,7 +265,6 @@ @%1$s bahset %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 3f1c735a..36c5265d 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -71,12 +71,8 @@ بک مارک شامل کریں - واپس - چینل: %1$s - چھوڑیں Nostr کے ذریعے رسائی غیر پڑھے ہوئے نجی پیغامات - بک مارک تبدیل کریں ٹیلی پورٹ Tor کی کیفیت منسلک پیئرز @@ -96,14 +92,12 @@ تصویر منتخب کریں - غیر مرکزی میش چیٹ • اینڈ‑ٹو‑اینڈ انکرپشن آف لائن میش چیٹ انٹرنیٹ یا سرورز کے بغیر Bluetooth LE کے ذریعے براہ راست بات چیت کریں۔ پیغامات قریبی آلات کے ذریعے ریلے ہوتے ہیں تاکہ رسائی بڑھے۔ آن لائن geohash چینلز geohash پر مبنی چینلز کے ذریعے اپنے قریب کے لوگوں سے جڑیں۔ عوامی انٹرنیٹ ریلے کے ذریعے میش کو پھیلائیں۔ اینڈ‑ٹو‑اینڈ انکرپشن نجی پیغامات انکرپٹڈ ہیں۔ چینل پیغامات عوامی ہیں۔ - ظاہری شکل سسٹم ہلکا گہرا @@ -127,7 +121,6 @@ Tor کی کیفیت: %1$s، بوٹ اسٹریپ %2$d%% آخری: %1$s ہنگامی ڈیٹا صافی - تجویز: تمام محفوظ شدہ ڈیٹا – پیغامات، کلیدیں اور سیٹنگز سمیت – صاف کرنے کے لیے ایپ کے ٹائٹل پر تین بار ٹیپ کریں۔ ڈیبگ سیٹنگز اوپن سورس • پرائیویسی پہلے • غیر مرکزی بند کریں @@ -147,7 +140,6 @@ نقشہ کھولیں بک مارک ہٹائیں ٹیلی پورٹ - منتخب چینل چھوڑیں Nostr کے ذریعے رسائی پسندیدہ (آف لائن) @@ -286,14 +278,12 @@ اجازات اجازات دیں bitchat آپ کا مقام ٹریک نہیں کرتا - لوگ یہاں کوئی نہیں… (آپ) geohash منتخب کرنے کے لیے پین/زوم کریں منتخب کریں پیغام لکھیں… ذکر - (~%1$s) v%1$s image/* تصویر @@ -307,11 +297,6 @@ چلائیں روکیں - PoW مائننگ - PoW فعال - پروف آف ورک - مائننگ… - pow: %1$dbit بلوٹوتھ کے ذریعے bitchat صارفین کی دریافت کے لیے ضروری diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 4dd4ad37..20028f38 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -71,12 +71,8 @@ Thêm bookmark - quay lại - Kênh: %1$s - rời khỏi Có thể tiếp cận qua Nostr Tin nhắn riêng chưa đọc - Bật/tắt bookmark Đã dịch chuyển Trạng thái Tor Peers đã kết nối @@ -96,14 +92,12 @@ Chọn hình ảnh - trò chuyện mesh phi tập trung với mã hóa đầu cuối Trò chuyện Mesh ngoại tuyến Liên lạc trực tiếp qua Bluetooth LE mà không cần Internet hoặc máy chủ. Tin nhắn được chuyển tiếp qua các thiết bị gần để mở rộng phạm vi. Kênh Geohash trực tuyến Kết nối với những người trong khu vực của bạn qua các kênh dựa trên geohash. Mở rộng mesh qua các relay Internet công cộng. Mã hóa đầu cuối Tin nhắn riêng được mã hóa. Tin nhắn kênh là công khai. - Giao diện Hệ thống Sáng Tối @@ -127,7 +121,6 @@ Trạng thái Tor: %1$s, Bootstrap %2$d%% Cuối cùng: %1$s Xóa dữ liệu khẩn cấp - Mẹo: Nhấn ba lần vào tiêu đề ứng dụng để xóa tất cả dữ liệu đã lưu – bao gồm tin nhắn, khóa và cài đặt. Cài đặt debug Mã nguồn mở • Quyền riêng tư trước tiên • Phi tập trung Đóng @@ -147,7 +140,6 @@ Mở bản đồ Xóa bookmark Dịch chuyển - Đã chọn Rời khỏi kênh Có thể tiếp cận qua Nostr Yêu thích (ngoại tuyến) @@ -286,14 +278,12 @@ Quyền Cấp quyền bitchat KHÔNG theo dõi vị trí của bạn - MỌI NGƯỜI không có ai xung quanh … (bạn) di chuyển và zoom để chọn geohash chọn Nhập tin nhắn … nhắc đến - (~%1$s) v%1$s image/* Hình ảnh @@ -307,11 +297,6 @@ Phát Tạm dừng - Đang mining PoW - PoW đã bật - Proof of Work - Đang mining … - pow: %1$dbit Cần thiết để khám phá người dùng bitchat qua Bluetooth diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index f8879cc8..d6eada8c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -71,12 +71,8 @@ 添加书签 - 返回 - 频道:%1$s - 离开 可通过 Nostr 联系 未读私信 - 切换书签 已传送 Tor 状态 已连接节点 @@ -96,14 +92,12 @@ 选择图片 - 去中心化 mesh 消息,端到端加密 离线 Mesh 聊天 无需网络或服务器,通过 Bluetooth LE 直接通信。利用附近设备中继消息以扩展范围。 在线 Geohash 频道 使用基于 geohash 的频道与附近的人交流。通过公共互联网中继扩展 mesh。 端到端加密 私信加密;频道消息为公开。 - 外观 系统 浅色 深色 @@ -127,7 +121,6 @@ Tor 状态:%1$s,引导 %2$d%% 最近:%1$s 紧急数据删除 - 提示:三击应用标题以删除所有数据,包括消息、密钥和设置。 调试设置 Open Source • Privacy First • 去中心化 关闭 @@ -147,7 +140,6 @@ 打开地图 移除书签 传送 - 已选择 离开频道 可通过 Nostr 联系 收藏(离线) @@ -281,7 +273,6 @@ bitchat 不会跟踪你的位置 @ · ⧉ - 成员 附近无人… (你) 拖动/缩放以选择 geohash @@ -290,7 +281,6 @@ @%1$s 提及 %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd316e59..02393f74 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,8 +4,8 @@ Bluetooth permission is required for peer-to-peer messaging without internet. Location permission is required to discover nearby devices via Bluetooth. Notification permission is required to alert you of new messages. - nickname - type a message… + Nickname + Type a message… Password Join Channel Leave @@ -54,7 +54,7 @@ Messages in %1$s %1$s joined the conversation bitchat - %1$d mentions - bitchat - location chats + bitchat - Location chats %1$d messages from %2$d locations Mentioned in Mesh Chat %1$d mentions in Mesh Chat @@ -72,12 +72,8 @@ Add bookmark - back - channel: %1$s - leave Nostr reachable Unread private messages - Toggle bookmark Location notes Teleported Tor status @@ -109,43 +105,75 @@ Pick file - decentralized mesh messaging with end-to-end encryption - Offline Mesh Chat - Communicate directly via Bluetooth LE without internet or servers. Messages relay through nearby devices to extend range. - Online Geohash Channels - Connect with people in your area using geohash-based channels. Extend the mesh using public internet relays. + The sidegroup chat + + + Info + Settings + + + How To Use + Set your nickname by tapping it + Tap the channel name to change channels + Tap the people icon to see who\'s in the channel + Bookmark a channel to keep it in your list + Type @ to mention someone + Type / to see the available commands + Triple-tap the logo to wipe all data + + + About + Theme + Settings + + + No Tracking + No servers, accounts, or data collection. + Ephemeral Identity + A new peer ID is generated regularly. + Triple-tap the logo to instantly clear all data. + + + Tor Network + Difficulty + %1$d bits • %2$s + Connected (%1$d%%) + Disconnected + + Offline Communication + Chat over a Bluetooth mesh with no internet and no servers. Messages relay through nearby devices to extend range. + Local Channels + Geohash channels connect you with people nearby, extending the mesh over public relays. End-to-End Encryption Private messages are encrypted. Channel messages are public. - appearance - system - light - dark - proof of work - pow off - pow on - add proof of work to geohash messages for spam deterrence. - difficulty: %1$d bits (~%2$s) - difficulty %1$d requires ~%2$s hash attempts - no proof of work required - very low - minimal spam protection - low - basic spam protection - medium - good spam protection - high - strong spam protection - very high - may cause delays - extreme - significant computation required - network - tor off - tor on - route internet over tor for enhanced privacy. - tor Status: %1$s, bootstrap %2$d%% + System + Light + Dark + Proof of Work + PoW Off + PoW On + Add proof of work to geohash messages for spam deterrence. + Difficulty: %1$d bits (~%2$s) + Difficulty %1$d requires ~%2$s hash attempts + No proof of work required + Very low – minimal spam protection + Low – basic spam protection + Medium – good spam protection + High – strong spam protection + Very high – may cause delays + Extreme – significant computation required + Network + Tor Off + Tor On + Route internet over Tor for enhanced privacy. + Tor Status: %1$s, bootstrap %2$d%% Last: %1$s - Emergency Data Deletion - Tip: Triple-click the app title to emergency delete all stored data including messages, keys, and settings. + Panic Mode Debug Settings Open Source • Privacy First • Decentralized Close Privacy Protected - cancel + Cancel Share BitChat @@ -226,10 +254,9 @@ Open map Remove bookmark Teleport - Selected Quit bitchat - run in background - keep mesh active when app is closed (foreground service) + Run in Background + Keep mesh active when app is closed (foreground service) Leave channel Reachable via Nostr Offline favorite @@ -243,15 +270,11 @@ Link Record voice note Pick media - Mining PoW - PoW Enabled - Proof of Work - mining... - pow: %1$dbit Offline Mesh Chat Online Geohash Channels End-to-End Encryption - #bluetooth • %1$s + Bluetooth • %1$s + Bluetooth • Wifi • %1$s Image %1$d of %2$d Image unavailable @@ -259,44 +282,61 @@ Failed to save image Pick file [file unavailable] - unknown + Unknown - choose an action for this message or user - choose an action for this user - copy message - copy this message to clipboard - slap %1$s - send a playful slap message - hug %1$s - send a friendly hug message - block %1$s - block all messages from this user - message %1$s - send a private message + Choose an action for this message or user + Choose an action for this user + Copy message + Copy this message to clipboard + Slap %1$s + Send a playful slap message + Hug %1$s + Send a friendly hug message + Block %1$s + Block all messages from this user + Message %1$s + Send a private message - #location channels - chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. do not screenshot or share this screen to protect your privacy. - grant location permission - location permission denied. enable in settings to use location channels. + Channels + Location Channels + Chat by coarse location. Never shares exact GPS. + Nearby + Offline mesh. No internet required. - \u2713 location permission granted - checking permissions... - finding nearby channels… - bookmarked + + Nearby + Tor Routing + Hides your IP for location channels. + Recommended + + + People (%1$d) + People + On location + Teleported in + + Grant location permission + Location permission denied. Enable in settings to use location channels. + + \u2713 Location permission granted + Checking permissions… + Finding nearby channels… + Bookmarked geohash - invalid geohash - teleport - disable location services - enable location services + Invalid geohash + Teleport + Remove location access + Enable location services mesh - block - neighborhood - city - province - region + Mesh + Block + Neighborhood + City + Province + Region check for notes left here @@ -307,89 +347,89 @@ #%1$s ± 1 • %2$d note #%1$s ± 1 • %2$d notes - add short permanent notes to this location for other visitors to find. - geo relays unavailable; notes paused - no geo relays nearby - notes rely on geo relays. check connection and try again. - loading notes… - no notes yet - be the first to add one for this spot. - dismiss - add a note for this place + Add short permanent notes to this location for other visitors to find. + Geo relays unavailable; notes paused + No geo relays nearby + Notes rely on geo relays. Check connection and try again. + Loading notes… + No notes yet + Be the first to add one for this spot. + Dismiss + Add a note for this place - debug tools - developer utilities for diagnostics and control - verbose logging - logs peer joins/leaves, connection direction, packet routing and relays - bluetooth roles - gatt server - connections: %1$d / %2$d - max server - gatt client - max client - connections: %1$d / %2$d - max overall - packet relay - since start: %1$d - turn roles on/off and close all connections when disabled - sync settings - max packets per sync: %1$d - max GCS filter size: %1$d bytes (128–1024) - target FPR: %1$.2f%% - connected devices - our device id: %1$s - none - disconnect - recent scan results + Debug Tools + Developer utilities for diagnostics and control + Verbose logging + Logs peer joins/leaves, connection direction, packet routing and relays + Bluetooth roles + GATT server + Connections: %1$d / %2$d + Max server + GATT client + Max client + Connections: %1$d / %2$d + Max overall + Packet relay + Since start: %1$d + Turn roles on/off and close all connections when disabled + Sync settings + Max packets per sync: %1$d + Max GCS filter size: %1$d bytes (128–1024) + Target FPR: %1$.2f%% + Connected devices + Our device ID: %1$s + None + Disconnect + Recent scan results - verify - scan to verify me - scan someone elses qr - scan someone elses qr - show my qr - remove verification - qr unavailable - camera permission is needed to scan qr codes - enable camera - paste verification url - validate - verification requested - security verification - their fingerprint - your fingerprint - handshake pending - open a private chat to view fingerprints - encrypted & verified - encrypted - handshaking - handshake failed - not encrypted - verified - you have verified this persons identity. - not verified - compare these fingerprints with %1$s using a secure channel. - mark as verified - start handshake - copy + Verify + Scan to verify me + Scan someone else\'s QR + Scan someone else\'s QR + Show my QR + Remove verification + QR unavailable + Camera permission is needed to scan QR codes + Enable camera + Paste verification URL + Validate + Verification requested + Security verification + Their fingerprint + Your fingerprint + Handshake pending + Open a private chat to view fingerprints + Encrypted & verified + Encrypted + Handshaking + Handshake failed + Not encrypted + Verified + You have verified this person\'s identity. + Not verified + Compare these fingerprints with %1$s using a secure channel. + Mark as verified + Start handshake + Copy Mutual verification You and %1$s verified each other - mutual verification with %1$s + Mutual verification with %1$s Verified You verified %1$s - verified %1$s + Verified %1$s - connect - debug console - clear - last 10s: %1$d • 1m: %2$d • 15m: %3$d - derived P: %1$s • est. max elements: %2$s + Connect + Debug console + Clear + Last 10s: %1$d • 1m: %2$d • 15m: %3$d + Derived P: %1$s • est. max elements: %2$s • direct RSSI: %1$s ? - as server (we host) - as client (we connect) + As server (we host) + As client (we connect) Location Services Required @@ -398,11 +438,11 @@ bitchat needs location services for: • Bluetooth device scanning\n• Discovering nearby users on mesh network\n• Geohash chat feature\n• No tracking or location collection Background Location Recommended - optional, improves mesh reliability + Optional, improves mesh reliability Android recommends background location so bitchat can scan for nearby devices while the app is not open. This keeps the mesh alive after reboot. When settings opens, choose "Allow all the time". bitchat uses background location for: - - scan for nearby devices while the app is closed\n- reconnect after reboot\n- keep the mesh running in the background + - Scan for nearby devices while the app is closed\n- Reconnect after reboot\n- Keep the mesh running in the background We NEVER collect or store your location. Your privacy is safe. Allow Background Location Continue without background location @@ -418,13 +458,13 @@ Bluetooth Not Supported This device doesn\'t support Bluetooth Low Energy (BLE), which is required for bitchat to function.\n\nbitchat needs BLE to create mesh networks and communicate with nearby devices without internet. Checking Bluetooth status... - battery optimization detected + Battery optimization detected Battery Optimization Enabled - bitchat needs to run in the background to maintain mesh connections. battery optimization can interrupt these connections. + bitchat needs to run in the background to maintain mesh connections. Battery optimization can interrupt these connections. Benefits of Disabling - • reliable message delivery\n• maintains mesh connectivity\n• prevents connection drops + • Reliable message delivery\n• Maintains mesh connectivity\n• Prevents connection drops Disable Battery Optimization - battery optimization disabled + Battery optimization disabled Continue Initializing mesh network . @@ -435,23 +475,21 @@ Try Again Open Settings Your Privacy is Protected - • no tracking or data collection\n• Bluetooth mesh chats are fully offline\n• Geohash chats use the internet - permissions + • No tracking or data collection\n• Bluetooth mesh chats are fully offline\n• Geohash chats use the internet + Permissions Grant Permissions bitchat does NOT track your location @ Open About · ⧉ - PEOPLE - nobody around... + Nobody around… (you) - pan and zoom to select a geohash - select - type a message... + Pan and zoom to select a geohash + Select + Type a message… @%1$s - mention + Mention %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/test/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicyTest.kt b/app/src/test/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicyTest.kt new file mode 100644 index 00000000..3c6217af --- /dev/null +++ b/app/src/test/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicyTest.kt @@ -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(), + GeohashNostrPrivacyPolicy.livePresenceTargets(channels, false) + ) + assertEquals( + setOf("region", "city"), + GeohashNostrPrivacyPolicy.livePresenceTargets(channels, true) + ) + } +} diff --git a/app/src/test/java/com/bitchat/android/geohash/LiveLocationAccessPolicyTest.kt b/app/src/test/java/com/bitchat/android/geohash/LiveLocationAccessPolicyTest.kt new file mode 100644 index 00000000..93cef8ca --- /dev/null +++ b/app/src/test/java/com/bitchat/android/geohash/LiveLocationAccessPolicyTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacyTest.kt b/app/src/test/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacyTest.kt new file mode 100644 index 00000000..a964a686 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacyTest.kt @@ -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>(), + NostrLiveSubscriptionPrivacy.closeTargets( + liveSubscriptionIds = emptySet(), + subscriptionsByRelay = mapOf("default-relay" to setOf("dm")), + ), + ) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index 304166c0..e64448c3 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -1,44 +1,558 @@ package com.bitchat.android.ui +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.ChatVisualTokens +import com.bitchat.android.ui.theme.DarkBitchatColorScheme +import com.bitchat.android.ui.theme.DarkBitchatPalette +import com.bitchat.android.ui.theme.LightBitchatColorScheme +import com.bitchat.android.ui.theme.LightBitchatPalette +import com.bitchat.android.ui.theme.MessageBodyTextStyle +import com.bitchat.android.ui.theme.MessageSenderTextStyle +import com.bitchat.android.ui.theme.colorForPeer import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +/** + * Runs under Robolectric because URL detection in message bodies goes through + * `android.util.Patterns.WEB_URL`, which is null on a bare JVM. + */ +@RunWith(RobolectricTestRunner::class) class ChatUIUtilsTest { - private val timeFormatter = SimpleDateFormat("HH:mm:ss", Locale.ROOT).apply { + private val timeFormatter = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.ROOT).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } + private val palette = DarkBitchatPalette + private val colorScheme = DarkBitchatColorScheme + + private fun message( + content: String, + sender: String = "alice", + powDifficulty: Int? = null, + ) = BitchatMessage( + sender = sender, + content = content, + timestamp = Date(0), + powDifficulty = powDifficulty, + ) + + // MARK: - Timestamp metadata + @Test fun `text message metadata separates PoW badge with one space`() { - val message = BitchatMessage( - sender = "alice", - content = "hello", - timestamp = Date(0), - powDifficulty = 12, - ) - assertEquals( - "00:00:00 ⛨12b", - formatTextMessageMetadata(message, timeFormatter).text, + "00:00 ⛨12b", + formatTextMessageMetadata(message("hello", powDifficulty = 12), timeFormatter).text, ) } @Test fun `text message metadata omits non-positive PoW difficulty`() { - val message = BitchatMessage( - sender = "alice", - content = "hello", - timestamp = Date(0), - powDifficulty = 0, - ) - assertEquals( - "00:00:00", - formatTextMessageMetadata(message, timeFormatter).text, + "00:00", + formatTextMessageMetadata(message("hello", powDifficulty = 0), timeFormatter).text, ) } + + // MARK: - Body with inline trailing timestamp + + @Test + fun `body appends timestamp inline after the message text`() { + val body = formatTextMessageBody( + message = message("hello there"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + ) + + assertEquals("hello there 00:00", body.text) + val timestamp = body.spanStyles.first { + body.text.substring(it.start, it.end) == " 00:00" + }.item + assertEquals(10.sp, timestamp.fontSize) + assertEquals(FontWeight.Normal, timestamp.fontWeight) + assertEquals(palette.textTertiary, timestamp.color) + } + + @Test + fun `body appends PoW badge after the inline timestamp`() { + val body = formatTextMessageBody( + message = message("mined", powDifficulty = 8), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + ) + + assertEquals("mined 00:00 ⛨8b", body.text) + val timestampAndPow = body.spanStyles.first { + body.text.substring(it.start, it.end) == " 00:00 ⛨8b" + }.item + assertEquals(10.sp, timestampAndPow.fontSize) + assertEquals(palette.textTertiary, timestampAndPow.color) + } + + @Test + fun `body can omit the timestamp for callers that render it separately`() { + val body = formatTextMessageBody( + message = message("hello there"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + assertEquals("hello there", body.text) + } + + @Test + fun `body renders plain text in the neutral palette color, not terminal green`() { + val body = formatTextMessageBody( + message = message("plain words"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val textStyle = body.spanStyles.first { it.start == 0 } + assertEquals(colorScheme.onSurface, textStyle.item.color) + } + + @Test + fun `chat text styles match the exported type scale`() { + assertEquals(14.sp, MessageBodyTextStyle.fontSize) + assertEquals(20.sp, MessageBodyTextStyle.lineHeight) + assertEquals(FontWeight.Normal, MessageBodyTextStyle.fontWeight) + assertEquals(BitchatFontFamily, MessageBodyTextStyle.fontFamily) + assertEquals(14.sp, MessageSenderTextStyle.fontSize) + assertEquals(16.sp, MessageSenderTextStyle.lineHeight) + assertEquals(FontWeight.SemiBold, MessageSenderTextStyle.fontWeight) + } + + @Test + fun `body does not bold plain text for the sender's own messages`() { + // Regression guard: the old renderer bolded the entire body when the message was yours, + // and again when you were mentioned, which is what made busy channels unreadable. + val body = formatTextMessageBody( + message = message("my own words", sender = "bob"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + assertTrue( + "no span should force a bold weight", + body.spanStyles.none { it.item.fontWeight?.weight?.let { w -> w >= 700 } == true } + ) + } + + // MARK: - Mention chips + + private fun mentionChipSpans(body: androidx.compose.ui.text.AnnotatedString) = + body.spanStyles.filter { it.item.background.isSpecified() } + + private fun androidx.compose.ui.graphics.Color.isSpecified() = + this != androidx.compose.ui.graphics.Color.Unspecified && alpha > 0f + + @Test + fun `mention renders as a single contiguous background chip`() { + val body = formatTextMessageBody( + message = message("hey @carol#04af what's up"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val chips = mentionChipSpans(body) + assertEquals("expected exactly one chip", 1, chips.size) + + // The chip must cover "@carol#04af" as one run so it paints without seams. + val chip = chips.single() + assertEquals("@carol#04af", body.text.substring(chip.start, chip.end)) + } + + @Test + fun `mention targeting the current user uses the orange accent`() { + val body = formatTextMessageBody( + message = message("ping @bob now"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val chip = mentionChipSpans(body).single() + assertEquals(palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA_SELF), chip.item.background) + assertEquals(0.2f, chip.item.background.alpha) + + val nameStyle: SpanStyle = body.spanStyles + .first { it.start == chip.start && it.item.color == palette.accentOrange } + .item + assertEquals(palette.accentOrange, nameStyle.color) + } + + @Test + fun `mention of another user is tinted by that user's own peer color`() { + val pubkey = "0123456789abcdef".repeat(4) + val identity = PeerIdentity.nostr(pubkey) + val mentionPeerIdentities = buildMentionPeerIdentityMap( + messages = listOf( + BitchatMessage( + sender = "carol#04af", + content = "hello", + timestamp = Date(0), + senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, + ) + ) + ) + val body = formatTextMessageBody( + message = message("cc @carol#04af"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + mentionPeerIdentities = mentionPeerIdentities, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val expected = colorForPeer(identity, palette) + val chip = mentionChipSpans(body).single() + assertEquals(expected.copy(alpha = MENTION_CHIP_ALPHA), chip.item.background) + assertTrue(body.spanStyles.any { it.item.color == expected }) + } + + @Test + fun `composer colors nickname and hash suffix from the mentioned peer identity`() { + val pubkey = "0123456789abcdef".repeat(4) + val identity = PeerIdentity.nostr(pubkey) + val token = "@carol#04af" + val input = "ping $token now" + val transformed = MentionVisualTransformation( + mentionPeerIdentities = mapOf("carol#04af" to identity), + palette = palette, + ).filter(AnnotatedString(input)).text + + val expectedColor = colorForPeer(identity, palette) + val tokenStart = input.indexOf(token) + val suffixStart = input.indexOf("#04af") + val tokenEnd = tokenStart + token.length + + assertEquals(input, transformed.text) + assertTrue(transformed.spanStyles.any { + it.start == tokenStart && + it.end == tokenEnd && + it.item.background == expectedColor.copy(alpha = MENTION_CHIP_ALPHA) + }) + assertTrue(transformed.spanStyles.any { + it.start == tokenStart && + it.end == suffixStart && + it.item.color == expectedColor + }) + assertTrue(transformed.spanStyles.any { + it.start == suffixStart && + it.end == tokenEnd && + it.item.color == expectedColor.copy(alpha = SUFFIX_ALPHA) + }) + } + + @Test + fun `ambiguous base nickname is not assigned to the wrong peer`() { + val firstIdentity = PeerIdentity.nostr("11111111".repeat(8)) + val secondIdentity = PeerIdentity.nostr("22222222".repeat(8)) + val identities = buildMentionPeerIdentityMap( + messages = emptyList(), + knownPeers = listOf( + "alice#1111" to firstIdentity, + "alice#2222" to secondIdentity, + ) + ) + + assertEquals(firstIdentity, identities["alice#1111"]) + assertEquals(secondIdentity, identities["alice#2222"]) + assertFalse(identities.containsKey("alice")) + assertEquals( + firstIdentity, + resolveMentionPeerIdentity("@alice#1111", identities) + ) + assertEquals(null, resolveMentionPeerIdentity("@alice", identities)) + } + + @Test + fun `message without mentions has no background chips`() { + val body = formatTextMessageBody( + message = message("no mentions in here"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + assertTrue(mentionChipSpans(body).isEmpty()) + } + + // MARK: - System / action messages + + @Test + fun `system message uses a double-slash prefix and no brackets`() { + val text = formatSystemMessage( + message = message("Tor started. Routing all chats via Tor", sender = "system"), + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + ).text + + assertEquals("// Tor started. Routing all chats via Tor 00:00", text) + } + + @Test + fun `system message is not italic`() { + // The old treatment was `* italic asterisks *`, which competed visually with real + // messages despite being lower-priority narration. + val annotated = formatSystemMessage( + message = message("tor restarting", sender = "system"), + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + ) + + assertTrue(annotated.spanStyles.all { it.item.fontStyle == null }) + } + + @Test + fun `system action and timestamp use their exported weights sizes and opacity`() { + val annotated = formatSystemMessage( + message = message("tor restarting", sender = "system"), + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + ) + + val action = annotated.spanStyles.first { + annotated.text.substring(it.start, it.end) == "// tor restarting" + }.item + val time = annotated.spanStyles.first { + annotated.text.substring(it.start, it.end) == " 00:00" + }.item + + assertEquals(12.sp, action.fontSize) + assertEquals(FontWeight.Medium, action.fontWeight) + assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), action.color) + assertEquals(10.sp, time.fontSize) + assertEquals(FontWeight.Normal, time.fontWeight) + assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), time.color) + } + + // MARK: - Sender label + + @Test + fun `sender label drops angle brackets and dims the hash suffix`() { + val sender = formatTextMessageSender( + message = message("hi", sender = "carol#04af"), + currentUserNickname = "bob", + myPeerID = "peer-me", + palette = palette, + ) + + assertEquals("@carol#04af", sender.text) + + val suffixSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "#04af" } + val nameSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "@carol" } + assertNotNull(suffixSpan.item.color) + assertEquals(14.sp, nameSpan.item.fontSize) + assertEquals(FontWeight.SemiBold, nameSpan.item.fontWeight) + assertEquals(14.sp, suffixSpan.item.fontSize) + assertEquals(FontWeight.Normal, suffixSpan.item.fontWeight) + assertEquals(ChatVisualTokens.SenderSuffixAlpha, suffixSpan.item.color.alpha) + assertTrue( + "suffix must be dimmer than the name", + suffixSpan.item.color.alpha < nameSpan.item.color.alpha + ) + } + + @Test + fun `sender label annotates the nickname for others but not for yourself`() { + val other = formatTextMessageSender( + message = message("hi", sender = "carol#04af"), + currentUserNickname = "bob", + myPeerID = "peer-me", + palette = palette, + ) + assertEquals(1, other.getStringAnnotations("nickname_click", 0, other.length).size) + + val mine = formatTextMessageSender( + message = message("hi", sender = "bob"), + currentUserNickname = "bob", + myPeerID = "peer-me", + palette = palette, + ) + assertTrue(mine.getStringAnnotations("nickname_click", 0, mine.length).isEmpty()) + } + + // MARK: - Peer colors + + @Test + fun `peer identity factories normalize stable IDs without resolving UI colors`() { + assertEquals( + PeerIdentity.mesh("abcdef"), + PeerIdentity.mesh("ABCDEF") + ) + assertEquals( + PeerIdentity.nostr("abcdef"), + PeerIdentity.nostr("ABCDEF") + ) + assertEquals( + PeerIdentity.nostr("abcdef"), + PeerIdentity.nostr("nostr:nostr_ABCDEF") + ) + assertEquals( + "nostr:nostr:abcdef01", + PeerIdentity.nostr("ABCDEF0123456789").stableKey + ) + assertEquals("alice#1234", PeerIdentity.nickname("ALICE#1234").stableKey) + } + + @Test + fun `peer color hue is stable across light and dark, only chroma differs`() { + // Hue derivation must stay byte-identical to iOS; only saturation/value are tuned for + // the redesigned neutral message body. + val identity = PeerIdentity.mesh("abc") + val dark = colorForPeer(identity, DarkBitchatPalette) + val light = colorForPeer(identity, LightBitchatPalette) + + val darkHsv = FloatArray(3) + val lightHsv = FloatArray(3) + rgbToHsv(dark.red, dark.green, dark.blue, darkHsv) + rgbToHsv(light.red, light.green, light.blue, lightHsv) + + assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0) + assertEquals(1.0, darkHsv[1].toDouble(), 0.01) + assertEquals(1.0, darkHsv[2].toDouble(), 0.01) + assertEquals(0.85, lightHsv[1].toDouble(), 0.01) + assertEquals(0.45, lightHsv[2].toDouble(), 0.01) + } + + @Test + fun `peer color avoids the orange hue reserved for self`() { + // Sweep a range of seeds; none may land within the reserved orange band. + repeat(500) { i -> + val color = colorForPeer( + PeerIdentity.mesh("seed$i"), + DarkBitchatPalette + ) + val hsv = FloatArray(3) + rgbToHsv(color.red, color.green, color.blue, hsv) + val distanceFromOrange = kotlin.math.abs(hsv[0] - 30f) + assertTrue( + "seed$i resolved to ${hsv[0]}°, inside the reserved orange band", + distanceFromOrange >= 17f || hsv[1] < 0.01f + ) + } + } + + @Test + fun `material owns standard text while Bitchat palette owns peer chroma`() { + assertEquals(Color(0xFFF5F5F5), DarkBitchatColorScheme.onSurface) + assertTrue(LightBitchatColorScheme.onSurface != DarkBitchatColorScheme.onSurface) + assertTrue( + LightBitchatPalette.peerColorValue != DarkBitchatPalette.peerColorValue + ) + } + + @Test + fun `geohash chat and people sheet resolve the same full Nostr identity`() { + val pubkey = "ABCDEF0123456789".repeat(4) + val peopleIdentity = PeerIdentity.nostr(pubkey) + val chatIdentity = peerIdentityForMessage( + BitchatMessage( + sender = "alice#1234", + content = "hello", + timestamp = Date(0), + senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, + ) + ) + + assertEquals(peopleIdentity, chatIdentity) + assertEquals( + colorForPeer(peopleIdentity, palette), + colorForPeer(chatIdentity, palette) + ) + } + + @Test + fun `mesh chat and people sheet resolve the same peer identity`() { + val peerID = "ABCDEF0123456789" + val peopleIdentity = PeerIdentity.mesh(peerID) + val chatIdentity = peerIdentityForMessage( + BitchatMessage( + sender = "alice#1234", + content = "hello", + timestamp = Date(0), + senderPeerID = peerID, + ) + ) + + assertEquals(peopleIdentity, chatIdentity) + } + + @Test + fun `full Nostr identity wins over a truncated routing alias`() { + val pubkey = "0123456789ABCDEF".repeat(4) + val identity = peerIdentityForMessage( + BitchatMessage( + sender = "alice", + content = "hello", + timestamp = Date(0), + senderPeerID = "nostr_${pubkey.take(16)}", + senderNostrPubkey = pubkey, + ) + ) + + assertEquals(PeerIdentity.nostr(pubkey), identity) + } + + private fun rgbToHsv(r: Float, g: Float, b: Float, out: FloatArray) { + val max = maxOf(r, g, b) + val min = minOf(r, g, b) + val delta = max - min + out[0] = when { + delta == 0f -> 0f + max == r -> (60f * (((g - b) / delta) % 6f) + 360f) % 360f + max == g -> 60f * (((b - r) / delta) + 2f) + else -> 60f * (((r - g) / delta) + 4f) + } + out[1] = if (max == 0f) 0f else delta / max + out[2] = max + } } diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt new file mode 100644 index 00000000..869ae924 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt @@ -0,0 +1,130 @@ +package com.bitchat.android.ui + +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GeohashPresenceGroupingTest { + + private fun person(id: String, name: String = id, lastSeen: Long = 0) = GeoPerson( + id = id, + displayName = name, + lastSeen = Date(lastSeen) + ) + + @Test + fun `heartbeat anon is hidden while announced anon name remains`() { + val people = listOf( + person("local-heartbeat", "anon"), + person("named", "alice"), + person("teleported-named", "anon7674"), + person("teleported-heartbeat", "anon#04af") + ) + + val sections = sectionGeohashPeople( + people = people, + myId = null, + selfIsTeleported = false, + teleportedIds = setOf("teleported-named", "teleported-heartbeat") + ) + + assertEquals(listOf("alice"), sections.onLocation.map { it.displayName }) + assertEquals(listOf("anon7674"), sections.teleportedIn.map { it.displayName }) + assertEquals(2, sections.onLocation.size + sections.teleportedIn.size) + } + + @Test + fun `self is first in on-location section when not teleported`() { + val sections = sectionGeohashPeople( + people = listOf( + person("recent", lastSeen = 3_000), + person("me", name = "anon", lastSeen = 0), + person("older", lastSeen = 1_000) + ), + myId = "me", + selfIsTeleported = false, + teleportedIds = emptySet() + ) + + assertEquals(listOf("me", "recent", "older"), sections.onLocation.map { it.id }) + assertTrue(sections.teleportedIn.isEmpty()) + } + + @Test + fun `self is first in teleported section when teleported`() { + val sections = sectionGeohashPeople( + people = listOf( + person("remote-teleport", lastSeen = 3_000), + person("me", lastSeen = 0), + person("local", lastSeen = 2_000) + ), + myId = "ME", + selfIsTeleported = true, + teleportedIds = setOf("remote-teleport") + ) + + assertEquals(listOf("local"), sections.onLocation.map { it.id }) + assertEquals(listOf("me", "remote-teleport"), sections.teleportedIn.map { it.id }) + } + + @Test + fun `remote teleport matching is case insensitive`() { + val sections = sectionGeohashPeople( + people = listOf(person("ABCDEF")), + myId = null, + selfIsTeleported = false, + teleportedIds = setOf("abcdef") + ) + + assertTrue(sections.onLocation.isEmpty()) + assertEquals(listOf("ABCDEF"), sections.teleportedIn.map { it.id }) + } + + @Test + fun `duplicate nicknames are detected across case and sections`() { + val duplicates = duplicateGeohashBaseNames( + listOf( + person("first", "Alice"), + person("second", "alice"), + person("third", "bob") + ) + ) + + assertEquals(setOf("alice"), duplicates) + } + + @Test + fun `duplicate nickname gets the same last-four ID suffix as chat`() { + assertEquals( + "#cdef", + geohashIdentitySuffix(person("0123456789abcdef", "alice"), showHashSuffix = true) + ) + assertEquals( + "", + geohashIdentitySuffix(person("0123456789abcdef", "alice"), showHashSuffix = false) + ) + } + + @Test + fun `existing chat-style suffix is preserved`() { + assertEquals( + "#04af", + geohashIdentitySuffix(person("0123456789abcdef", "alice#04af"), showHashSuffix = true) + ) + } + + @Test + fun `disambiguated display name matches the mention token used by chat`() { + val alice = person("0123456789abcdef", "alice") + + assertEquals( + "alice#cdef", + disambiguatedGeohashDisplayName(alice, duplicateBaseNames = setOf("alice")) + ) + assertEquals( + "alice", + disambiguatedGeohashDisplayName(alice, duplicateBaseNames = emptySet()) + ) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt b/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt new file mode 100644 index 00000000..e53665ca --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt @@ -0,0 +1,49 @@ +package com.bitchat.android.ui + +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class LocationChannelsSheetTest { + + @Test + fun `teleported channel gets a standalone row`() { + val nearby = listOf(channel("u33dc")) + val teleported = channel("dr5ru") + + assertEquals( + teleported, + selectedLocationChannelOutsideNearby(ChannelID.Location(teleported), nearby) + ) + } + + @Test + fun `nearby selected channel is not duplicated`() { + val nearby = channel("u33dc") + + assertNull( + selectedLocationChannelOutsideNearby( + ChannelID.Location(channel("U33DC")), + listOf(nearby) + ) + ) + } + + @Test + fun `mesh selection has no standalone location row`() { + assertNull( + selectedLocationChannelOutsideNearby( + ChannelID.Mesh, + listOf(channel("u33dc")) + ) + ) + } + + private fun channel(geohash: String) = GeohashChannel( + level = GeohashChannelLevel.CITY, + geohash = geohash + ) +} diff --git a/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt new file mode 100644 index 00000000..607aaf1e --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt @@ -0,0 +1,54 @@ +package com.bitchat.android.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MentionSuggestionsTest { + + @Test + fun `only users without an announced nickname are excluded from mentions`() { + val suggestions = filterMentionCandidates( + candidates = listOf( + "anon", + "anon#04af", + "anon7674#df5b", + "alice#1234", + "anonymous", + "anonracer#04af" + ), + query = "" + ) + + assertEquals( + listOf("alice#1234", "anon7674#df5b", "anonracer#04af", "anonymous"), + suggestions + ) + assertTrue(suggestions.none(::isUnannouncedNickname)) + } + + @Test + fun `mention filtering is case insensitive and removes duplicates`() { + val suggestions = filterMentionCandidates( + candidates = listOf("Bob#1234", "bob#1234", "bobby#5678", "alice#9999"), + query = "BO" + ) + + assertEquals(listOf("Bob#1234", "bobby#5678"), suggestions) + } + + @Test + fun `announced names beginning with anon stay mentionable`() { + assertTrue(isUnannouncedNickname("anon")) + assertTrue(isUnannouncedNickname("anon#04af")) + assertFalse(isUnannouncedNickname("anon1234#04af")) + assertFalse(isUnannouncedNickname("anonymous#04af")) + assertFalse(isUnannouncedNickname("anonracer")) + } + + @Test + fun `mention popup viewport is capped at five rows`() { + assertEquals(5, MaxVisibleMentionSuggestions) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt new file mode 100644 index 00000000..fb534e69 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt @@ -0,0 +1,186 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The arrival tracker decides which messages animate in. + * + * Getting it wrong is not subtle: animate too eagerly and every old message replays its entrance + * whenever `LazyColumn` recycles it back into view, which looks broken during a scroll. + */ +class MessageArrivalTrackerTest { + + private var seq = 0 + + private fun msg(id: String = "m${seq++}") = BitchatMessage( + id = id, + sender = "alice", + content = "hello", + timestamp = Date(0), + ) + + @Test + fun `first load adopts everything silently`() { + val tracker = MessageArrivalTracker() + val initial = listOf(msg("a"), msg("b"), msg("c")) + + assertTrue( + "a whole screenful animating on open reads as a glitch", + tracker.arrivals(initial).isEmpty() + ) + } + + @Test + fun `a message appended after the first load animates`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a"), msg("b")) + tracker.arrivals(messages) + + messages += msg("c") + assertEquals(setOf("c"), tracker.arrivals(messages)) + } + + @Test + fun `an empty first load still lets the first real message animate`() { + // A brand new conversation seeds with nothing, so its opening message is a genuine arrival. + val tracker = MessageArrivalTracker() + tracker.arrivals(emptyList()) + + assertEquals(setOf("a"), tracker.arrivals(listOf(msg("a")))) + } + + @Test + fun `re-presenting the same list animates nothing`() { + val tracker = MessageArrivalTracker() + val messages = listOf(msg("a"), msg("b")) + tracker.arrivals(messages) + + assertTrue(tracker.arrivals(messages).isEmpty()) + assertTrue(tracker.arrivals(messages).isEmpty()) + } + + @Test + fun `an already-seen message never animates again`() { + // Stands in for LazyColumn recycling an old row back into view mid-scroll. + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a")) + tracker.arrivals(messages) + messages += msg("b") + tracker.arrivals(messages) + + assertTrue(tracker.arrivals(messages).isEmpty()) + } + + @Test + fun `only the new messages in a batch animate`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a")) + tracker.arrivals(messages) + + messages += listOf(msg("b"), msg("c")) + assertEquals(setOf("b", "c"), tracker.arrivals(messages)) + } + + @Test + fun `a burst larger than the cap is adopted silently`() { + // History sync: animating hundreds of rows would spend the whole frame budget on motion + // nobody asked to see. + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("seed")) + tracker.arrivals(messages) + + repeat(MaxAnimatedArrivals + 1) { messages += msg("burst$it") } + assertTrue(tracker.arrivals(messages).isEmpty()) + } + + @Test + fun `a burst exactly at the cap still animates`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("seed")) + tracker.arrivals(messages) + + repeat(MaxAnimatedArrivals) { messages += msg("b$it") } + assertEquals(MaxAnimatedArrivals, tracker.arrivals(messages).size) + } + + @Test + fun `clearing the conversation prunes the known set`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a"), msg("b"), msg("c")) + tracker.arrivals(messages) + + tracker.arrivals(emptyList()) + assertTrue("stale ids would leak and grow without bound", tracker.known.isEmpty()) + } + + @Test + fun `a message re-added after a clear animates again`() { + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"))) + tracker.arrivals(emptyList()) + + assertEquals(setOf("a"), tracker.arrivals(listOf(msg("a")))) + } + + @Test + fun `a wholesale replacement animates nothing`() { + // Switching channels, or /clear followed by fresh content: the incoming list shares no ids + // with the outgoing one, so it is a different conversation rather than a burst of arrivals. + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"), msg("b"))) + + val other = listOf(msg("x"), msg("y")) + assertTrue( + "a different conversation must not slide every message in", + tracker.arrivals(other).isEmpty() + ) + } + + @Test + fun `a small replacement below the burst cap still animates nothing`() { + // The burst cap alone would not catch this: two messages is well under it. + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"), msg("b"), msg("c"))) + + assertTrue(tracker.arrivals(listOf(msg("x"), msg("y"))).isEmpty()) + } + + @Test + fun `a replacement that overlaps is treated as normal arrivals`() { + // Still the same conversation if anything carries over, so genuine new messages animate. + val tracker = MessageArrivalTracker() + val kept = msg("a") + tracker.arrivals(listOf(kept, msg("b"))) + + assertEquals(setOf("c"), tracker.arrivals(listOf(kept, msg("c")))) + } + + @Test + fun `after a wholesale replacement, later arrivals animate normally`() { + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"))) + + val switched = mutableListOf(msg("x")) + tracker.arrivals(switched) // adopted silently + + switched += msg("y") + assertEquals(setOf("y"), tracker.arrivals(switched)) + } + + @Test + fun `the known set never outgrows the conversation`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a"), msg("b")) + tracker.arrivals(messages) + + // Simulate a channel switch: entirely different messages, same list size. + val switched = listOf(msg("x"), msg("y")) + tracker.arrivals(switched) + + assertEquals(setOf("x", "y"), tracker.known) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt new file mode 100644 index 00000000..eea760f7 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt @@ -0,0 +1,154 @@ +package com.bitchat.android.ui + +import androidx.compose.ui.unit.dp +import com.bitchat.android.model.BitchatMessage +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Grouping decides whether a message repeats its author's name. Getting it wrong either spams + * the transcript with redundant labels or, worse, silently attributes one person's message to + * another — hence the emphasis on the negative cases here. + */ +class MessageGroupingTest { + + private val base = 1_700_000_000_000L + + private fun message( + sender: String = "alice", + senderPeerId: String? = "peer-alice", + offsetMs: Long = 0L, + isPrivate: Boolean = false, + channel: String? = null, + content: String = "hello", + ) = BitchatMessage( + sender = sender, + content = content, + timestamp = Date(base + offsetMs), + isPrivate = isPrivate, + senderPeerID = senderPeerId, + channel = channel, + ) + + @Test + fun `first message in a list never groups`() { + assertFalse(MessageGrouping.shouldGroup(previous = null, current = message())) + } + + @Test + fun `consecutive messages from the same peer group`() { + val first = message(offsetMs = 0) + val second = message(offsetMs = 30_000) + + assertTrue(MessageGrouping.shouldGroup(first, second)) + } + + @Test + fun `messages from different peers do not group`() { + val first = message(sender = "alice", senderPeerId = "peer-alice") + val second = message(sender = "bob", senderPeerId = "peer-bob", offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(first, second)) + } + + @Test + fun `same nickname but different peer id does not group`() { + // Two people can pick the same nickname; peer ID is the authority. + val first = message(sender = "alice", senderPeerId = "peer-one") + val second = message(sender = "alice", senderPeerId = "peer-two", offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(first, second)) + } + + @Test + fun `falls back to display name when peer ids are unavailable`() { + val first = message(sender = "alice#04af", senderPeerId = null) + val second = message(sender = "alice#04af", senderPeerId = null, offsetMs = 1_000) + val other = message(sender = "bob#1122", senderPeerId = null, offsetMs = 2_000) + + assertTrue(MessageGrouping.shouldGroup(first, second)) + assertFalse(MessageGrouping.shouldGroup(second, other)) + } + + @Test + fun `messages outside the grouping window start a new group`() { + val first = message(offsetMs = 0) + val justInside = message(offsetMs = MessageGrouping.GROUPING_WINDOW_MS) + val justOutside = message(offsetMs = MessageGrouping.GROUPING_WINDOW_MS + 1) + + assertTrue(MessageGrouping.shouldGroup(first, justInside)) + assertFalse(MessageGrouping.shouldGroup(first, justOutside)) + } + + @Test + fun `out of order timestamps do not group`() { + // Mesh delivery can surface messages out of order; a negative delta means we cannot + // reason about adjacency, so fall back to showing the sender. + val later = message(offsetMs = 60_000) + val earlier = message(offsetMs = 0) + + assertFalse(MessageGrouping.shouldGroup(later, earlier)) + } + + @Test + fun `system messages never group in either direction`() { + val system = message(sender = "system", senderPeerId = null, content = "tor started") + val user = message(offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(system, user)) + assertFalse(MessageGrouping.shouldGroup(user, system.copyWithOffset(2_000))) + assertFalse(MessageGrouping.shouldGroup(system, system.copyWithOffset(1_000))) + } + + @Test + fun `grouping does not cross the public private boundary`() { + val public = message(isPrivate = false) + val private = message(isPrivate = true, offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(public, private)) + } + + @Test + fun `grouping does not cross channels`() { + val inGeneral = message(channel = "#general") + val inRandom = message(channel = "#random", offsetMs = 1_000) + val inNoChannel = message(channel = null, offsetMs = 2_000) + + assertFalse(MessageGrouping.shouldGroup(inGeneral, inRandom)) + assertFalse(MessageGrouping.shouldGroup(inGeneral, inNoChannel)) + } + + @Test + fun `peer id comparison ignores case`() { + val first = message(senderPeerId = "PEER-Alice") + val second = message(senderPeerId = "peer-alice", offsetMs = 1_000) + + assertTrue(MessageGrouping.shouldGroup(first, second)) + } + + // MARK: - Spacing + + @Test + fun `spacing is zero for the very first row`() { + assertEquals(0.dp, MessageGrouping.topSpacingFor(isGrouped = false, isFirstInList = true)) + assertEquals(0.dp, MessageGrouping.topSpacingFor(isGrouped = true, isFirstInList = true)) + } + + @Test + fun `all transcript rows use the exported eight dp rhythm`() { + val grouped = MessageGrouping.topSpacingFor(isGrouped = true, isFirstInList = false) + val newGroup = MessageGrouping.topSpacingFor(isGrouped = false, isFirstInList = false) + + assertEquals(8.dp, grouped) + assertEquals(8.dp, newGroup) + assertEquals(grouped, newGroup) + assertEquals(8.dp, MessageGrouping.SENDER_TOP_PADDING) + assertEquals(4.dp, MessageGrouping.SENDER_TO_BODY_SPACING) + } + + private fun BitchatMessage.copyWithOffset(offsetMs: Long) = + copy(timestamp = Date(base + offsetMs)) +} diff --git a/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt b/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt new file mode 100644 index 00000000..d1381170 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt @@ -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) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/noise/NoiseExternalVectorTest.kt b/app/src/test/kotlin/com/bitchat/android/noise/NoiseExternalVectorTest.kt new file mode 100644 index 00000000..70badb96 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/noise/NoiseExternalVectorTest.kt @@ -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() + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt index 63f5e3c1..1e18fd6a 100644 --- a/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt @@ -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() + } + } } diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt new file mode 100644 index 00000000..7bb4aa30 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt @@ -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) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySubscriptionRaceTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySubscriptionRaceTest.kt index c8c9c7fe..3388af50 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySubscriptionRaceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySubscriptionRaceTest.kt @@ -8,6 +8,7 @@ import okhttp3.Request import okhttp3.WebSocket import okio.ByteString import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -51,6 +52,47 @@ class NostrRelaySubscriptionRaceTest { assertTrue(deduplicator.contains(event.id)) } + @Test + fun ndrAdapterNeverUsesConnectedLiveLocationOnlyRelay() { + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + val manager = NostrRelayManager( + scope, + NostrEventDeduplicator(maxCapacity = 8) + ) + val accountRelayUrl = NostrRelayManager.defaultRelays().first() + val liveOnlyRelayUrl = "wss://live-location-only.example" + val accountSocket = RecordingWebSocket() + val liveOnlySocket = RecordingWebSocket() + installConnection(manager, accountRelayUrl, accountSocket) + installConnection(manager, liveOnlyRelayUrl, liveOnlySocket) + val adapter = BitchatNdrRelayAdapter(manager) + + adapter.subscribe( + filter = NostrFilter(kinds = listOf(1060)), + id = "ndr-account-only" + ) { true } + + val event = NostrEvent( + id = "7c".repeat(32), + pubkey = "7d".repeat(32), + createdAt = 1, + kind = 1060, + tags = emptyList(), + content = "ciphertext", + sig = "signature" + ) + var accepted: Boolean? = null + adapter.sendEventConfirmed(event) { accepted = it } + + assertTrue(accountSocket.messages.any { "\"REQ\"" in it }) + assertTrue(accountSocket.messages.any { "\"EVENT\"" in it }) + assertTrue(liveOnlySocket.messages.isEmpty()) + assertEquals(null, accepted) + + adapter.cancelConfirmedEvent(event.id) + assertFalse(accepted ?: true) + } + @Suppress("UNCHECKED_CAST") private fun installConnection( manager: NostrRelayManager, @@ -102,6 +144,26 @@ class NostrRelaySubscriptionRaceTest { override fun cancel() = Unit } + private class RecordingWebSocket : WebSocket { + val messages = mutableListOf() + + override fun request(): Request = + Request.Builder().url("https://relay.example").build() + + override fun queueSize(): Long = 0L + + override fun send(text: String): Boolean { + messages += text + return true + } + + override fun send(bytes: ByteString): Boolean = false + + override fun close(code: Int, reason: String?): Boolean = true + + override fun cancel() = Unit + } + companion object { private const val RELAY_URL = "wss://relay.example" } diff --git a/app/src/test/kotlin/com/bitchat/android/onboarding/SystemStateManagerContractTest.kt b/app/src/test/kotlin/com/bitchat/android/onboarding/SystemStateManagerContractTest.kt new file mode 100644 index 00000000..a3f0341f --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/onboarding/SystemStateManagerContractTest.kt @@ -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() + 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() + 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() + val controller = Robolectric.buildActivity(ComponentActivity::class.java).create() + var enabled = 0 + val disabled = mutableListOf() + 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() + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt new file mode 100644 index 00000000..63ba28ef --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt @@ -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 + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/SyncedSocketContractTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/SyncedSocketContractTest.kt new file mode 100644 index 00000000..ed5cec9f --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/SyncedSocketContractTest.kt @@ -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()) + 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() + 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 { + 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() +} diff --git a/build.gradle.kts b/build.gradle.kts index 44c5199e..5aac5518 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") +} diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md new file mode 100644 index 00000000..0337ccf9 --- /dev/null +++ b/docs/client-rewrite-contracts.md @@ -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. diff --git a/docs/device-transport-test-matrix.md b/docs/device-transport-test-matrix.md new file mode 100644 index 00000000..8ada4018 --- /dev/null +++ b/docs/device-transport-test-matrix.md @@ -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 | | diff --git a/docs/release-gate-runbook.md b/docs/release-gate-runbook.md new file mode 100644 index 00000000..414bfbae --- /dev/null +++ b/docs/release-gate-runbook.md @@ -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. diff --git a/docs/test-implementation-plan.md b/docs/test-implementation-plan.md new file mode 100644 index 00000000..e64b633b --- /dev/null +++ b/docs/test-implementation-plan.md @@ -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. diff --git a/docs/testing-conventions.md b/docs/testing-conventions.md new file mode 100644 index 00000000..b2da4e7b --- /dev/null +++ b/docs/testing-conventions.md @@ -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. diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 00000000..b48e1209 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +"""Repository-local verification tooling.""" diff --git a/tools/coverage/__init__.py b/tools/coverage/__init__.py new file mode 100644 index 00000000..8aec01cc --- /dev/null +++ b/tools/coverage/__init__.py @@ -0,0 +1 @@ +"""Coverage verification helpers.""" diff --git a/tools/coverage/check_changed_coverage.py b/tools/coverage/check_changed_coverage.py new file mode 100644 index 00000000..fde7b1bc --- /dev/null +++ b/tools/coverage/check_changed_coverage.py @@ -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()) diff --git a/tools/coverage/test_check_changed_coverage.py b/tools/coverage/test_check_changed_coverage.py new file mode 100644 index 00000000..7c452449 --- /dev/null +++ b/tools/coverage/test_check_changed_coverage.py @@ -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 = """\ + + + + + + + + +""" + 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() diff --git a/tools/release_gate/__init__.py b/tools/release_gate/__init__.py new file mode 100644 index 00000000..e9ab0855 --- /dev/null +++ b/tools/release_gate/__init__.py @@ -0,0 +1 @@ +"""Physical-device and cross-client release-gate tooling.""" diff --git a/tools/release_gate/android_lab.py b/tools/release_gate/android_lab.py new file mode 100644 index 00000000..165bb469 --- /dev/null +++ b/tools/release_gate/android_lab.py @@ -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()) diff --git a/tools/release_gate/device-matrix.example.json b/tools/release_gate/device-matrix.example.json new file mode 100644 index 00000000..01b8f629 --- /dev/null +++ b/tools/release_gate/device-matrix.example.json @@ -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"] + } + ] +} diff --git a/tools/release_gate/release_gate.py b/tools/release_gate/release_gate.py new file mode 100644 index 00000000..fbec7e32 --- /dev/null +++ b/tools/release_gate/release_gate.py @@ -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()) diff --git a/tools/release_gate/scenarios.json b/tools/release_gate/scenarios.json new file mode 100644 index 00000000..d9b50907 --- /dev/null +++ b/tools/release_gate/scenarios.json @@ -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"] + } + ] +} diff --git a/tools/release_gate/test_release_gate.py b/tools/release_gate/test_release_gate.py new file mode 100644 index 00000000..3ca39ac8 --- /dev/null +++ b/tools/release_gate/test_release_gate.py @@ -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()