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