Merge remote-tracking branch 'origin/main' into codex/nostr-double-ratchet

# Conflicts:
#	.gitignore
#	app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
#	app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt
#	app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt
This commit is contained in:
Dev 2026-07-27 18:44:31 +03:00
commit eb85711b5f
196 changed files with 13458 additions and 4439 deletions

9
.gitignore vendored
View File

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

View File

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

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="M3 15L8 11.267L13 15V2.4C13 2.029 12.856 1.673 12.601 1.41C12.345 1.147 11.998 1 11.636 1H4.364C4.002 1 3.655 1.147 3.399 1.41C3.144 1.673 3 2.029 3 2.4V15Z" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 370 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 15L8 11.267L13 15V2.4C13 2.029 12.856 1.673 12.601 1.41C12.345 1.147 11.998 1 11.636 1H4.364C4.002 1 3.655 1.147 3.399 1.41C3.144 1.673 3 2.029 3 2.4V15Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 362 B

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -370)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M28.5 370.625H18.5C18.0027 370.625 17.5258 370.823 17.1742 371.174C16.8225 371.526 16.625 372.003 16.625 372.5V378.75C16.625 379.247 16.8225 379.724 17.1742 380.076C17.5258 380.427 18.0027 380.625 18.5 380.625H20.375V384.375L24.125 380.625H28.5C28.9973 380.625 29.4742 380.427 29.8258 380.076C30.1775 379.724 30.375 379.247 30.375 378.75V372.5C30.375 372.003 30.1775 371.526 29.8258 371.174C29.4742 370.823 28.9973 370.625 28.5 370.625Z"/>
<path d="M24.125 385.625H27.875L31.625 389.375V385.625H33.5C33.9973 385.625 34.4742 385.427 34.8258 385.076C35.1775 384.724 35.375 384.247 35.375 383.75V377.5C35.375 377.003 35.1775 376.526 34.8258 376.174C34.4742 375.823 33.9973 375.625 33.5 375.625H32.875"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 955 B

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -407)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M26.625 422.875H35.375"/>
<path d="M16.625 407.875L24.125 415.375L16.625 422.875"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -682)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M16.625 692C16.625 692 20.375 685.125 26 685.125C31.625 685.125 35.375 692 35.375 692C35.375 692 31.625 698.875 26 698.875C20.375 698.875 16.625 692 16.625 692Z"/>
<path d="M22.25 692C22.25 689.929 23.9288 688.25 26 688.25M29.75 692C29.75 694.071 28.0712 695.75 26 695.75M17.25 700.75L34.75 683.25"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 555 B

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -278)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M25.9999 297.375C28.2187 297.375 30.0174 293.178 30.0174 288C30.0174 282.822 28.2187 278.625 25.9999 278.625C23.7811 278.625 21.9824 282.822 21.9824 288C21.9824 293.178 23.7811 297.375 25.9999 297.375Z"/>
<path d="M16.625 288H35.375"/>
<path d="M26 297.375C31.1777 297.375 35.375 293.178 35.375 288C35.375 282.822 31.1777 278.625 26 278.625C20.8223 278.625 16.625 282.822 16.625 288C16.625 293.178 20.8223 297.375 26 297.375Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 687 B

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -310)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M32.25 329.375H19.75C18.715 329.375 17.875 328.535 17.875 327.5V321.25C17.875 320.215 18.715 319.375 19.75 319.375H32.25C33.285 319.375 34.125 320.215 34.125 321.25V327.5C34.125 328.535 33.285 329.375 32.25 329.375Z"/>
<path d="M21.625 316.875V315C21.625 312.584 23.5838 310.625 26 310.625C28.4162 310.625 30.375 312.584 30.375 315V316.875"/>
<path d="M26 326.25C27.0355 326.25 27.875 325.411 27.875 324.375C27.875 323.339 27.0355 322.5 26 322.5C24.9645 322.5 24.125 323.339 24.125 324.375C24.125 325.411 24.9645 326.25 26 326.25Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 792 B

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -582)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M29.5236 587.888V594.4C29.5236 596.41 32.5761 596.856 34.1749 594.139C35.5299 591.84 35.1974 588.334 33.5049 586.025C31.0149 582.629 25.2574 581.359 21.0736 584.166C17.2311 586.746 15.8624 591.968 17.9861 596.188C20.0874 600.364 25.0199 602.386 29.4861 600.876"/>
<path d="M25.8485 595.785C27.8698 595.785 29.5085 594.032 29.5085 591.87C29.5085 589.708 27.8698 587.955 25.8485 587.955C23.8271 587.955 22.1885 589.708 22.1885 591.87C22.1885 594.032 23.8271 595.785 25.8485 595.785Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 738 B

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 15.5H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 12.5V8.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="8" cy="4.5" r="4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 395 B

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path transform="translate(-16 -786)" d="M17.875 805.375H22.875M20.375 786.625V789.125M20.375 789.125L27.875 796.625M16.625 790.375L22.875 796.625M30.375 789.125L35.375 794.125M25.375 789.125L32.875 796.625M35.375 789.125H16.625V796.625H35.375V789.125ZM20.375 805.375V799.125M29.125 805.375H34.125M31.625 786.625V789.125M31.625 805.375V799.125" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 541 B

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -314)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M28.2187 328.116L25.14 327.241C25.0239 327.208 24.9198 327.142 24.8404 327.051C24.761 326.96 24.7096 326.848 24.6925 326.729L24.4338 324.907C25.0869 324.609 25.6407 324.129 26.0291 323.525C26.4176 322.921 26.6244 322.218 26.625 321.5V319.782C26.6402 318.788 26.2704 317.826 25.593 317.098C24.9156 316.37 23.9829 315.932 22.99 315.875C22.488 315.86 21.9879 315.945 21.5196 316.127C21.0513 316.308 20.6242 316.582 20.2637 316.932C19.9032 317.282 19.6166 317.7 19.421 318.163C19.2254 318.625 19.1248 319.123 19.125 319.625V321.5C19.1256 322.218 19.3324 322.921 19.7209 323.525C20.1093 324.129 20.6631 324.609 21.3162 324.907L21.0575 326.724C21.0404 326.843 20.989 326.955 20.9096 327.046C20.8302 327.137 20.7261 327.203 20.61 327.236L17.5313 328.111C17.2702 328.186 17.0406 328.343 16.8771 328.56C16.7136 328.777 16.6251 329.041 16.625 329.312V332.125H29.125V329.317C29.1249 329.046 29.0364 328.782 28.8729 328.565C28.7094 328.348 28.4798 328.191 28.2187 328.116Z"/>
<path d="M31.625 332.125H35.375V328.101C35.375 327.823 35.2819 327.552 35.1104 327.332C34.939 327.113 34.6991 326.956 34.4288 326.889L30.7825 325.977C30.6618 325.947 30.5528 325.882 30.4695 325.789C30.3863 325.697 30.3324 325.582 30.315 325.459L30.0587 323.657C30.7119 323.359 31.2657 322.879 31.6541 322.275C32.0426 321.671 32.2494 320.968 32.25 320.25V318.532C32.2652 317.538 31.8954 316.576 31.218 315.848C30.5406 315.12 29.6079 314.682 28.615 314.625C27.9181 314.603 27.229 314.777 26.625 315.125"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -242)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M27.725 252.271L28.1875 255.125H31.48C32.3075 255.125 33.0375 255.669 33.2763 256.461L34.75 261.375H17.25L18.7237 256.461C18.9612 255.669 19.6912 255.125 20.52 255.125H23.8125L24.275 252.271"/>
<path d="M30.375 247C30.375 244.584 28.4162 242.625 26 242.625C23.5838 242.625 21.625 244.584 21.625 247V248.25C21.625 250.666 23.5838 252.625 26 252.625C28.4162 252.625 30.375 250.666 30.375 248.25V247Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 655 B

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -378)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M25.375 386.125C26.0654 386.125 26.625 385.565 26.625 384.875C26.625 384.185 26.0654 383.625 25.375 383.625C24.6846 383.625 24.125 384.185 24.125 384.875C24.125 385.565 24.6846 386.125 25.375 386.125Z"/>
<path d="M25.375 397.375V386.125M21.625 397.375H29.125M28.9102 388.41C29.8475 387.472 30.3741 386.201 30.3741 384.875C30.3741 383.549 29.8475 382.278 28.9102 381.34M31.5625 391.062C32.3751 390.25 33.0197 389.285 33.4595 388.224C33.8993 387.162 34.1256 386.024 34.1256 384.875C34.1256 383.726 33.8993 382.588 33.4595 381.526C33.0197 380.465 32.3751 379.5 31.5625 378.688M21.8399 388.41C20.9026 387.472 20.376 386.201 20.376 384.875C20.376 383.549 20.9026 382.278 21.8399 381.34M19.1876 391.062C18.375 390.25 17.7304 389.285 17.2907 388.224C16.8509 387.162 16.6245 386.024 16.6245 384.875C16.6245 383.726 16.8509 382.588 17.2907 381.526C17.7304 380.465 18.375 379.5 19.1876 378.688"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path transform="translate(-16 -734)" d="M16.625 749.625H16.88C18.3088 749.625 19.6688 749.014 20.6175 747.946L27.6338 740.053C28.5825 738.985 29.9425 738.374 31.3712 738.374H35.375M31.625 734.625L35.375 738.375L31.625 742.125M26.3477 746.5L27.6339 747.946C28.5827 749.014 29.9427 749.625 31.3714 749.625H35.3752M16.625 738.375H16.88C18.3088 738.375 19.6688 738.986 20.6175 740.054L21.9025 741.5M31.625 753.375L35.375 749.625L31.625 745.875" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 638 B

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path transform="translate(-16 -446)" d="M26 446.925L28.1637 453.836H35.375L29.645 458.143L31.8975 465.075L26 460.79L20.1025 465.075L22.355 458.143L16.625 453.836H23.8363L26 446.925Z" fill="currentColor" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 388 B

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path transform="translate(-16 -446)" d="M26 446.925L28.1637 453.836H35.375L29.645 458.143L31.8975 465.075L26 460.79L20.1025 465.075L22.355 458.143L16.625 453.836H23.8363L26 446.925Z" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="7.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="3 3"/>
<circle cx="5" cy="6" r="1" fill="currentColor"/><circle cx="11" cy="6" r="1" fill="currentColor"/>
<path d="M5.5 10.5H10.5" stroke="currentColor" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 405 B

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M0 5H2.5V12.5H0ZM2.5 2.5H5V15H2.5ZM5 0H7.5V15H5ZM7.5 0H10V17.5H7.5ZM10 0H12.5V17.5H10ZM12.5 0H15V20H12.5ZM15 2.5H17.5V15H15ZM17.5 5H20V12.5H17.5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 271 B

View File

@ -0,0 +1,8 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -242)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.1387 252.139C22.4278 250.851 24.1757 250.127 25.998 250.127C27.8204 250.127 29.5683 250.851 30.8574 252.139"/>
<path d="M27.25 255.601C27.4831 255.81 27.6603 256.073 27.7656 256.368C27.8708 256.662 27.9008 256.978 27.8527 257.287C27.8046 257.596 27.68 257.888 27.4902 258.137C27.3004 258.385 27.0514 258.582 26.766 258.71C26.4806 258.838 26.1677 258.892 25.856 258.868C25.5442 258.844 25.2433 258.743 24.9809 258.573C24.7184 258.403 24.5026 258.17 24.3531 257.895C24.2036 257.62 24.1252 257.313 24.125 257"/>
<path d="M17.6025 248.604C19.8294 246.378 22.8493 245.127 25.9982 245.127C29.147 245.127 32.1669 246.378 34.3938 248.604"/>
<path d="M34.125 243.875L19.125 258.875"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 942 B

View File

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

View File

@ -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<Job?>(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),
)
}
}

View File

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

View File

@ -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,
)
}
) {
CompositionLocalProvider(LocalSheetDismiss provides animatedDismiss) {
content()
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String> = 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(

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
package com.bitchat.android.nostr
import android.util.Log
import com.bitchat.android.geohash.LiveLocationPrivacyGate
import com.google.gson.Gson
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -9,6 +10,7 @@ import com.google.gson.JsonArray
import com.google.gson.JsonParser
import kotlinx.coroutines.*
import okhttp3.*
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import kotlin.math.min
@ -108,11 +110,17 @@ class NostrRelayManager internal constructor(
val handler: (NostrEvent) -> Unit,
val targetRelayUrls: Set<String>? = null, // null means all relays
val createdAt: Long = System.currentTimeMillis(),
val originGeohash: String? = null // used for logging and grouping
val liveLocationToken: Long? = null
)
// Message queue for reliability
private val messageQueue = mutableListOf<Pair<NostrEvent, List<String>>>()
private data class QueuedEvent(
val event: NostrEvent,
val targetRelays: List<String>,
val liveLocationToken: Long? = null
)
private val messageQueue = mutableListOf<QueuedEvent>()
private val messageQueueLock = Any()
private data class ConfirmedPublish(
@ -137,27 +145,49 @@ class NostrRelayManager internal constructor(
// Per-geohash relay selection
private val geohashToRelays = ConcurrentHashMap<String, Set<String>>() // geohash -> relay URLs
private val liveGeohashTokens = ConcurrentHashMap<String, Long>()
private val liveLocationRelayTokens = ConcurrentHashMap<String, Long>()
private val nonLiveRelayUrls = ConcurrentHashMap.newKeySet<String>()
private val liveLocationConnectionJobs = ConcurrentHashMap.newKeySet<Job>()
// --- Public API for geohash-specific operation ---
/**
* Compute and connect to relays for a given geohash (nearest + optional defaults), cache the mapping.
*/
fun ensureGeohashRelaysConnected(geohash: String, nRelays: Int = 5, includeDefaults: Boolean = false) {
fun ensureGeohashRelaysConnected(
geohash: String,
nRelays: Int = 5,
includeDefaults: Boolean = false,
liveLocationToken: Long? = null
) {
if (!isNetworkActionAllowed(liveLocationToken)) return
try {
val nearest = RelayDirectory.closestRelaysForGeohash(geohash, nRelays)
val selected = if (includeDefaults) {
(nearest + Companion.defaultRelays()).toSet()
} else nearest.toSet()
if (selected.isEmpty()) {
Log.w(TAG, "No relays selected for geohash=$geohash")
Log.w(TAG, "No relays selected for a geohash")
return
}
geohashToRelays[geohash] = selected
Log.d(TAG, "Geohash $geohash using ${selected.size} relays")
ensureConnectionsFor(selected)
runNetworkAction(liveLocationToken) {
geohashToRelays[geohash] = selected
if (liveLocationToken == null) {
liveGeohashTokens.remove(geohash)
nonLiveRelayUrls.addAll(selected)
} else {
liveGeohashTokens[geohash] = liveLocationToken
selected.forEach { relayUrl ->
if (relayUrl !in nonLiveRelayUrls) {
liveLocationRelayTokens[relayUrl] = liveLocationToken
}
}
}
ensureConnectionsFor(selected, liveLocationToken)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to ensure relays for $geohash: ${e.message}")
Log.e(TAG, "Failed to ensure geohash relays")
}
}
@ -177,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<String>) {
private fun isNetworkActionAllowed(liveLocationToken: Long?): Boolean =
liveLocationToken == null || LiveLocationPrivacyGate.accepts(liveLocationToken)
private fun runNetworkAction(
liveLocationToken: Long?,
action: () -> Unit
): Boolean = if (liveLocationToken == null) {
action()
true
} else {
LiveLocationPrivacyGate.runIfAllowed(liveLocationToken, action)
}
/**
* Privacy teardown is allowed to bypass an already-revoked token solely to stop
* server-side delivery. Live subscription IDs are opaque, so CLOSE carries no
* geohash. If a CLOSE cannot be queued, fail closed by dropping that socket.
*/
private fun closeSubscriptionsOnConnectedRelays(subscriptionIds: Set<String>) {
if (subscriptionIds.isEmpty()) return
val closeTargets = NostrLiveSubscriptionPrivacy.closeTargets(
liveSubscriptionIds = subscriptionIds,
subscriptionsByRelay = subscriptions,
)
closeTargets.forEach { (relayUrl, relaySubscriptionIds) ->
val webSocket = connections[relayUrl] ?: return@forEach
relaySubscriptionIds.forEach { subscriptionId ->
val request = NostrRequest.Close(subscriptionId)
val message = gson.toJson(request, NostrRequest::class.java)
val closeQueued = runCatching { webSocket.send(message) }
.getOrDefault(false)
if (!closeQueued) {
connections.remove(relayUrl, webSocket)
webSocket.cancel()
}
}
}
}
private fun revokeLiveLocationAccess() {
liveLocationConnectionJobs.forEach(Job::cancel)
liveLocationConnectionJobs.clear()
val liveSubscriptionIds = activeSubscriptions.values
.filter { it.liveLocationToken != null }
.mapTo(mutableSetOf()) { it.id }
closeSubscriptionsOnConnectedRelays(liveSubscriptionIds)
liveSubscriptionIds.forEach { id ->
activeSubscriptions.remove(id)
messageHandlers.remove(id)
}
subscriptions.replaceAll { _, ids -> ids - liveSubscriptionIds }
synchronized(messageQueueLock) {
messageQueue.removeAll { it.liveLocationToken != null }
}
liveGeohashTokens.keys.forEach(geohashToRelays::remove)
liveGeohashTokens.clear()
val liveOnlyRelayUrls = liveLocationRelayTokens.keys
.filterNotTo(mutableSetOf()) { it in nonLiveRelayUrls }
liveOnlyRelayUrls.forEach { relayUrl ->
connections.remove(relayUrl)?.cancel()
}
synchronized(relaysList) {
relaysList.removeAll { it.url in liveOnlyRelayUrls }
}
liveLocationRelayTokens.clear()
updateRelaysList()
updateConnectionStatus()
}
private fun ensureConnectionsFor(
relayUrls: Set<String>,
liveLocationToken: Long? = null
) {
if (!isNetworkActionAllowed(liveLocationToken)) return
// Ensure relays are tracked for UI/status
relayUrls.forEach { url ->
if (relaysList.none { it.url == url }) {
@ -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<String>? = null) {
fun sendEvent(
event: NostrEvent,
relayUrls: List<String>? = null,
liveLocationToken: Long? = null
) {
val targetRelays = relayUrls ?: relaysList.map { it.url }
// Add to queue for reliability
synchronized(messageQueueLock) {
messageQueue.add(Pair(event, targetRelays))
}
// Attempt immediate send
scope.launch {
targetRelays.forEach { relayUrl ->
val webSocket = connections[relayUrl]
if (webSocket != null) {
sendToRelay(event, webSocket, relayUrl)
val queued = runNetworkAction(liveLocationToken) {
synchronized(messageQueueLock) {
messageQueue.add(QueuedEvent(event, targetRelays, liveLocationToken))
}
scope.launch {
if (!isNetworkActionAllowed(liveLocationToken)) return@launch
targetRelays.forEach { relayUrl ->
val webSocket = connections[relayUrl]
if (webSocket != null) {
sendToRelay(event, webSocket, relayUrl, liveLocationToken)
}
}
}
}
if (!queued) return
}
/**
@ -389,14 +528,15 @@ class NostrRelayManager internal constructor(
filter: NostrFilter,
id: String = generateSubscriptionId(),
handler: (NostrEvent) -> Unit,
targetRelayUrls: List<String>? = null
targetRelayUrls: List<String>? = null,
liveLocationToken: Long? = null
): String {
// Store subscription info for persistent tracking
val subscriptionInfo = SubscriptionInfo(
id = id,
filter = filter,
handler = handler,
targetRelayUrls = targetRelayUrls?.toSet()
targetRelayUrls = targetRelayUrls?.toSet(),
liveLocationToken = liveLocationToken
)
return registerSubscription(
subscriptionInfo = subscriptionInfo,
@ -412,12 +552,14 @@ class NostrRelayManager internal constructor(
fun subscribeAfterSuccessfulProcessing(
filter: NostrFilter,
id: String,
targetRelayUrls: List<String>? = 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)
}
}
}

View File

@ -2,6 +2,7 @@ package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import com.bitchat.android.geohash.LiveLocationPrivacyGate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -28,18 +29,54 @@ class NostrSubscriptionManager(
}
/** Subscribe to geohash chat messages only (kind 20000) — low-volume, kept alive in background. */
fun subscribeGeohashMessages(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
fun subscribeGeohashMessages(
geohash: String,
sinceMs: Long,
limit: Int,
id: String,
handler: (NostrEvent) -> Unit,
liveLocationToken: Long? = null
) {
scope.launch {
if (liveLocationToken != null &&
!LiveLocationPrivacyGate.accepts(liveLocationToken)
) return@launch
val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit)
relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5)
relayManager.subscribeForGeohash(
geohash,
filter,
id,
handler,
includeDefaults = false,
nRelays = 5,
liveLocationToken = liveLocationToken
)
}
}
/** Subscribe to geohash presence heartbeats only (kind 20001) — high-volume, paused in background. */
fun subscribeGeohashPresence(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
fun subscribeGeohashPresence(
geohash: String,
sinceMs: Long,
limit: Int,
id: String,
handler: (NostrEvent) -> Unit,
liveLocationToken: Long? = null
) {
scope.launch {
if (liveLocationToken != null &&
!LiveLocationPrivacyGate.accepts(liveLocationToken)
) return@launch
val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit)
relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5)
relayManager.subscribeForGeohash(
geohash,
filter,
id,
handler,
includeDefaults = false,
nRelays = 5,
liveLocationToken = liveLocationToken
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Float> =
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 <T> AnimatedRowColumn(
items: List<T>,
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)
}
}
}
}
}
}

View File

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

View File

@ -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<CommandSuggestion>,
showMentionSuggestions: Boolean,
mentionSuggestions: List<String>,
mentionPeerIdentities: Map<String, PeerIdentity> = 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<String>()) }
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()
}
)
}
}

View File

@ -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<String, PeerIdentity> = 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<String, String> {
}
/**
* 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<BitchatMessage>,
knownPeers: List<Pair<String, PeerIdentity>> = emptyList(),
): Map<String, PeerIdentity> {
val candidates = linkedMapOf<String, MutableSet<PeerIdentity>>()
fun add(displayName: String, identity: PeerIdentity) {
val normalizedName = displayName.trim().removePrefix("@")
if (normalizedName.isEmpty()) return
val (baseName, suffix) = splitSuffix(normalizedName)
val exactKey = normalizedName.lowercase(Locale.ROOT)
candidates.getOrPut(exactKey) { linkedSetOf() }.add(identity)
if (suffix.isNotEmpty()) {
val baseKey = baseName.lowercase(Locale.ROOT)
candidates.getOrPut(baseKey) { linkedSetOf() }.add(identity)
}
}
messages
.asSequence()
.filterNot { it.sender == "system" }
.forEach { add(it.sender, peerIdentityForMessage(it)) }
knownPeers.forEach { (displayName, identity) -> add(displayName, identity) }
return candidates.mapNotNull { (token, identities) ->
identities.singleOrNull()?.let { token to it }
}.toMap()
}
internal fun resolveMentionPeerIdentity(
mention: String,
mentionPeerIdentities: Map<String, PeerIdentity>,
): PeerIdentity? {
val mentionWithoutAt = mention.trim().removePrefix("@")
val baseName = splitSuffix(mentionWithoutAt).first
return mentionPeerIdentities[mentionWithoutAt.lowercase(Locale.ROOT)]
?: mentionPeerIdentities[baseName.lowercase(Locale.ROOT)]
}
/**
* Resolve the deterministic color for a mention on every surface that displays one.
*
* Exact suffixed tokens win; an unsuffixed nickname is only present in the identity map when it is
* unambiguous. The nickname fallback preserves the legacy behavior for peers with no stable ID.
*/
internal fun colorForMention(
mention: String,
mentionPeerIdentities: Map<String, PeerIdentity>,
palette: BitchatPalette,
): Color {
val mentionWithoutAt = mention.trim().removePrefix("@")
val identity = resolveMentionPeerIdentity(mentionWithoutAt, mentionPeerIdentities)
?: PeerIdentity.nickname(mentionWithoutAt)
return colorForPeer(identity, palette)
}
/**
* A bare `anon` label means the geohash heartbeat has not announced a username yet. The transport
* may append a `#abcd` disambiguator, which does not turn it into an announced name. Names such as
* `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<String>?,
currentUserNickname: String,
baseColor: Color,
isSelf: Boolean,
contentColor: Color = baseColor,
palette: BitchatPalette,
contentColor: Color,
linkColor: Color,
mentionPeerIdentities: Map<String, PeerIdentity>,
) {
// iOS-style patterns: allow optional '#abcd' suffix in mentions
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex()
val hashtagMatches = hashtagPattern.findAll(content).toList()
val mentionMatches = mentionPattern.findAll(content).toList()
val mentionMatches = MENTION_TOKEN_REGEX.findAll(content).toList()
// Combine and sort matches, but exclude hashtags that overlap with mentions
val mentionRanges = mentionMatches.map { it.range }
@ -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()
}
}

View File

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

View File

@ -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<String>) {
geohashViewModel.beginGeohashSampling(geohashes)
fun beginGeohashSampling(
liveLocationGeohashes: Collection<String>,
userSelectedGeohashes: Collection<String>,
) {
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)
}

View File

@ -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<String>,
query: String
): List<String> {
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()
}

View File

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

View File

@ -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<String, Int>()
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<GeoPerson>,
val teleportedIn: List<GeoPerson>
)
/**
* Names that require a short identity suffix, calculated across both people sections.
*
* Matching is case-insensitive to mirror geohash chat's nickname collision handling.
*/
internal fun duplicateGeohashBaseNames(people: List<GeoPerson>): Set<String> =
people
.groupingBy { splitSuffix(it.displayName).first.lowercase(Locale.ROOT) }
.eachCount()
.filterValues { it > 1 }
.keys
/**
* The same `#abcd` disambiguator used by geohash chat.
*
* Presence rows normally carry only a base nickname, so derive the suffix from the full Nostr
* public key when a collision exists. Preserve an already-announced suffix for compatibility.
*/
internal fun geohashIdentitySuffix(person: GeoPerson, showHashSuffix: Boolean): String {
if (!showHashSuffix) return ""
val announcedSuffix = splitSuffix(person.displayName).second
return announcedSuffix.ifEmpty { "#${person.id.takeLast(4)}" }
}
internal fun disambiguatedGeohashDisplayName(
person: GeoPerson,
duplicateBaseNames: Set<String>,
): String {
val baseName = splitSuffix(person.displayName).first
val showSuffix = baseName.lowercase(Locale.ROOT) in duplicateBaseNames
return baseName + geohashIdentitySuffix(person, showSuffix)
}
/**
* Split announced identities by how they entered this geohash. Bare `anon` heartbeat identities
* are omitted, while announced names such as `anon1234` remain ordinary participants. Self is
* retained even before a nickname announcement and is always first in the matching section.
*/
internal fun sectionGeohashPeople(
people: List<GeoPerson>,
myId: String?,
selfIsTeleported: Boolean,
teleportedIds: Set<String>
): 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<GeoPerson>(::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<GeoPerson>,
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))
}
}

View File

@ -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<WebView?>(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
)
}
}

View File

@ -8,6 +8,8 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.viewModelScope
import com.bitchat.android.geohash.GeohashNostrPrivacyPolicy
import com.bitchat.android.geohash.LiveLocationPrivacyGate
import com.bitchat.android.nostr.GeohashMessageHandler
import com.bitchat.android.nostr.GeohashRepository
import com.bitchat.android.nostr.NostrDirectMessageHandler
@ -18,15 +20,18 @@ import com.bitchat.android.nostr.NostrSubscriptionManager
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.nostr.GeohashConversationRegistry
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import java.util.Date
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.isActive
import kotlinx.coroutines.Dispatchers
import java.security.SecureRandom
import java.util.UUID
import kotlin.random.asKotlinRandom
class GeohashViewModel(
@ -69,10 +74,24 @@ class GeohashViewModel(
// Presence heartbeat firehose (kind 20001). High-volume; paused while backgrounded.
private var currentGeohashPresenceSubId: String? = null
private var currentDmSubId: String? = null
private var currentDmGeohash: String? = null
private var geoTimer: Job? = null
private var globalPresenceJob: Job? = null
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
private val activeSamplingGeohashes = mutableSetOf<String>()
private val samplingSubscriptionIds = mutableMapOf<String, String>()
private val liveSamplingSubscriptionGeohashes = mutableSetOf<String>()
private var requestedLiveSamplingGeohashes: Set<String> = emptySet()
private var requestedUserSamplingGeohashes: Set<String> = emptySet()
private val liveLocationRevocationListener: () -> Unit = {
val revokedLiveGeohashes = liveSamplingSubscriptionGeohashes.toSet()
revokedLiveGeohashes.forEach { geohash ->
samplingSubscriptionIds.remove(geohash)
activeSamplingGeohashes.remove(geohash)
}
liveSamplingSubscriptionGeohashes.clear()
requestedLiveSamplingGeohashes = emptySet()
}
// Geohash of the currently selected Location channel (null for Mesh/none).
private var activeChannelGeohash: String? = null
@ -81,6 +100,10 @@ class GeohashViewModel(
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
init {
LiveLocationPrivacyGate.addRevocationListener(liveLocationRevocationListener)
}
fun initialize() {
subscriptionManager.connect()
// Observe process lifecycle to manage background sampling
@ -126,10 +149,13 @@ class GeohashViewModel(
private fun startGlobalPresenceHeartbeat() {
globalPresenceJob?.cancel()
globalPresenceJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
// Reactively restart heartbeat whenever available channels change
locationChannelManager?.availableChannels?.collectLatest { channels ->
// Filter for REGION (2), PROVINCE (4), CITY (5) - precision <= 5
val targetGeohashes = channels.filter { it.level.precision <= 5 }.map { it.geohash }
val manager = locationChannelManager ?: return@launch
combine(
manager.availableChannels,
LiveLocationPrivacyGate.enabled
) { channels, enabled ->
GeohashNostrPrivacyPolicy.livePresenceTargets(channels, enabled)
}.collectLatest { targetGeohashes ->
if (targetGeohashes.isNotEmpty()) {
// Enter heartbeat loop for this set of channels
@ -147,8 +173,10 @@ class GeohashViewModel(
delay(stepDelay)
timeSpent += stepDelay
broadcastPresence(geohash)
broadcastLiveLocationPresence(geohash)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Global presence heartbeat error: ${e.message}")
}
@ -174,6 +202,7 @@ class GeohashViewModel(
currentGeohashMsgSubId = null
currentGeohashPresenceSubId = null
currentDmSubId = null
currentDmGeohash = null
activeChannelGeohash = null
geoTimer?.cancel()
geoTimer = null
@ -187,22 +216,52 @@ class GeohashViewModel(
dmHandler.invalidateDoubleRatchetAccount()
}
private suspend fun broadcastPresence(geohash: String) {
private suspend fun broadcastLiveLocationPresence(geohash: String) {
val manager = locationChannelManager ?: return
val token = LiveLocationPrivacyGate.captureToken() ?: return
val isCurrentLiveTarget = GeohashNostrPrivacyPolicy.livePresenceTargets(
manager.availableChannels.value,
liveLocationEnabled = true
).contains(geohash)
if (!isCurrentLiveTarget || !LiveLocationPrivacyGate.accepts(token)) return
try {
val identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity)
val relayManager = NostrRelayManager.getInstance(getApplication())
// Presence is lightweight, send to geohash relays
relayManager.sendEventToGeohash(event, geohash, includeDefaults = false, nRelays = 5)
Log.v(TAG, "💓 Sent presence heartbeat for $geohash")
var identity: com.bitchat.android.nostr.NostrIdentity? = null
LiveLocationPrivacyGate.runIfAllowed(token) {
identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
}
val preparedIdentity = identity ?: return
if (!LiveLocationPrivacyGate.accepts(token)) return
val event = NostrProtocol.createGeohashPresenceEvent(geohash, preparedIdentity)
LiveLocationPrivacyGate.runIfAllowed(token) {
val relayManager = NostrRelayManager.getInstance(getApplication())
relayManager.sendEventToGeohash(
event,
geohash,
includeDefaults = false,
nRelays = 5,
liveLocationToken = token
)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to send presence for $geohash: ${e.message}")
Log.w(TAG, "Failed to send live-location presence")
}
}
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
viewModelScope.launch {
try {
val canUseChannel = locationChannelManager
?.canUseSelectedLocationChannel(channel) == true
if (!canUseChannel) {
Log.w(TAG, "Blocked message to a stale live-location channel")
return@launch
}
val isLiveDerived = locationChannelManager
?.isSelectedChannelLiveDerived(channel) == true
val liveLocationToken = locationChannelManager
?.liveLocationTokenForSelectedChannel(channel)
if (isLiveDerived && liveLocationToken == null) return@launch
val tempId = "temp_${System.currentTimeMillis()}_${kotlin.random.Random.nextInt(1000)}"
val pow = PoWPreferenceManager.getCurrentSettings()
val localMsg = com.bitchat.android.model.BitchatMessage(
@ -216,51 +275,73 @@ class GeohashViewModel(
powDifficulty = if (pow.enabled) pow.difficulty else null
)
messageManager.addChannelMessage("geo:${channel.geohash}", localMsg)
val startedMining = pow.enabled && pow.difficulty > 0
if (startedMining) {
com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempId)
}
try {
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
val teleported = state.isTeleported.value
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
val relayManager = NostrRelayManager.getInstance(getApplication())
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
} finally {
// Ensure we stop the per-message mining animation regardless of success/failure
if (startedMining) {
com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage(tempId)
}
}
val identity = NostrIdentityBridge.deriveIdentity(
forGeohash = channel.geohash,
context = getApplication()
)
val teleported = locationChannelManager?.teleported?.value
?: state.isTeleported.value
val event = NostrProtocol.createEphemeralGeohashEvent(
content,
channel.geohash,
identity,
nickname,
teleported
)
val relayManager = NostrRelayManager.getInstance(getApplication())
relayManager.sendEventToGeohash(
event,
channel.geohash,
includeDefaults = false,
nRelays = 5,
liveLocationToken = liveLocationToken
)
} catch (e: Exception) {
Log.e(TAG, "Failed to send geohash message: ${e.message}")
}
}
}
fun beginGeohashSampling(geohashes: List<String>) {
if (geohashes.isEmpty()) {
endGeohashSampling()
return
}
// Diffing logic to avoid redundant REQ and leaks
fun beginGeohashSampling(
liveLocationGeohashes: Collection<String>,
userSelectedGeohashes: Collection<String>,
) {
requestedLiveSamplingGeohashes = liveLocationGeohashes.toSet()
requestedUserSamplingGeohashes = userSelectedGeohashes.toSet()
reconcileSamplingSubscriptions()
}
private fun reconcileSamplingSubscriptions() {
val currentSet = activeSamplingGeohashes.toSet()
val newSet = geohashes.toSet()
val newSet = GeohashNostrPrivacyPolicy.samplingTargets(
liveLocationGeohashes = requestedLiveSamplingGeohashes,
userSelectedGeohashes = requestedUserSamplingGeohashes,
liveLocationEnabled = LiveLocationPrivacyGate.isEnabled
)
val toRemove = currentSet - newSet
val toAdd = newSet - currentSet
val toPromoteToUserSelection = currentSet
.intersect(requestedUserSamplingGeohashes)
.intersect(liveSamplingSubscriptionGeohashes)
if (toAdd.isEmpty() && toRemove.isEmpty()) return
if (toAdd.isEmpty() && toRemove.isEmpty() && toPromoteToUserSelection.isEmpty()) return
Log.d(TAG, "🌍 Updating sampling: +${toAdd.size} new, -${toRemove.size} removed")
// Remove old subscriptions
toRemove.forEach { geohash ->
subscriptionManager.unsubscribe("sampling-$geohash")
unsubscribeSampling(geohash)
activeSamplingGeohashes.remove(geohash)
}
// A bookmark must remain functional after live access is revoked. Replace a
// live-tagged subscription with an untagged manual subscription immediately.
toPromoteToUserSelection.forEach { geohash ->
unsubscribeSampling(geohash)
if (isAppInForeground()) performSubscribeSampling(geohash)
}
// Add new subscriptions
activeSamplingGeohashes.addAll(toAdd)
if (isAppInForeground()) {
@ -271,11 +352,13 @@ class GeohashViewModel(
}
fun endGeohashSampling() {
requestedLiveSamplingGeohashes = emptySet()
requestedUserSamplingGeohashes = emptySet()
if (activeSamplingGeohashes.isEmpty()) return
Log.d(TAG, "🌍 Ending geohash sampling (cleaning up ${activeSamplingGeohashes.size} subs)")
activeSamplingGeohashes.toList().forEach { geohash ->
subscriptionManager.unsubscribe("sampling-$geohash")
unsubscribeSampling(geohash)
}
activeSamplingGeohashes.clear()
}
@ -293,7 +376,7 @@ class GeohashViewModel(
GeohashConversationRegistry.set(convKey, gh)
}
onStartPrivateChat(convKey)
Log.d(TAG, "🗨️ Started geohash DM with ${pubkeyHex} -> ${convKey} (geohash=${gh})")
Log.d(TAG, "🗨️ Started geohash DM")
}
fun startGeohashDMByNickname(nickname: String, onStartPrivateChat: (String) -> Unit) {
@ -346,19 +429,28 @@ class GeohashViewModel(
locationChannelManager?.select(channel) ?: run { Log.w(TAG, "Cannot select location channel - not initialized") }
}
fun ensureGeohashDMSubscriptionForConversation(conversationKey: String) {
val geohash = repo.getConversationGeohash(conversationKey) ?: return
if (currentDmGeohash == geohash && currentDmSubId != null) return
currentDmSubId?.let(subscriptionManager::unsubscribe)
currentDmSubId = null
currentDmGeohash = null
subscribeChannelDM(geohash)
}
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex)
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
val seed = "nostr:${pubkeyHex.lowercase()}"
return colorForPeerSeed(seed, isDark).copy()
}
fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity =
PeerIdentity.nostr(pubkeyHex)
private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
geoTimer?.cancel(); geoTimer = null
currentGeohashMsgSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashMsgSubId = null }
currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null }
currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null }
currentDmGeohash = null
when (channel) {
is com.bitchat.android.geohash.ChannelID.Mesh -> {
@ -370,7 +462,12 @@ class GeohashViewModel(
repo.refreshGeohashPeople()
}
is com.bitchat.android.geohash.ChannelID.Location -> {
Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}")
if (locationChannelManager?.canUseSelectedLocationChannel(channel.channel) != true) {
Log.w(TAG, "Ignoring a stale live-location channel selection")
switchLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh)
return
}
Log.d(TAG, "📍 Switching to geohash channel")
activeChannelGeohash = channel.channel.geohash
repo.setCurrentGeohash(channel.channel.geohash)
repo.refreshGeohashPeople()
@ -381,19 +478,22 @@ class GeohashViewModel(
try {
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
// We don't update participant here anymore; presence loop handles it via Kind 20001
val teleported = state.isTeleported.value
val teleported = locationChannelManager?.teleported?.value
?: state.isTeleported.value
if (teleported) repo.markTeleported(identity.publicKeyHex)
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
startGeoParticipantsTimer()
val liveLocationToken = locationChannelManager
?.liveLocationTokenForSelectedChannel(channel.channel)
// Chat message stream (kind 20000) is low-volume; keep it alive even when
// backgrounded so geohash messages still arrive.
subscribeChannelMessages(channel.channel.geohash)
subscribeChannelMessages(channel.channel.geohash, liveLocationToken)
// Presence heartbeat firehose (kind 20001) is the high-volume data hog; only
// run it in the foreground. It is restored in onStart() and torn down in onStop().
if (isAppInForeground()) {
subscribeChannelPresence(channel.channel.geohash)
subscribeChannelPresence(channel.channel.geohash, liveLocationToken)
}
// Gift-wrap DM subscription is lightweight (filtered to our pubkey) and is
// kept alive in the background so geohash DMs still arrive.
@ -411,14 +511,18 @@ class GeohashViewModel(
* Subscribe to the chat message stream (kind 20000) for a geohash channel.
* Low-volume; kept alive in the background so messages keep arriving.
*/
private fun subscribeChannelMessages(geohash: String) {
val subId = "geohash-$geohash"; currentGeohashMsgSubId = subId
private fun subscribeChannelMessages(
geohash: String,
liveLocationToken: Long?
) {
val subId = "geohash-${UUID.randomUUID()}"; currentGeohashMsgSubId = subId
subscriptionManager.subscribeGeohashMessages(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 3600000L,
limit = 200,
id = subId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
handler = { event -> geohashMessageHandler.onEvent(event, geohash) },
liveLocationToken = liveLocationToken
)
}
@ -427,14 +531,18 @@ class GeohashViewModel(
* High-volume; only used to refresh the participant list, so it is torn down in
* onStop() and restored in onStart() to cut background mobile data.
*/
private fun subscribeChannelPresence(geohash: String) {
val subId = "geohash-presence-$geohash"; currentGeohashPresenceSubId = subId
private fun subscribeChannelPresence(
geohash: String,
liveLocationToken: Long?
) {
val subId = "geohash-presence-${UUID.randomUUID()}"; currentGeohashPresenceSubId = subId
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 3600000L,
limit = 200,
id = subId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
handler = { event -> geohashMessageHandler.onEvent(event, geohash) },
liveLocationToken = liveLocationToken
)
}
@ -443,18 +551,18 @@ class GeohashViewModel(
* Lightweight (filtered to our pubkey); kept alive in the background.
*/
private fun subscribeChannelDM(geohash: String) {
viewModelScope.launch {
val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId
subscriptionManager.subscribeGiftWraps(
pubkey = dmIdentity.publicKeyHex,
sinceMs = System.currentTimeMillis() - 172800000L,
id = dmSubId,
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
)
// Also register alias in global registry for routing convenience
GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
}
val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
val dmSubId = "geo-dm-${UUID.randomUUID()}"
currentDmSubId = dmSubId
currentDmGeohash = geohash
subscriptionManager.subscribeGiftWraps(
pubkey = dmIdentity.publicKeyHex,
sinceMs = System.currentTimeMillis() - 172800000L,
id = dmSubId,
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
)
// Also register alias in global registry for routing convenience
GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
}
private fun startGeoParticipantsTimer() {
@ -471,13 +579,29 @@ class GeohashViewModel(
kotlin.runCatching {
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
}
LiveLocationPrivacyGate.removeRevocationListener(liveLocationRevocationListener)
}
override fun onStart(owner: LifecycleOwner) {
Log.d(TAG, "🌍 App foregrounded: resuming Nostr streaming")
// Android permission may have changed while backgrounded. Invalidate the
// process-wide token before restoring any subscription or heartbeat.
locationChannelManager?.syncPermissionState()
// Restore the presence heartbeat firehose for the selected geohash channel.
// (The chat message stream is kept alive in the background, so it is not restored here.)
activeChannelGeohash?.let { subscribeChannelPresence(it) }
val selected = locationChannelManager?.selectedChannel?.value
val selectedLocation = selected as? com.bitchat.android.geohash.ChannelID.Location
if (selectedLocation != null &&
selectedLocation.channel.geohash == activeChannelGeohash &&
locationChannelManager?.canUseSelectedLocationChannel(selectedLocation.channel) == true
) {
subscribeChannelPresence(
selectedLocation.channel.geohash,
locationChannelManager
?.liveLocationTokenForSelectedChannel(selectedLocation.channel)
)
}
// Resume geohash sampling subscriptions
activeSamplingGeohashes.forEach { performSubscribeSampling(it) }
// Resume the participant-refresh polling timer if a geohash is selected
@ -496,7 +620,7 @@ class GeohashViewModel(
// The chat message stream (kind 20000) is intentionally left active so messages still arrive.
currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null }
// Drop geohash sampling subscriptions
activeSamplingGeohashes.forEach { subscriptionManager.unsubscribe("sampling-$it") }
activeSamplingGeohashes.forEach(::unsubscribeSampling)
// Stop broadcasting presence heartbeats
globalPresenceJob?.cancel(); globalPresenceJob = null
// Stop participant-refresh polling
@ -506,15 +630,48 @@ class GeohashViewModel(
}
private fun performSubscribeSampling(geohash: String) {
val subscriptionId = samplingSubscriptionIds.getOrPut(geohash) {
"sampling-${UUID.randomUUID()}"
}
// Sampling only needs participant counts, never message bodies, so it subscribes to
// presence heartbeats only (kind 20001) to keep the payload small.
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200,
id = "sampling-$geohash",
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
)
val subscribe = {
liveSamplingSubscriptionGeohashes.remove(geohash)
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200,
id = subscriptionId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
)
}
if (geohash in requestedUserSamplingGeohashes) {
subscribe()
} else if (geohash in requestedLiveSamplingGeohashes) {
val isCurrentLiveTarget = locationChannelManager
?.availableChannels
?.value
?.any { it.geohash == geohash } == true
if (!isCurrentLiveTarget) return
val token = LiveLocationPrivacyGate.captureToken() ?: return
LiveLocationPrivacyGate.runIfAllowed(token) {
liveSamplingSubscriptionGeohashes.add(geohash)
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200,
id = subscriptionId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) },
liveLocationToken = token
)
}
}
}
private fun unsubscribeSampling(geohash: String) {
samplingSubscriptionIds.remove(geohash)?.let(subscriptionManager::unsubscribe)
liveSamplingSubscriptionGeohashes.remove(geohash)
}
private fun isAppInForeground(): Boolean {

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,7 @@
package com.bitchat.android.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Link
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
@ -7,8 +9,6 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Link
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -17,11 +17,11 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import java.net.URL
@ -43,11 +43,8 @@ fun LinkPreviewPill(
) {
val context = LocalContext.current
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
// iOS-style colors
val textColor = if (isDark) Color.Green else Color(red = 0f, green = 0.5f, blue = 0f)
val backgroundColor = if (isDark) Color.Gray.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.08f)
val textColor = colorScheme.secondary
val backgroundColor = colorScheme.secondaryContainer.copy(alpha = 0.35f)
val borderColor = textColor.copy(alpha = 0.3f)
// Parse URL for host extraction
@ -87,7 +84,7 @@ fun LinkPreviewPill(
Surface(
modifier = Modifier.size(60.dp),
shape = RoundedCornerShape(8.dp),
color = Color.Blue.copy(alpha = 0.1f)
color = colorScheme.secondaryContainer
) {
Box(
modifier = Modifier.fillMaxSize(),
@ -97,7 +94,7 @@ fun LinkPreviewPill(
imageVector = Icons.Outlined.Link,
contentDescription = stringResource(com.bitchat.android.R.string.cd_link),
modifier = Modifier.size(24.dp),
tint = Color.Blue
tint = colorScheme.secondary
)
}
}
@ -110,7 +107,7 @@ fun LinkPreviewPill(
// Title - matches iOS styling
Text(
text = displayTitle,
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = FontWeight.SemiBold,
color = textColor,
@ -121,7 +118,7 @@ fun LinkPreviewPill(
// Host - matches iOS styling
Text(
text = displayHost,
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = textColor.copy(alpha = 0.6f),
maxLines = 1,

View File

@ -1,19 +1,21 @@
package com.bitchat.android.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.bitchat.android.R
import com.bitchat.android.geohash.ChannelID
@ -21,9 +23,9 @@ import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.LocationNotesManager
/**
* Location Notes button component for MainHeader
* Shows in mesh mode when location permission granted AND services enabled
* Icon turns primary color when notes exist, gray otherwise
* Location Notes button for MainHeader.
* Mesh-only with location authorized. Tor health tints the glyph with muted colours
* and a slow glow while connecting (via [rememberTorConnectionVisual]).
*/
@Composable
fun LocationNotesButton(
@ -33,18 +35,15 @@ fun LocationNotesButton(
) {
val colorScheme = MaterialTheme.colorScheme
val context = LocalContext.current
// Get channel and permission state
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val locationManager = remember { LocationChannelManager.getInstance(context) }
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false)
// Check both permission AND location services enabled
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
val locationEnabled = locationPermissionGranted && locationServicesEnabled
// Get notes count from LocationNotesManager
val notesManager = remember { LocationNotesManager.getInstance() }
val notes by notesManager.notes.collectAsStateWithLifecycle()
val notesCount = notes.size
@ -52,15 +51,22 @@ fun LocationNotesButton(
// Only show in mesh mode when location is authorized (iOS pattern)
if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) {
val hasNotes = notesCount > 0
IconButton(
onClick = onClick,
modifier = modifier.size(24.dp)
val contentDescription = stringResource(R.string.cd_location_notes)
val normalTint = if (hasNotes) colorScheme.primary else colorScheme.onSurfaceVariant
val torVisual = rememberTorConnectionVisual(normal = normalTint)
Box(
modifier = modifier
.size(44.dp)
.clip(CircleShape)
.pressScaleClickable(onClick = onClick, onClickLabel = contentDescription),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Description, // "long.text.page.and.pencil" equivalent
contentDescription = stringResource(R.string.cd_location_notes),
modifier = Modifier.size(16.dp),
tint = if (hasNotes) colorScheme.primary else Color.Gray
TorAwareHeaderIcon(
painter = painterResource(R.drawable.ic_spec_chat_bubbles),
tint = torVisual.tint,
isProgress = torVisual.isProgress,
contentDescription = contentDescription
)
}
}

View File

@ -1,16 +1,15 @@
package com.bitchat.android.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
@ -18,9 +17,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.pluralStringResource
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@ -51,10 +50,8 @@ fun LocationNotesSheet(
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val isDark = isSystemInDarkTheme()
// iOS color scheme
val accentGreen = if (isDark) Color.Green else Color(0xFF008000) // dark: green, light: dark green (0, 0.5, 0)
val colorScheme = MaterialTheme.colorScheme
val accentGreen = colorScheme.primary
// Managers
val notesManager = remember { LocationNotesManager.getInstance() }
@ -251,7 +248,7 @@ private fun LocationNotesHeader(
if (name.isNotEmpty()) {
Text(
text = name,
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
color = accentGreen
)
@ -262,7 +259,7 @@ private fun LocationNotesHeader(
// Description
Text(
text = stringResource(R.string.location_notes_description),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
@ -272,7 +269,7 @@ private fun LocationNotesHeader(
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.location_notes_relays_unavailable),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
@ -303,7 +300,7 @@ private fun NoteRow(note: LocationNotesManager.Note) {
) {
Text(
text = "@$baseName",
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
@ -312,7 +309,7 @@ private fun NoteRow(note: LocationNotesManager.Note) {
Spacer(modifier = Modifier.width(6.dp))
Text(
text = ts,
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
@ -324,7 +321,7 @@ private fun NoteRow(note: LocationNotesManager.Note) {
// Second row: content
Text(
text = note.content,
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface
)
@ -343,7 +340,7 @@ private fun NoRelaysRow(onRetry: () -> Unit) {
) {
Text(
text = stringResource(R.string.location_notes_no_relays_title),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
@ -351,14 +348,14 @@ private fun NoRelaysRow(onRetry: () -> Unit) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.location_notes_no_relays_desc),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.retry),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onRetry)
@ -385,7 +382,7 @@ private fun LoadingRow() {
Spacer(modifier = Modifier.width(10.dp))
Text(
text = stringResource(R.string.loading_location_notes),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
@ -404,7 +401,7 @@ private fun EmptyRow() {
) {
Text(
text = stringResource(R.string.location_notes_empty_title),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
@ -412,7 +409,7 @@ private fun EmptyRow() {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.location_notes_empty_desc),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
@ -440,7 +437,7 @@ private fun ErrorRow(message: String, onDismiss: () -> Unit) {
Spacer(modifier = Modifier.width(6.dp))
Text(
text = message,
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface
)
@ -448,7 +445,7 @@ private fun ErrorRow(message: String, onDismiss: () -> Unit) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.dismiss),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onDismiss)
@ -468,7 +465,6 @@ private fun LocationNotesInputSection(
nickname: String?,
onSend: () -> Unit
) {
val isDark = isSystemInDarkTheme()
val colorScheme = MaterialTheme.colorScheme
Column(
@ -482,7 +478,7 @@ private fun LocationNotesInputSection(
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "@$baseName",
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = colorScheme.onSurface
@ -506,7 +502,7 @@ private fun LocationNotesInputSection(
onValueChange = onDraftChange,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = colorScheme.primary,
fontFamily = FontFamily.Monospace
fontFamily = BitchatFontFamily
),
cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
@ -523,7 +519,7 @@ private fun LocationNotesInputSection(
Text(
text = stringResource(R.string.location_notes_input_placeholder),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
fontFamily = BitchatFontFamily
),
color = colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth()
@ -556,10 +552,8 @@ private fun LocationNotesInputSection(
modifier = Modifier.size(20.dp),
tint = if (!sendButtonEnabled) {
colorScheme.onSurface.copy(alpha = 0.5f)
} else if (isDark) {
Color.Black // Black arrow on green in dark theme
} else {
Color.White // White arrow on green in light theme
colorScheme.onPrimary
}
)
}

View File

@ -1,179 +0,0 @@
package com.bitchat.android.ui
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import kotlin.random.Random
/**
* Animation state for individual characters
*/
private enum class CharacterAnimationState {
ENCRYPTED, // Showing random encrypted characters
FINAL // Showing final decrypted character
}
/**
* Check if a message should be animated based on its mining state
*/
@Composable
fun shouldAnimateMessage(messageId: String): Boolean {
val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle()
return miningMessages.contains(messageId)
}
/**
* Tracks which messages are currently being mined for PoW
* Provides reactive state for UI animations
*/
object PoWMiningTracker {
private val _miningMessages = MutableStateFlow<Set<String>>(emptySet())
val miningMessages: StateFlow<Set<String>> = _miningMessages.asStateFlow()
/**
* Start tracking a message as mining
*/
fun startMiningMessage(messageId: String) {
_miningMessages.value = _miningMessages.value + messageId
}
/**
* Stop tracking a message as mining
*/
fun stopMiningMessage(messageId: String) {
_miningMessages.value = _miningMessages.value - messageId
}
/**
* Check if a message is currently mining
*/
fun isMiningMessage(messageId: String): Boolean {
return _miningMessages.value.contains(messageId)
}
/**
* Clear all mining messages (for cleanup)
*/
fun clearAllMining() {
_miningMessages.value = emptySet()
}
}
/**
* Shows the active PoW animation inside the same two-row layout used by static text messages.
*/
@Composable
fun MessageWithMatrixAnimation(
message: BitchatMessage,
currentUserNickname: String,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier,
) {
AnimatedMessageDisplay(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier,
)
}
/**
* Animates only the body content; sender, metadata, gestures, and spacing remain stable.
*/
@Composable
private fun AnimatedMessageDisplay(
message: BitchatMessage,
currentUserNickname: String,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier,
) {
var animatedContent by remember(message.id, message.content) {
mutableStateOf(message.content)
}
// Character-by-character animation state like the JavaScript version
var characterStates by remember(message.id, message.content) {
mutableStateOf(message.content.map { char ->
if (char == ' ') CharacterAnimationState.FINAL else CharacterAnimationState.ENCRYPTED
})
}
LaunchedEffect(message.id, message.content) {
if (message.content.isEmpty()) return@LaunchedEffect
val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray()
// Start character animations with staggered delays (like JS version).
message.content.forEachIndexed { index, targetChar ->
if (targetChar != ' ') {
launch {
delay(index * 50L)
while (true) {
while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) {
val newContent = animatedContent.toCharArray()
if (index < newContent.size) {
newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)]
animatedContent = String(newContent)
}
delay(100L)
if (Random.nextFloat() < 0.1f) {
val finalContent = animatedContent.toCharArray()
if (index < finalContent.size) {
finalContent[index] = targetChar
animatedContent = String(finalContent)
}
val finalStates = characterStates.toMutableList()
finalStates[index] = CharacterAnimationState.FINAL
characterStates = finalStates
break
}
}
delay(2000L)
val resetStates = characterStates.toMutableList()
resetStates[index] = CharacterAnimationState.ENCRYPTED
characterStates = resetStates
}
}
}
}
}
TextMessageLayout(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier,
bodyContent = animatedContent,
)
}

View File

@ -1,26 +1,35 @@
package com.bitchat.android.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -29,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.favorites.FavoriteRelationship
@ -36,6 +46,9 @@ import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.ui.theme.colorForPeer
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.nostr.GeohashConversationRegistry
import com.bitchat.android.services.ContactDirectory
@ -68,6 +81,8 @@ fun MeshPeerListSheet(
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val geohashPeopleCount = geohashPeople.size
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
@ -98,74 +113,83 @@ fun MeshPeerListSheet(
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp)
contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp)
) {
val peopleCount = when (selectedLocationChannel) {
is ChannelID.Location -> geohashPeopleCount
else -> connectedPeers.count { it != viewModel.myPeerID }
}
// Channels section
if (joinedChannels.isNotEmpty()) {
item(key = "channels_header") {
Text(
text = stringResource(id = R.string.channels).uppercase(),
style = MaterialTheme.typography.labelLarge,
color = colorScheme.onSurface.copy(alpha = 0.7f),
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 8.dp, bottom = 4.dp)
)
}
items(
items = joinedChannels.toList(),
key = { "channel_$it" }
) { channel ->
val isSelected = channel == currentChannel
val unreadCount = unreadChannelMessages[channel] ?: 0
ChannelRow(
channel = channel,
isSelected = isSelected,
unreadCount = unreadCount,
colorScheme = colorScheme,
onChannelClick = {
// Check if this is a DM channel (starts with @)
if (channel.startsWith("@")) {
// Extract peer name and find the peer ID
val peerName = channel.removePrefix("@")
val peerID =
peerNicknames.entries.firstOrNull { it.value == peerName }?.key
if (peerID != null) {
viewModel.showPrivateChatSheet(peerID)
onDismiss()
item(key = "channels_section") {
Column {
SheetIconSectionHeader(
iconRes = R.drawable.ic_spec_chat_bubbles,
title = stringResource(R.string.channels),
modifier = Modifier.padding(top = 8.dp)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.padding(top = 10.dp),
color = MaterialTheme.colorScheme.surface,
shape = AboutCardShape
) {
Column {
joinedChannels.toList().forEachIndexed { index, channel ->
if (index > 0) SheetCardDivider()
val isSelected = channel == currentChannel
val unreadCount = unreadChannelMessages[channel] ?: 0
ChannelRow(
channel = channel,
isSelected = isSelected,
unreadCount = unreadCount,
colorScheme = colorScheme,
onChannelClick = {
if (channel.startsWith("@")) {
val peerName = channel.removePrefix("@")
val peerID =
peerNicknames.entries.firstOrNull { it.value == peerName }?.key
if (peerID != null) {
viewModel.showPrivateChatSheet(peerID)
onDismiss()
}
} else {
viewModel.switchToChannel(channel)
onDismiss()
}
},
onLeaveChannel = {
viewModel.leaveChannel(channel)
},
)
}
} else {
// Regular channel switch
viewModel.switchToChannel(channel)
onDismiss()
}
},
onLeaveChannel = {
viewModel.leaveChannel(channel)
},
)
}
}
}
}
// People section - switch between mesh and geohash lists (iOS-compatible)
// People / geohash participants
item(key = "people_section") {
when (selectedLocationChannel) {
is ChannelID.Location -> {
// Show geohash people list when in location channel
GeohashPeopleList(
viewModel = viewModel,
onTapPerson = onDismiss
onTapPerson = onDismiss,
modifier = Modifier.padding(
top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp
)
)
}
else -> {
// Show mesh peer list when in mesh channel (default)
PeopleSection(
modifier = Modifier.padding(top = if (joinedChannels.isNotEmpty()) 16.dp else 0.dp),
modifier = Modifier.padding(
top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp
),
connectedPeers = connectedPeers,
peerNicknames = peerNicknames,
peerRSSI = peerRSSI,
@ -173,6 +197,7 @@ fun MeshPeerListSheet(
colorScheme = colorScheme,
selectedPrivatePeer = selectedPrivatePeer,
wifiAwarePeerIDs = wifiAwarePeerIDs,
peopleCount = peopleCount,
viewModel = viewModel,
onPrivateChatStart = { peerID ->
viewModel.showPrivateChatSheet(peerID)
@ -194,13 +219,13 @@ fun MeshPeerListSheet(
if (selectedLocationChannel !is ChannelID.Location) {
IconButton(
onClick = onShowVerification,
modifier = Modifier.size(24.dp)
modifier = Modifier.size(44.dp)
) {
Icon(
imageVector = Icons.Outlined.QrCode,
contentDescription = stringResource(R.string.verify_title),
tint = colorScheme.onSurface.copy(alpha = 0.8f),
modifier = Modifier.size(18.dp)
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp)
)
}
}
@ -213,6 +238,9 @@ fun MeshPeerListSheet(
}
}
/** Icon size for trailing actions on peer rows (matches settings glyph scale). */
private val PeerRowIconSize = 22.dp
@Composable
private fun ChannelRow(
channel: String,
@ -222,59 +250,52 @@ private fun ChannelRow(
onChannelClick: () -> Unit,
onLeaveChannel: () -> Unit,
) {
Surface(
onClick = onChannelClick,
color = if (isSelected) {
colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
Color.Transparent
},
shape = MaterialTheme.shapes.medium,
val palette = LocalBitchatPalette.current
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 2.dp)
.clickable(onClick = onChannelClick)
.padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
Box(
modifier = Modifier.size(SheetRowLeadingSlot),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Unread badge
if (unreadCount > 0) {
UnreadBadge(
count = unreadCount,
colorScheme = colorScheme
)
}
Text(
text = channel,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp
),
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal
if (isSelected) {
Box(
modifier = Modifier
.size(SheetRowSelectedDot)
.background(colorScheme.primary, CircleShape)
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Leave channel button
CloseButton(
onClick = onLeaveChannel,
} else if (unreadCount > 0) {
UnreadBadge(count = unreadCount, colorScheme = colorScheme)
} else {
Text(
text = "#",
fontFamily = BitchatFontFamily,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = palette.textTertiary
)
}
}
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
Text(
text = channel,
fontFamily = BitchatFontFamily,
fontSize = 14.sp,
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
CloseButton(onClick = onLeaveChannel)
}
}
@ -290,6 +311,7 @@ fun PeopleSection(
colorScheme: ColorScheme,
selectedPrivatePeer: String?,
wifiAwarePeerIDs: Set<String> = emptySet(),
peopleCount: Int = 0,
viewModel: ChatViewModel,
onPrivateChatStart: (String) -> Unit
) {
@ -298,31 +320,34 @@ fun PeopleSection(
SecureIdentityStateManager(context.applicationContext)
}
val palette = LocalBitchatPalette.current
Column(modifier = modifier) {
Text(
text = stringResource(id = R.string.people).uppercase(),
style = MaterialTheme.typography.labelLarge,
color = colorScheme.onSurface.copy(alpha = 0.7f),
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 8.dp, bottom = 4.dp)
SheetIconSectionHeader(
iconRes = R.drawable.ic_spec_people,
title = stringResource(R.string.people_count_title, peopleCount)
)
if (connectedPeers.isEmpty()) {
Text(
text = stringResource(id = R.string.no_one_connected),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
),
color = colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp, vertical = 12.dp)
)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.padding(top = 10.dp),
color = colorScheme.surface,
shape = AboutCardShape
) {
Column {
if (connectedPeers.isEmpty()) {
Text(
text = stringResource(id = R.string.no_one_connected),
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
color = palette.textTertiary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical)
)
}
// Observe reactive state for favorites and fingerprints
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
@ -426,7 +451,21 @@ fun PeopleSection(
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
}
sortedPeers.forEach { peerID ->
// Every row this card will show, in final order, so the animated list can key on identity
// and animate reordering. Offline favourites are appended after the connected peers.
// Collected once for the whole card rather than once per row.
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
val offlineFavoriteRows = offlineFavorites.filterNot { isFavoriteMappedToConnected(it) }
val rowKeys: List<String> = sortedPeers +
offlineFavoriteRows.map { ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) }
AnimatedRowColumn(items = rowKeys, key = { it }) { rowIndex, rowKey ->
Column {
if (rowIndex > 0) SheetCardDivider()
val connectedPeerForRow = sortedPeers.firstOrNull { it == rowKey }
if (connectedPeerForRow != null) {
val peerID = connectedPeerForRow
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val isFavorite = peerFavoriteStates[peerID] ?: false
val isVerified = peerVerifiedStates[peerID] ?: false
@ -446,7 +485,6 @@ fun PeopleSection(
val (bName, _) = splitSuffix(displayName)
val showHash = (baseNameCounts[bName] ?: 0) > 1
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
val isDirectLive = directMap[peerID] ?: try { viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
PeerItem(
peerID = peerID,
@ -468,12 +506,12 @@ fun PeopleSection(
showNostrGlobe = false,
showHashSuffix = showHash
)
}
// Append offline favorites we actively favorite (and not currently connected)
offlineFavorites.forEach { fav ->
val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey)
if (isFavoriteMappedToConnected(fav)) return@forEach
} else {
// Offline favourite: still worth showing, reachable over Nostr.
val fav = offlineFavoriteRows.first {
ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) == rowKey
}
val favPeerID = rowKey
val nostrConvKey: String? = try {
FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
@ -518,6 +556,10 @@ fun PeopleSection(
showHashSuffix = showHash
)
}
}
}
}
}
}
}
@ -547,143 +589,123 @@ private fun PeerItem(
val isMe = displayName == "You" || peerID == currentNickname
// Get consistent peer color (iOS-compatible)
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val assignedColor = viewModel.colorForMeshPeer(peerID, isDark)
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
val palette = LocalBitchatPalette.current
val assignedColor = colorForPeer(
viewModel.peerIdentityForMeshPeer(peerID),
palette
)
val baseColor = if (isMe) palette.accentOrange else assignedColor
Surface(
onClick = onItemClick,
color = if (isSelected) {
colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
Color.Transparent
},
shape = MaterialTheme.shapes.medium,
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 2.dp)
.clickable(onClick = onItemClick)
.padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
Box(
modifier = Modifier.size(SheetRowLeadingSlot),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Connection/status indicator
if (hasUnreadDM) {
// Show mail icon for unread DMs (iOS orange)
Icon(
imageVector = Icons.Filled.Email,
contentDescription = stringResource(R.string.cd_unread_message),
modifier = Modifier.size(16.dp),
tint = Color(0xFFFF9500) // iOS orange
)
} else if (showNostrGlobe) {
// Purple globe to indicate Nostr availability
Icon(
imageVector = Icons.Filled.Public,
contentDescription = stringResource(R.string.cd_reachable_via_nostr),
modifier = Modifier.size(16.dp),
tint = Color(0xFF9C27B0) // Purple
)
} else if (!isDirect && isFavorite) {
// Offline favorited user: show outlined circle icon
Icon(
imageVector = Icons.Outlined.Circle,
contentDescription = stringResource(R.string.cd_offline_favorite),
modifier = Modifier.size(16.dp),
tint = Color.Gray
)
} else {
Icon(
imageVector = when {
isWifiAware -> Icons.Filled.Wifi
isDirect -> Icons.Outlined.Bluetooth
else -> Icons.Filled.Route
},
contentDescription = when {
isWifiAware -> "Direct Wi-Fi Aware"
isDirect -> "Direct Bluetooth"
else -> "Routed"
},
modifier = Modifier.size(16.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
// Display name with iOS-style color and hashtag suffix support
Row(verticalAlignment = Alignment.CenterVertically) {
// Base name with peer-specific color
Text(
text = baseName,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal
),
color = baseColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Hashtag suffix in lighter shade (iOS-style)
if (suffix.isNotEmpty()) {
Text(
text = suffix,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp
),
color = baseColor.copy(alpha = 0.6f)
)
}
if (isWifiAware && hasUnreadDM) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Filled.Wifi,
contentDescription = "Direct Wi-Fi Aware",
modifier = Modifier.size(13.dp),
tint = colorScheme.onSurface.copy(alpha = 0.8f)
)
}
}
}
if (isVerified) {
Spacer(modifier = Modifier.width(4.dp))
if (isSelected) {
Box(
modifier = Modifier
.size(SheetRowSelectedDot)
.background(colorScheme.primary, CircleShape)
)
} else if (hasUnreadDM) {
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = Color(0xFF32D74B) // iOS Green
painter = painterResource(R.drawable.ic_spec_envelope),
contentDescription = stringResource(R.string.cd_unread_message),
modifier = Modifier.size(PeerRowIconSize),
tint = palette.accentOrange
)
} else if (showNostrGlobe) {
Icon(
painter = painterResource(R.drawable.ic_spec_globe),
contentDescription = stringResource(R.string.cd_reachable_via_nostr),
modifier = Modifier.size(PeerRowIconSize),
tint = palette.accentPurple
)
} else if (!isDirect && isFavorite) {
Icon(
imageVector = Icons.Outlined.Circle,
contentDescription = stringResource(R.string.cd_offline_favorite),
modifier = Modifier.size(PeerRowIconSize),
tint = palette.textTertiary
)
} else {
Icon(
painter = painterResource(
conversationTransportIcon(
isReachedOverInternet = false,
isWifiAware = isWifiAware,
isDirect = isDirect
)
),
contentDescription = when {
isWifiAware -> "Direct Wi-Fi Aware"
isDirect -> "Direct Bluetooth"
else -> "Routed"
},
modifier = Modifier.size(PeerRowIconSize),
tint = colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = baseName,
fontFamily = BitchatFontFamily,
fontSize = 14.sp,
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
color = baseColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (suffix.isNotEmpty()) {
Text(
text = suffix,
fontFamily = BitchatFontFamily,
fontSize = 14.sp,
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
color = baseColor.copy(alpha = SUFFIX_ALPHA)
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Favorite star with proper filled/outlined states
IconButton(
onClick = onToggleFavorite,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
modifier = Modifier.size(16.dp),
tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50)
)
}
if (isVerified) {
Icon(
painter = painterResource(R.drawable.ic_spec_check),
contentDescription = stringResource(R.string.verify_title),
modifier = Modifier.size(16.dp),
tint = colorScheme.primary
)
}
}
Box(
modifier = Modifier
.size(36.dp)
.clickable(onClick = onToggleFavorite),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(
if (isFavorite) R.drawable.ic_spec_star_filled else R.drawable.ic_spec_star
),
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
modifier = Modifier.size(PeerRowIconSize),
tint = if (isFavorite) palette.accentOrange else palette.textTertiary
)
}
}
}
@ -696,25 +718,41 @@ private fun UnreadBadge(
colorScheme: ColorScheme,
modifier: Modifier = Modifier
) {
if (count > 0) {
val palette = LocalBitchatPalette.current
// Scale/fade in and out so a badge appearing while the sheet is open is noticed, and one
// clearing does not just blink away.
AnimatedVisibility(
visible = count > 0,
enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) +
scaleIn(
initialScale = 0.5f,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
),
exit = fadeOut(tween(BitchatMotion.QUICK_MS)) +
scaleOut(
targetScale = 0.5f,
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing)
),
modifier = modifier
) {
Box(
modifier = modifier
modifier = Modifier
.background(
color = Color(0xFFFFD700), // Yellow color
color = palette.accentOrange,
shape = RoundedCornerShape(10.dp)
)
.padding(horizontal = 6.dp, vertical = 2.dp)
.defaultMinSize(minWidth = 18.dp, minHeight = 18.dp),
contentAlignment = Alignment.Center
) {
Text(
AnimatedCountLabel(
count = count,
text = if (count > 99) "99+" else count.toString(),
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
),
color = Color.Black // Black text on yellow background
style = MaterialTheme.typography.labelSmall,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
fontFamily = BitchatFontFamily,
color = Color.Black
)
}
}
@ -804,7 +842,7 @@ fun PrivateChatSheet(
val name = if (fullPubkey.isNotEmpty()) {
viewModel.geohashViewModel.displayNameForGeohashConversation(fullPubkey, gh)
} else {
peerNicknames[peerID] ?: "unknown"
peerNicknames[peerID] ?: "Unknown"
}
"#$gh/@$name"
} else {
@ -826,12 +864,7 @@ fun PrivateChatSheet(
viewModel.isPeerVerified(peerID, verifiedFingerprints)
}
val securityModifier = if (!isNostrPeer && !isNostrReachableFavorite) {
Modifier.clickable { viewModel.showSecurityVerificationSheet() }
} else {
Modifier
}
val palette = LocalBitchatPalette.current
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
@ -845,9 +878,9 @@ fun PrivateChatSheet(
Column(
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.height(64.dp))
Spacer(modifier = Modifier.height(ChatHeaderHeight))
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
// Messages list
var forceScrollToBottom by remember { mutableStateOf(false) }
@ -858,6 +891,7 @@ fun PrivateChatSheet(
currentUserNickname = nickname,
meshService = viewModel.meshServiceFacade,
modifier = Modifier.weight(1f),
conversationKey = "dm:$peerID",
forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
onNicknameClick = { /* handle mention */ },
@ -866,9 +900,8 @@ fun PrivateChatSheet(
onImageClick = { _, _, _ -> /* handle image click */ }
)
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
// Input section
// Input section. No divider here: ChatInputSection draws its own fade and
// hairline.
var messageText by remember {
mutableStateOf(
androidx.compose.ui.text.input.TextFieldValue(
@ -913,112 +946,97 @@ fun PrivateChatSheet(
)
}
// TopBar (fixed at top, iOS-style)
BitchatSheetCenterTopBar(
onClose = onDismiss,
// Header. Built from the same tokens as the main chat header rather than a
// TopAppBar, so moving between the timeline and a conversation does not shift the
// bar's height, insets or type.
Surface(
modifier = Modifier.align(Alignment.TopCenter),
navigationIcon = {
IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 16.dp)
.size(32.dp)
color = colorScheme.background
) {
ConversationHeader(
leadingIconRes = conversationTransportIcon(
isReachedOverInternet = isNostrPeer || isNostrReachableFavorite,
isWifiAware = isWifiAware,
isDirect = isDirect
),
leadingIconTint = colorScheme.primary,
leadingContentDescription = when {
isNostrPeer || isNostrReachableFavorite ->
stringResource(R.string.cd_nostr_reachable)
else -> null
},
title = titleText
) {
ConversationHeaderAction(
onClick = { viewModel.toggleFavorite(peerID) },
contentDescription = if (isFavorite) {
stringResource(R.string.cd_remove_favorite)
} else {
stringResource(R.string.cd_add_favorite)
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.chat_back),
tint = colorScheme.onSurface
painter = painterResource(
if (isFavorite) {
R.drawable.ic_spec_star_filled
} else {
R.drawable.ic_spec_star
}
),
contentDescription = null,
modifier = Modifier.size(HeaderIconSize),
tint = if (isFavorite) {
palette.accentOrange
} else {
colorScheme.onSurfaceVariant
}
)
}
},
title = {
// Center content: connection status + name + encryption
Row(
modifier = Modifier.align(Alignment.Center),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
when {
isNostrPeer || isNostrReachableFavorite -> {
Icon(
imageVector = Icons.Filled.Public,
contentDescription = stringResource(R.string.cd_nostr_reachable),
modifier = Modifier.size(14.dp),
tint = Color(0xFF9C27B0)
)
}
isWifiAware -> {
Icon(
imageVector = Icons.Filled.Wifi,
contentDescription = "Direct Wi-Fi Aware",
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
isDirect -> {
Icon(
imageVector = Icons.Outlined.Bluetooth,
contentDescription = "Direct Bluetooth",
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
isConnected -> {
Icon(
imageVector = Icons.Filled.Route,
contentDescription = "Routed",
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
Text(
text = titleText,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
),
color = if (isNostrPeer || isNostrReachableFavorite) Color(0xFFFF9500) else colorScheme.onSurface
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.then(securityModifier)
// Encryption state, and the verification badge that qualifies it. Both are
// read-only for Nostr peers, which have no Noise session at all.
if (!isNostrPeer && !isNostrReachableFavorite) {
ConversationHeaderAction(
onClick = { viewModel.showSecurityVerificationSheet() },
contentDescription = stringResource(R.string.verify_title)
) {
if (!isNostrPeer && !isNostrReachableFavorite) {
Box(contentAlignment = Alignment.Center) {
NoiseSessionIcon(
sessionState = sessionState,
modifier = Modifier.size(14.dp)
)
}
if (isVerified) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = stringResource(R.string.verify_title),
modifier = Modifier.size(14.dp),
tint = Color(0xFF32D74B) // iOS Green
modifier = Modifier.size(HeaderIconSize)
)
}
}
}
IconButton(
onClick = { viewModel.toggleFavorite(peerID) },
modifier = Modifier.size(28.dp)
if (isVerified) {
Box(
modifier = Modifier.size(HeaderIconSize),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite),
modifier = Modifier.size(16.dp),
tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f)
painter = painterResource(R.drawable.ic_spec_check),
contentDescription = stringResource(R.string.verify_title),
modifier = Modifier.size(HeaderIconSize),
tint = colorScheme.primary
)
}
}
val dismiss = LocalSheetDismiss.current
ConversationHeaderAction(
onClick = { dismiss?.invoke() ?: onDismiss() },
contentDescription = stringResource(R.string.close_plain)
) {
Icon(
painter = painterResource(R.drawable.ic_spec_close),
contentDescription = null,
modifier = Modifier.size(HeaderIconSize),
tint = colorScheme.onSurfaceVariant
)
}
}
)
}
}
}
}

View File

@ -1,6 +1,18 @@
package com.bitchat.android.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -9,16 +21,16 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -29,20 +41,25 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
import com.bitchat.android.mesh.MeshService
@ -50,6 +67,13 @@ import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.ui.media.FileMessageItem
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.ChatVisualTokens
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.ui.theme.MessageBodyTextStyle
import com.bitchat.android.ui.theme.MessageSenderTextStyle
import kotlinx.coroutines.delay
import java.text.SimpleDateFormat
import java.util.Locale
@ -61,12 +85,119 @@ import java.util.Locale
* Extracted from ChatScreen.kt for better organization
*/
/** How far a newly arrived message travels up into place. */
private val MessageEntrySlide = 14.dp
/**
* Entry motion for a new message: quick, with just enough damping to settle rather than snap.
* Runs entirely on a graphics layer, so it costs a transform and nothing else.
*/
private val MessageEntrySpec: AnimationSpec<Float> =
spring(dampingRatio = 0.85f, stiffness = 1200f)
/**
* Motion for messages being pushed out of the way by an arrival. Softer than the entry so the
* conversation glides up while the new message itself lands crisply.
*/
private val MessagePlacementSpec: FiniteAnimationSpec<IntOffset> = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = IntOffset.VisibilityThreshold
)
/** Removals are not worth dwelling on. */
private val MessageFadeOutSpec: FiniteAnimationSpec<Float> = tween(BitchatMotion.QUICK_MS)
/**
* How long placement animation stays armed after the list gains or loses a message.
*
* Comfortably longer than [MessagePlacementSpec] takes to settle, so an arrival's push is never cut
* short.
*/
private const val PlacementArmWindowMs = 600L
/**
* Above this many simultaneous arrivals, entry animations are skipped.
*
* A history sync or a channel switch can append hundreds of messages in one frame. Animating each
* would spend the entire frame budget on motion nobody asked to see, so a burst is adopted
* silently and only conversational-pace arrivals animate.
*/
internal const val MaxAnimatedArrivals = 6
/**
* Remembers which message ids have already been seen, so genuine arrivals can be told apart from
* items merely scrolling back into view.
*
* This distinction is the whole reason the entry animation is usable: `LazyColumn` composes items
* on demand, so animating on first composition would replay the animation for every old message
* the user scrolled back to.
*/
internal class MessageArrivalTracker {
val known = HashSet<String>()
var seeded = false
}
/**
* Ids that should animate in on this composition pass.
*
* Deliberately computed during composition rather than in a `LaunchedEffect`: effects run *after*
* the frame's composition, by which point a new message's item has already composed and would
* have missed its cue.
*/
internal fun MessageArrivalTracker.arrivals(messages: List<BitchatMessage>): Set<String> {
if (!seeded) {
// First load adopts everything silently. A whole screenful animating on open reads as a
// glitch, not a flourish.
messages.forEach { known.add(it.id) }
seeded = true
return emptySet()
}
// A list with nothing in common with the last one is a different conversation, not a burst of
// arrivals — /clear, or a switch the caller did not give us a distinct key for. Adopt it
// silently rather than sliding in every message at once.
val isWholesaleReplacement =
messages.isNotEmpty() && known.isNotEmpty() && messages.none { it.id in known }
// `HashSet.add` reports whether the id was new, so this both diffs and updates in one pass.
val added = messages.filter { known.add(it.id) }
if (known.size > messages.size) {
// Messages disappeared (/clear, channel switch). Drop the stale ids so the set cannot
// grow without bound and so re-added messages animate again.
known.retainAll(messages.mapTo(HashSet(messages.size)) { it.id })
}
return when {
isWholesaleReplacement -> emptySet()
added.isEmpty() || added.size > MaxAnimatedArrivals -> emptySet()
else -> added.mapTo(HashSet(added.size)) { it.id }
}
}
@Composable
fun MessagesList(
messages: List<BitchatMessage>,
currentUserNickname: String,
meshService: MeshService,
modifier: Modifier = Modifier,
mentionPeerIdentities: Map<String, PeerIdentity>? = null,
/**
* Extra inset on top of the list's own gutters.
*
* The chat screen's bars are translucent and the list scrolls underneath them, so the caller
* has to reserve room for their heights here rather than by shrinking the viewport.
*/
contentPadding: PaddingValues = PaddingValues(0.dp),
/**
* Identity of the conversation being shown a channel, a geohash, a peer.
*
* Everything below that is per-conversation state is keyed on this. Without it, switching
* channels reused the previous conversation's scroll offset, follow flag and seen-message set,
* so the new channel opened at a stale position and then animated itself into place.
*/
conversationKey: Any? = null,
forceScrollToBottom: Boolean = false,
onScrolledUpChanged: ((Boolean) -> Unit)? = null,
onNicknameClick: ((String) -> Unit)? = null,
@ -74,11 +205,23 @@ fun MessagesList(
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
onImageClick: ((String, List<String>, Int) -> Unit)? = null
) {
val listState = rememberLazyListState()
val resolvedMentionPeerIdentities = remember(messages, mentionPeerIdentities) {
mentionPeerIdentities ?: buildMentionPeerIdentityMap(messages)
}
// A fresh scroll position per conversation. Sharing one state meant a switch inherited the
// previous channel's offset and then had to correct itself, which is what the jump was.
//
// Passing the key as an *input* rather than as `key =` is deliberate: it discards the saved
// offset on every switch, so a conversation always opens on its newest message instead of
// wherever the reader happened to be some time ago, with unseen messages below them.
val listState = rememberSaveable(conversationKey, saver = LazyListState.Saver) {
LazyListState()
}
// Track if this is the first time messages are being loaded
var hasScrolledToInitialPosition by remember { mutableStateOf(false) }
var followIncomingMessages by remember { mutableStateOf(true) }
var hasScrolledToInitialPosition by remember(conversationKey) { mutableStateOf(false) }
var followIncomingMessages by remember(conversationKey) { mutableStateOf(true) }
// Smart scroll: auto-scroll to bottom for initial load, then follow unless user scrolls away
LaunchedEffect(messages.size) {
@ -94,7 +237,7 @@ fun MessagesList(
}
// Track whether user has scrolled away from the latest messages
val isAtLatest by remember {
val isAtLatest by remember(listState) {
derivedStateOf {
val firstVisibleIndex = listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1
firstVisibleIndex <= 2
@ -114,27 +257,107 @@ fun MessagesList(
}
}
// Recomputed only when the list actually gains or loses a message, and synchronously, so the
// arriving item can read its cue during the same composition pass in which it first appears.
// Reset per conversation, so a switch adopts the incoming messages silently instead of
// treating a whole channel's backlog as brand-new arrivals and sliding each one in.
val arrivalTracker = remember(conversationKey) { MessageArrivalTracker() }
val enteringIds = remember(conversationKey, messages.size, messages.lastOrNull()?.id) {
arrivalTracker.arrivals(messages)
}
// Placement animation exists to soften insertions and removals. But *any* relayout moves every
// item — the keyboard opening behind a bottom sheet, that sheet closing again, the composer
// growing a line — and animating those made the whole conversation lurch. So it is armed only
// briefly around a genuine change to the list, and is otherwise off, letting items track the
// viewport exactly.
var placementArmed by remember(conversationKey) { mutableStateOf(false) }
var previousMessageCount by remember(conversationKey) { mutableStateOf<Int?>(null) }
LaunchedEffect(conversationKey, messages.size) {
val previous = previousMessageCount
previousMessageCount = messages.size
// Skip the first composition: the list settling into its initial padding is not a change
// worth animating.
if (previous == null || previous == messages.size) return@LaunchedEffect
placementArmed = true
delay(PlacementArmWindowMs)
placementArmed = false
}
val layoutDirection = LocalLayoutDirection.current
LazyColumn(
state = listState,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
// Wider side gutters than the old 12.dp: the redesign trades a little line length for
// a much calmer edge, and long monospace lines were running into the screen bezel.
contentPadding = PaddingValues(
start = 16.dp + contentPadding.calculateStartPadding(layoutDirection),
end = 16.dp + contentPadding.calculateEndPadding(layoutDirection),
top = 8.dp + contentPadding.calculateTopPadding(),
bottom = 12.dp + contentPadding.calculateBottomPadding()
),
// Spacing is owned by each item. The exported transcript uses a consistent 8.dp rhythm;
// a new speaker gets additional separation from the visible sender row's top inset.
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = modifier,
reverseLayout = true
) {
items(
items = messages.asReversed(),
key = { it.id }
) { message ->
MessageItem(
message = message,
messages = messages,
currentUserNickname = currentUserNickname,
meshService = meshService,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
onImageClick = onImageClick
)
val reversed = messages.asReversed()
itemsIndexed(
items = reversed,
key = { _, message -> message.id }
) { reversedIndex, message ->
// reverseLayout renders index 0 at the bottom, so the chronological predecessor of
// this row lives at a *higher* original index offset. Resolve against the original
// list rather than the reversed view to keep the grouping logic readable.
val originalIndex = messages.lastIndex - reversedIndex
val previous = messages.getOrNull(originalIndex - 1)
val isGrouped = MessageGrouping.shouldGroup(previous, message)
// Decided once per item instance, so an item recycling back into view during a scroll
// never re-animates. Items that are not arriving skip the animation machinery
// entirely: no Animatable, no coroutine, and no extra render layer per row.
val isArriving = remember(message.id) { message.id in enteringIds }
val entryModifier = if (isArriving) {
val entry = remember(message.id) { Animatable(0f) }
LaunchedEffect(message.id) { entry.animateTo(1f, MessageEntrySpec) }
// A draw-time transform only: no measure, no layout, and no recomposition of the
// message content on any frame of the animation.
Modifier.graphicsLayer {
val progress = entry.value
alpha = progress
translationY = (1f - progress) * MessageEntrySlide.toPx()
}
} else {
Modifier
}
MessageItem(
message = message,
messages = messages,
currentUserNickname = currentUserNickname,
meshService = meshService,
mentionPeerIdentities = resolvedMentionPeerIdentities,
showSender = !isGrouped,
topSpacing = MessageGrouping.topSpacingFor(
isGrouped = isGrouped,
isFirstInList = originalIndex == 0
),
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
onImageClick = onImageClick,
modifier = Modifier
// Animates the shift when a neighbour is inserted or removed: this is what
// makes the conversation glide up instead of jumping.
.animateItem(
// Entry fade is handled by entryModifier, together with the slide, so the
// two cannot drift out of step.
fadeInSpec = null,
placementSpec = if (placementArmed) MessagePlacementSpec else null,
fadeOutSpec = MessageFadeOutSpec
)
.then(entryModifier)
)
}
}
}
@ -146,16 +369,22 @@ fun MessageItem(
currentUserNickname: String,
meshService: MeshService,
messages: List<BitchatMessage> = emptyList(),
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
showSender: Boolean = true,
topSpacing: Dp = 0.dp,
onNicknameClick: ((String) -> Unit)? = null,
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
onImageClick: ((String, List<String>, Int) -> Unit)? = null
onImageClick: ((String, List<String>, Int) -> Unit)? = null,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
val timeFormatter = remember { SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) }
Column(
modifier = Modifier.fillMaxWidth(),
modifier = modifier
.fillMaxWidth()
.padding(top = topSpacing),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
Box(modifier = Modifier.fillMaxWidth()) {
@ -172,8 +401,10 @@ fun MessageItem(
messages = messages,
currentUserNickname = currentUserNickname,
meshService = meshService,
mentionPeerIdentities = mentionPeerIdentities,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
@ -209,14 +440,18 @@ fun MessageItem(
messages: List<BitchatMessage>,
currentUserNickname: String,
meshService: MeshService,
mentionPeerIdentities: Map<String, PeerIdentity>,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
showSender: Boolean,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
onImageClick: ((String, List<String>, Int) -> Unit)?,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
// Image special rendering
if (message.type == BitchatMessageType.Image) {
com.bitchat.android.ui.media.ImageMessageItem(
@ -226,6 +461,7 @@ fun MessageItem(
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
@ -243,6 +479,7 @@ fun MessageItem(
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
@ -268,9 +505,11 @@ fun MessageItem(
val headerText = formatMessageHeaderAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
myPeerID = meshService.myPeerID,
palette = palette,
contentColor = colorScheme.onSurface,
timeFormatter = timeFormatter,
includeSender = showSender
)
val haptic = LocalHapticFeedback.current
AnnotatedClickableText(
@ -286,7 +525,7 @@ fun MessageItem(
}
},
onLongPress = { onMessageLongPress?.invoke(message) },
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
color = colorScheme.onSurface,
)
@ -335,15 +574,24 @@ fun MessageItem(
.align(Alignment.TopEnd)
.padding(4.dp)
.size(22.dp)
.background(Color.Gray.copy(alpha = 0.6f), CircleShape)
.background(colorScheme.surfaceVariant.copy(alpha = 0.85f), CircleShape)
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp))
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.cd_cancel),
tint = colorScheme.onSurface,
modifier = Modifier.size(14.dp)
)
}
}
} else {
Text(text = stringResource(R.string.file_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray)
Text(
text = stringResource(R.string.file_unavailable),
fontFamily = BitchatFontFamily,
color = palette.textTertiary
)
}
}
}
@ -351,31 +599,15 @@ fun MessageItem(
return
}
// Check if this message should be animated during PoW mining
val shouldAnimate = shouldAnimateMessage(message.id)
// If animation is needed, use the matrix animation component for content only
if (shouldAnimate) {
// Display message with matrix animation for content
MessageWithMatrixAnimation(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier
)
} else if (message.sender == "system") {
// Keep system messages on the compact legacy line.
val annotatedText = formatMessageAsAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
)
if (message.sender == "system") {
// Background narration: `// Tor started. Routing all chats…`
val annotatedText = remember(message, colorScheme.onSurface) {
formatSystemMessage(
message = message,
contentColor = colorScheme.onSurface,
timeFormatter = timeFormatter
)
}
val haptic = LocalHapticFeedback.current
Text(
@ -388,11 +620,11 @@ fun MessageItem(
}
)
},
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
softWrap = true,
overflow = TextOverflow.Visible,
style = androidx.compose.ui.text.TextStyle(
color = colorScheme.onSurface
style = ChatVisualTokens.SystemActionStyle.copy(
color = colorScheme.onSurface.copy(alpha = ChatVisualTokens.MutedTextAlpha),
)
)
} else {
@ -400,8 +632,10 @@ fun MessageItem(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
mentionPeerIdentities = mentionPeerIdentities,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier,
@ -414,37 +648,47 @@ internal fun TextMessageLayout(
message: BitchatMessage,
currentUserNickname: String,
meshService: MeshService,
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier,
showSender: Boolean = true,
bodyContent: String = message.content,
) {
val palette = LocalBitchatPalette.current
val myPeerId = meshService.myPeerID
val displayMessage = remember(message, bodyContent) {
if (bodyContent == message.content) message else message.copy(content = bodyContent)
}
val senderText = remember(message, currentUserNickname, myPeerId, colorScheme) {
val senderText = remember(message, currentUserNickname, myPeerId, palette) {
formatTextMessageSender(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
myPeerID = myPeerId,
palette = palette,
)
}
val metadataText = remember(message.timestamp, message.powDifficulty, timeFormatter) {
formatTextMessageMetadata(
message = message,
timeFormatter = timeFormatter,
)
}
val bodyText = remember(displayMessage, currentUserNickname, myPeerId, colorScheme) {
// The timestamp trails the body rather than occupying its own column, so a short message
// no longer reserves a full-width row for eight grey characters.
val bodyText = remember(
displayMessage,
currentUserNickname,
palette,
colorScheme.onSurface,
colorScheme.secondary,
mentionPeerIdentities,
timeFormatter
) {
formatTextMessageBody(
message = displayMessage,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
palette = palette,
contentColor = colorScheme.onSurface,
linkColor = colorScheme.secondary,
mentionPeerIdentities = mentionPeerIdentities,
timeFormatter = timeFormatter,
)
}
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
@ -457,12 +701,9 @@ internal fun TextMessageLayout(
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(MessageGrouping.SENDER_TO_BODY_SPACING),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
if (showSender) {
AnnotatedClickableText(
text = senderText,
annotationTags = listOf("nickname_click"),
@ -476,18 +717,13 @@ internal fun TextMessageLayout(
}
},
onLongPress = handleLongPress,
modifier = Modifier.weight(1f),
fontFamily = FontFamily.Monospace,
modifier = Modifier
.fillMaxWidth()
.padding(top = MessageGrouping.SENDER_TOP_PADDING),
fontFamily = BitchatFontFamily,
softWrap = false,
overflow = TextOverflow.Ellipsis,
)
AnnotatedClickableText(
text = metadataText,
annotationTags = emptyList(),
onAnnotationClick = { _, _ -> false },
onLongPress = handleLongPress,
fontFamily = FontFamily.Monospace,
softWrap = false,
style = MessageSenderTextStyle,
)
}
@ -512,10 +748,10 @@ internal fun TextMessageLayout(
}
},
onLongPress = handleLongPress,
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
softWrap = true,
overflow = TextOverflow.Visible,
style = androidx.compose.ui.text.TextStyle(color = colorScheme.onSurface),
style = MessageBodyTextStyle.copy(color = colorScheme.onSurface),
)
}
}
@ -523,53 +759,40 @@ internal fun TextMessageLayout(
@Composable
fun DeliveryStatusIcon(status: DeliveryStatus) {
val colorScheme = MaterialTheme.colorScheme
when (status) {
is DeliveryStatus.Sending -> {
Text(
text = stringResource(R.string.status_sending),
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.6f)
)
}
is DeliveryStatus.Sent -> {
// Use a subtle hollow marker for Sent; single check is reserved for Delivered (iOS parity)
Text(
text = stringResource(R.string.status_pending),
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.6f)
)
}
is DeliveryStatus.Delivered -> {
// Single check for Delivered (matches iOS expectations)
Text(
text = stringResource(R.string.status_sent),
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.8f)
)
}
is DeliveryStatus.Read -> {
Text(
text = stringResource(R.string.status_delivered),
fontSize = 10.sp,
color = Color(0xFF007AFF), // Blue
fontWeight = FontWeight.Bold
)
}
is DeliveryStatus.Failed -> {
Text(
text = stringResource(R.string.status_failed),
fontSize = 10.sp,
color = Color.Red.copy(alpha = 0.8f)
)
}
is DeliveryStatus.PartiallyDelivered -> {
// Show a single subdued check without numeric label
Text(
text = stringResource(R.string.status_sent),
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.6f)
)
// Status advances on its own as acks come back, so a hard glyph swap reads as a flicker.
// Keyed on the status *type* rather than the instance, because Delivered/Read carry a
// timestamp that would otherwise retrigger the transition on every identical update.
AnimatedContent(
targetState = status::class,
transitionSpec = {
fadeIn(tween(BitchatMotion.STANDARD_MS)) togetherWith
fadeOut(tween(BitchatMotion.QUICK_MS))
},
label = "deliveryStatus"
) { statusClass ->
val (text, color, weight) = when (statusClass) {
DeliveryStatus.Sending::class ->
Triple(R.string.status_sending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
// Subtle hollow marker for Sent; a single check is reserved for Delivered (iOS parity).
DeliveryStatus.Sent::class ->
Triple(R.string.status_pending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
DeliveryStatus.Delivered::class ->
Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.8f), FontWeight.Normal)
DeliveryStatus.Read::class ->
Triple(R.string.status_delivered, colorScheme.secondary, FontWeight.Bold)
DeliveryStatus.Failed::class ->
Triple(R.string.status_failed, colorScheme.error, FontWeight.Normal)
// A single subdued check, without the numeric label.
else ->
Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
}
Text(
text = stringResource(text),
fontSize = 10.sp,
color = color,
fontWeight = weight
)
}
}

View File

@ -0,0 +1,84 @@
package com.bitchat.android.ui
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.theme.ChatVisualTokens
/**
* Consecutive-message grouping for the chat surface.
*
* The redesign suppresses the `@sender` label on runs of messages from the same author so the
* eye only has to register a name when the speaker actually changes. The transcript uses one
* consistent item rhythm; new groups get their additional separation from the visible sender
* row's own top padding.
*/
object MessageGrouping {
/** Space above the first message of a group (i.e. one that renders its sender label). */
val NEW_GROUP_SPACING: Dp = ChatVisualTokens.MessageItemSpacing
/** Space above a continuation message inside an existing group. */
val GROUPED_SPACING: Dp = ChatVisualTokens.MessageItemSpacing
/** Gap between the sender label and the first line of the body. */
val SENDER_TO_BODY_SPACING: Dp = ChatVisualTokens.SenderToBodySpacing
/** Top inset inside a visible sender row. */
val SENDER_TOP_PADDING: Dp = ChatVisualTokens.SenderTopPadding
/**
* Messages further apart than this always start a new group, even from the same sender:
* a reply an hour later is a new thought, not a continuation.
*/
const val GROUPING_WINDOW_MS: Long = 5 * 60 * 1000L
private const val SYSTEM_SENDER = "system"
/**
* Whether [current] should be rendered as a continuation of [previous], hiding its sender
* label.
*
* [previous] is the message immediately *before* [current] in chronological order. Note the
* message list renders with `reverseLayout = true`, so callers must be careful to resolve
* the chronological predecessor rather than the visually preceding item.
*/
fun shouldGroup(previous: BitchatMessage?, current: BitchatMessage): Boolean {
if (previous == null) return false
// System/action lines never participate in grouping in either direction: they are
// narration, and folding a real message into them would attribute it to "system".
if (previous.sender == SYSTEM_SENDER || current.sender == SYSTEM_SENDER) return false
// Never group across the public/private boundary or between different channels.
if (previous.isPrivate != current.isPrivate) return false
if (previous.channel != current.channel) return false
if (!isSameSender(previous, current)) return false
val elapsed = current.timestamp.time - previous.timestamp.time
return elapsed in 0..GROUPING_WINDOW_MS
}
/**
* Identity comparison. Peer IDs are authoritative when both messages carry one, because two
* different peers can share a nickname. Falls back to the display name (which includes the
* `#abcd` suffix) when peer IDs are unavailable, e.g. for locally injected messages.
*/
private fun isSameSender(previous: BitchatMessage, current: BitchatMessage): Boolean {
val previousPeerID = previous.senderPeerID
val currentPeerID = current.senderPeerID
return if (previousPeerID != null && currentPeerID != null) {
previousPeerID.equals(currentPeerID, ignoreCase = true)
} else {
previous.sender == current.sender
}
}
/** Top padding for a message given whether it continues the previous author's run. */
fun topSpacingFor(isGrouped: Boolean, isFirstInList: Boolean): Dp = when {
isFirstInList -> 0.dp
isGrouped -> GROUPED_SPACING
else -> NEW_GROUP_SPACING
}
}

View File

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

View File

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

View File

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

View File

@ -1,133 +0,0 @@
package com.bitchat.android.ui
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Security
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.nostr.NostrProofOfWork
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.nostr.PoWPreferenceManager
/**
* Shows the current Proof of Work status and settings
*/
@Composable
fun PoWStatusIndicator(
modifier: Modifier = Modifier,
style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT
) {
val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle()
val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle()
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
if (!powEnabled) return
when (style) {
PoWIndicatorStyle.COMPACT -> {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// PoW icon with animation if mining
if (isMining) {
val rotation by rememberInfiniteTransition(label = "pow-rotation").animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "pow-icon-rotation"
)
Icon(
imageVector = Icons.Filled.Security,
contentDescription = stringResource(R.string.cd_mining_pow),
tint = Color(0xFFFF9500), // Orange for mining
modifier = Modifier
.size(12.dp)
.graphicsLayer { rotationZ = rotation }
)
} else {
Icon(
imageVector = Icons.Filled.Security,
contentDescription = stringResource(R.string.cd_pow_enabled),
tint = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), // Green when ready
modifier = Modifier.size(12.dp)
)
}
}
}
PoWIndicatorStyle.DETAILED -> {
Surface(
modifier = modifier,
color = colorScheme.surfaceVariant.copy(alpha = 0.3f),
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
// PoW icon
Icon(
imageVector = Icons.Filled.Security,
contentDescription = stringResource(R.string.cd_proof_of_work),
tint = if (isMining) Color(0xFFFF9500) else {
if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
},
modifier = Modifier.size(14.dp)
)
// Status text
Text(
text = if (isMining) {
stringResource(R.string.pow_mining_ellipsis)
} else {
stringResource(R.string.pow_label_format, powDifficulty)
},
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = if (isMining) Color(0xFFFF9500) else {
colorScheme.onSurface.copy(alpha = 0.7f)
}
)
// Time estimate
if (!isMining && powDifficulty > 0) {
Text(
text = stringResource(R.string.pow_time_estimate, NostrProofOfWork.estimateMiningTime(powDifficulty)),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.5f)
)
}
}
}
}
}
}
/**
* Style options for the PoW status indicator
*/
enum class PoWIndicatorStyle {
COMPACT, // Small icon + difficulty number
DETAILED // Icon + status text + time estimate
}

View File

@ -0,0 +1,64 @@
package com.bitchat.android.ui
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
/**
* Press feedback for the app's icon buttons.
*
* Terminal-style chrome has no elevation and no fills to lean on, so a Material ripple has almost
* nothing to show. A brief scale dip is legible on any background and reads as physical. Springs
* rather than tweens, so releasing overshoots very slightly instead of stopping dead.
*/
@Composable
fun rememberPressScale(
interactionSource: InteractionSource,
pressedScale: Float = 0.86f
): Float {
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (isPressed) pressedScale else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh
),
label = "pressScale"
)
return scale
}
/**
* Convenience wrapper for the common case: a clickable that scales while held.
*
* Returns the modifier chain to apply, having disabled the default indication the scale *is* the
* indication, and a ripple underneath it just muddies the edges of these small glyphs.
*/
@Composable
fun Modifier.pressScaleClickable(
onClick: () -> Unit,
enabled: Boolean = true,
onClickLabel: String? = null,
pressedScale: Float = 0.86f
): Modifier {
val interactionSource = remember { MutableInteractionSource() }
val scale = rememberPressScale(interactionSource, pressedScale)
return this
.clickable(
interactionSource = interactionSource,
indication = null,
enabled = enabled,
onClickLabel = onClickLabel,
onClick = onClick
)
.scale(scale)
}

View File

@ -1,9 +1,15 @@
package com.bitchat.android.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.NoEncryption
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material.icons.outlined.Warning as OutlinedWarning
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -12,13 +18,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.NoEncryption
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material.icons.outlined.Warning as OutlinedWarning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
@ -39,14 +38,15 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
private data class SecurityStatusInfo(
@ -69,9 +69,9 @@ fun SecurityVerificationSheet(
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
val isDark = isSystemInDarkTheme()
val accent = if (isDark) Color.Green else Color(0xFF008000)
val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f)
val colorScheme = MaterialTheme.colorScheme
val accent = colorScheme.primary
val boxColor = colorScheme.surfaceVariant
val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") }
BitchatBottomSheet(
@ -92,7 +92,7 @@ fun SecurityVerificationSheet(
if (peerID == null) {
Text(
text = stringResource(R.string.fingerprint_no_peer),
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
} else {
@ -155,13 +155,14 @@ private fun SecurityVerificationHeader(
Text(
text = stringResource(R.string.security_verification_title),
style = MaterialTheme.typography.titleSmall.copy(
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Bold
),
color = accent
)
Spacer(modifier = Modifier.weight(1f))
CloseButton(onClick = onClose)
val dismiss = LocalSheetDismiss.current
CloseButton(onClick = { dismiss?.invoke() ?: onClose() })
}
}
@ -219,7 +220,7 @@ private fun SecurityStatusCard(
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium.copy(
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Bold
),
color = accent
@ -227,7 +228,7 @@ private fun SecurityStatusCard(
Text(
text = statusInfo.text,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
fontFamily = BitchatFontFamily
),
color = accent.copy(alpha = 0.8f)
)
@ -257,7 +258,7 @@ private fun SecurityVerificationActions(
) {
Text(
text = stringResource(R.string.fingerprint_start_handshake),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp
)
}
@ -273,7 +274,7 @@ private fun SecurityVerificationActions(
Text(
text = stringResource(R.string.fingerprint_verified_message),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
fontFamily = BitchatFontFamily
),
color = accent.copy(alpha = 0.7f),
modifier = Modifier.fillMaxWidth(),
@ -289,7 +290,7 @@ private fun SecurityVerificationActions(
) {
Text(
text = stringResource(R.string.verify_remove),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp
)
}
@ -303,7 +304,7 @@ private fun SecurityVerificationActions(
Text(
text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
fontFamily = BitchatFontFamily
),
color = accent.copy(alpha = 0.7f),
modifier = Modifier.fillMaxWidth(),
@ -320,7 +321,7 @@ private fun SecurityVerificationActions(
) {
Text(
text = stringResource(R.string.fingerprint_mark_verified),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp
)
}
@ -349,7 +350,7 @@ private fun VerificationStatusRow(
Text(
text = text,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Bold
),
color = textTint
@ -375,7 +376,7 @@ private fun FingerprintBlock(
Text(
text = title,
style = MaterialTheme.typography.labelSmall.copy(
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Bold
),
color = accent.copy(alpha = 0.8f)
@ -385,7 +386,7 @@ private fun FingerprintBlock(
Text(
text = formatFingerprint(fingerprint),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 14.sp
),
color = accent,
@ -417,7 +418,7 @@ private fun FingerprintBlock(
} else {
Text(
text = stringResource(R.string.fingerprint_pending),
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily),
color = Color(0xFFFF9500),
modifier = Modifier.padding(16.dp)
)

View File

@ -1,5 +1,7 @@
package com.bitchat.android.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.QrCodeScanner
import android.graphics.Bitmap
import android.os.Handler
import android.os.Looper
@ -17,7 +19,6 @@ import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -31,8 +32,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@ -60,7 +59,6 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -70,8 +68,10 @@ import androidx.core.graphics.createBitmap
import androidx.core.graphics.set
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.services.VerificationService
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@ -98,8 +98,7 @@ fun VerificationSheet(
) {
if (!isPresented) return
val isDark = isSystemInDarkTheme()
val accent = if (isDark) Color.Green else Color(0xFF008000)
val accent = MaterialTheme.colorScheme.primary
var selectedTab by remember { mutableStateOf(0) } // 0 = My Code, 1 = Scan
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
@ -144,7 +143,7 @@ fun VerificationSheet(
text = {
Text(
text = "My QR",
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 14.sp
)
}
@ -155,7 +154,7 @@ fun VerificationSheet(
text = {
Text(
text = "Scan",
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 14.sp
)
}
@ -208,7 +207,7 @@ fun VerificationSheet(
) {
Text(
text = stringResource(R.string.verify_remove),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp
)
}
@ -232,10 +231,11 @@ private fun VerificationHeader(
Text(
text = stringResource(R.string.verify_title).uppercase(),
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
color = accent
)
CloseButton(onClick = onClose)
val dismiss = LocalSheetDismiss.current
CloseButton(onClick = { dismiss?.invoke() ?: onClose() })
}
}
@ -258,7 +258,7 @@ private fun MyQrTabContent(
Text(
text = stringResource(R.string.verify_my_qr_title),
style = MaterialTheme.typography.titleMedium,
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
color = accent
)
@ -283,7 +283,7 @@ private fun MyQrTabContent(
) {
Text(
text = stringResource(R.string.verify_qr_unavailable),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
color = Color.Black.copy(alpha = 0.6f)
)
@ -296,7 +296,7 @@ private fun MyQrTabContent(
Text(
text = nickname,
style = MaterialTheme.typography.headlineSmall,
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
@ -307,7 +307,7 @@ private fun MyQrTabContent(
Text(
text = stringResource(R.string.app_name).lowercase(),
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
textAlign = TextAlign.Center
)
@ -355,7 +355,7 @@ private fun ScanTabContent(
Text(
text = stringResource(R.string.verify_scan_prompt_friend),
color = Color.White,
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
modifier = Modifier
.align(Alignment.BottomCenter)
@ -386,7 +386,7 @@ private fun ScanTabContent(
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.verify_camera_permission),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
@ -397,7 +397,7 @@ private fun ScanTabContent(
) {
Text(
text = stringResource(R.string.verify_request_camera),
fontFamily = FontFamily.Monospace
fontFamily = BitchatFontFamily
)
}
}

View File

@ -1,24 +1,18 @@
package com.bitchat.android.ui
import android.Manifest
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import android.Manifest
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.unit.dp
import com.bitchat.android.features.voice.VoiceRecorder
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus
@ -27,21 +21,59 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
/**
* How long the button must be held before a recording starts.
*
* Push-to-talk should require intent. Firing on raw pointer-down meant a stray touch started a
* recording, and it also made the control impossible to defend against pointer events it should
* never have seen (see [ArmDelayMs]).
*/
private const val HoldToRecordMs = 220L
/**
* How long the button ignores presses after entering composition.
*
* The action cluster swaps send out for camera+microphone the instant the field is cleared, which
* puts the microphone exactly where the send button was a frame earlier. Tapping send quickly
* could hand the microphone a pointer-down whose matching pointer-up had already been delivered
* to the send button that no longer exists leaving the gesture waiting for a release that will
* never come, stuck in "recording" until the composable is disposed. Refusing presses until the
* swap animation has settled removes that whole class of failure.
*/
private const val ArmDelayMs = 350L
/** Hard cap on a single recording. */
private const val MaxRecordingMs = 10_000L
/** Tail kept after release so the last syllable is not clipped. */
private const val ReleaseTailMs = 500L
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun VoiceRecordButton(
modifier: Modifier = Modifier,
backgroundColor: Color,
/**
* Recording state as the composer sees it. Drives the active tint so the button and the
* pill's border change together instead of one lagging the other.
*/
isRecording: Boolean = false,
onStart: () -> Unit,
onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit,
onFinish: (filePath: String) -> Unit
onFinish: (filePath: String) -> Unit,
/**
* Invoked whenever a recording ends without producing a file permission denied, recorder
* failure, or the button being torn down mid-capture. The caller needs this to clear its own
* recording state; without it a failed capture left the composer stuck in recording mode.
*/
onCancel: () -> Unit = {}
) {
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
var isRecording by remember { mutableStateOf(false) }
var isCapturing by remember { mutableStateOf(false) }
var recorder by remember { mutableStateOf<VoiceRecorder?>(null) }
var recordedFilePath by remember { mutableStateOf<String?>(null) }
var recordingStart by remember { mutableStateOf(0L) }
@ -53,71 +85,127 @@ fun VoiceRecordButton(
val latestOnStart = rememberUpdatedState(onStart)
val latestOnAmplitude = rememberUpdatedState(onAmplitude)
val latestOnFinish = rememberUpdatedState(onFinish)
val latestOnCancel = rememberUpdatedState(onCancel)
Box(
// Set when this instance was composed, so presses inherited from whatever occupied this spot
// beforehand can be rejected.
val composedAt = remember { System.currentTimeMillis() }
fun buzz() {
try {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
} catch (_: Exception) {
}
}
// Last line of defence: if the button is removed while capturing — the cluster swapping to
// send, the sheet closing, the screen going away — release the recorder and tell the caller,
// so nothing is left holding the microphone or showing a recording UI.
DisposableEffect(Unit) {
onDispose {
ampJob?.cancel()
ampJob = null
if (isCapturing) {
isCapturing = false
runCatching { recorder?.stop() }
recorder = null
recordedFilePath = null
latestOnCancel.value()
}
}
}
// Same disc, same sizing and the same press feedback as the camera and send buttons.
ComposerActionSurface(
isActive = isRecording || isCapturing,
isPressed = isCapturing,
modifier = modifier
.size(32.dp)
.background(backgroundColor, CircleShape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
if (!isRecording) {
if (micPermission.status !is PermissionStatus.Granted) {
micPermission.launchPermissionRequest()
return@detectTapGestures
}
val rec = VoiceRecorder(context)
val f = rec.start()
recorder = rec
isRecording = f != null
recordedFilePath = f?.absolutePath
recordingStart = System.currentTimeMillis()
if (isRecording) {
latestOnStart.value()
// Haptic "knock" when recording starts
try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
// Start amplitude polling loop
ampJob?.cancel()
ampJob = scope.launch {
while (isActive && isRecording) {
val amp = recorder?.pollAmplitude() ?: 0
val elapsedMs = (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L)
latestOnAmplitude.value(amp, elapsedMs)
// Auto-stop after 10 seconds
if (elapsedMs >= 10_000 && isRecording) {
val file = recorder?.stop()
isRecording = false
recorder = null
val path = file?.absolutePath
if (!path.isNullOrBlank()) {
// Haptic "knock" on auto stop
try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
latestOnFinish.value(path)
}
break
}
delay(80)
// Guard 1: ignore anything arriving before the swap animation settled.
if (System.currentTimeMillis() - composedAt < ArmDelayMs) {
return@detectTapGestures
}
// Guard 2: never start a second capture on top of a live one.
if (isCapturing) return@detectTapGestures
if (micPermission.status !is PermissionStatus.Granted) {
micPermission.launchPermissionRequest()
return@detectTapGestures
}
// Guard 3: require a deliberate hold. `tryAwaitRelease` returns true on
// release and false on cancellation; either way the press was not a hold,
// so nothing should happen. Only a timeout means the finger is still down.
val stillHeld = withTimeoutOrNull(HoldToRecordMs) {
tryAwaitRelease()
} == null
if (!stillHeld) return@detectTapGestures
val rec = VoiceRecorder(context)
val startedFile = rec.start()
if (startedFile == null) {
// Recorder refused to start; make sure the caller does not sit in a
// recording state that never began.
runCatching { rec.stop() }
latestOnCancel.value()
return@detectTapGestures
}
recorder = rec
recordedFilePath = startedFile.absolutePath
recordingStart = System.currentTimeMillis()
isCapturing = true
latestOnStart.value()
buzz()
ampJob?.cancel()
ampJob = scope.launch {
while (isActive && isCapturing) {
val amp = recorder?.pollAmplitude() ?: 0
val elapsed =
(System.currentTimeMillis() - recordingStart).coerceAtLeast(0L)
latestOnAmplitude.value(amp, elapsed)
if (elapsed >= MaxRecordingMs && isCapturing) {
val file = recorder?.stop()
isCapturing = false
recorder = null
val path = file?.absolutePath ?: recordedFilePath
recordedFilePath = null
buzz()
// Always report the outcome, even when the file is unusable,
// or the caller stays stuck showing the waveform.
if (!path.isNullOrBlank()) {
latestOnFinish.value(path)
} else {
latestOnCancel.value()
}
break
}
delay(80)
}
}
try {
awaitRelease()
tryAwaitRelease()
} finally {
if (isRecording) {
// Extend recording for 500ms after release to avoid clipping
delay(500)
if (isCapturing) {
// Keep going briefly past the release so the tail is not clipped.
delay(ReleaseTailMs)
}
if (isRecording) {
if (isCapturing) {
val file = recorder?.stop()
isRecording = false
isCapturing = false
recorder = null
val path = (file?.absolutePath ?: recordedFilePath)
val path = file?.absolutePath ?: recordedFilePath
recordedFilePath = null
buzz()
if (!path.isNullOrBlank()) {
// Haptic "knock" when recording stops
try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
latestOnFinish.value(path)
} else {
latestOnCancel.value()
}
}
ampJob?.cancel()
@ -125,14 +213,13 @@ fun VoiceRecordButton(
}
}
)
},
contentAlignment = Alignment.Center
) {
}
) { tint ->
Icon(
imageVector = Icons.Filled.Mic,
contentDescription = stringResource(com.bitchat.android.R.string.cd_record_voice),
tint = Color.Black,
modifier = Modifier.size(20.dp)
tint = tint,
modifier = Modifier.size(ComposerIconSize)
)
}
}

View File

@ -3,6 +3,14 @@ package com.bitchat.android.ui.debug
import android.content.ClipData
import android.content.ClipboardManager
import android.widget.Toast
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.WifiTethering
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.SettingsEthernet
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@ -12,25 +20,17 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.WifiTethering
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.SettingsEthernet
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.draw.rotate
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.services.meshgraph.MeshGraphService
import kotlinx.coroutines.launch
@ -67,13 +67,13 @@ fun MeshTopologySection(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93))
Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text("Mesh topology", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
val nodes = snapshot.nodes
val edges = snapshot.edges
val empty = nodes.isEmpty()
if (empty) {
Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
Text("No gossip yet", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
ForceDirectedMeshGraph(
nodes = nodes,
@ -94,10 +94,10 @@ fun MeshTopologySection(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
nodes.forEach { node ->
val label = "${node.peerID.take(8)}${node.nickname ?: "unknown"}"
val label = "${node.peerID.take(8)}${node.nickname ?: "Unknown"}"
Text(
text = label,
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.85f)
)
@ -128,7 +128,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI
Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF5856D6))
Text(
"Distribution info",
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
@ -137,7 +137,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI
if (info == null) {
Text(
"Inspecting installed package…",
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
@ -170,7 +170,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI
},
contentPadding = PaddingValues(horizontal = 0.dp)
) {
Text("Copy certificate fingerprint", fontFamily = FontFamily.Monospace)
Text("Copy certificate fingerprint", fontFamily = BitchatFontFamily)
}
}
}
@ -184,13 +184,13 @@ private fun DistributionInfoRow(label: String, value: String) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
label,
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.55f)
)
Text(
value,
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.9f)
)
@ -347,7 +347,7 @@ fun DebugSettingsSheet(
item {
Text(
text = stringResource(R.string.debug_tools_desc),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
@ -361,13 +361,13 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF00C851))
Text(stringResource(R.string.debug_verbose_logging), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_verbose_logging), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Switch(checked = verboseLogging, onCheckedChange = { manager.setVerboseLoggingEnabled(it) })
}
Text(
stringResource(R.string.debug_verbose_hint),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
@ -392,10 +392,10 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.debug_gatt_server), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Text(stringResource(R.string.debug_gatt_server), fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f))
Switch(checked = gattServerEnabled, onCheckedChange = {
manager.setGattServerEnabled(it)
scope.launch {
@ -404,9 +404,9 @@ fun DebugSettingsSheet(
})
}
val serverCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_SERVER }
Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.debug_max_server), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Text(stringResource(R.string.debug_max_server), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp))
Slider(
value = maxServer.toFloat(),
onValueChange = { manager.setMaxServerConnections(it.toInt().coerceAtLeast(1)) },
@ -415,7 +415,7 @@ fun DebugSettingsSheet(
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.debug_gatt_client), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Text(stringResource(R.string.debug_gatt_client), fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f))
Switch(checked = gattClientEnabled, onCheckedChange = {
manager.setGattClientEnabled(it)
scope.launch {
@ -424,9 +424,9 @@ fun DebugSettingsSheet(
})
}
val clientCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_CLIENT }
Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.debug_max_client), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Text(stringResource(R.string.debug_max_client), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp))
Slider(
value = maxClient.toFloat(),
onValueChange = { manager.setMaxClientConnections(it.toInt().coerceAtLeast(1)) },
@ -435,9 +435,9 @@ fun DebugSettingsSheet(
)
}
val overallCount = connectedDevices.size
Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.debug_max_overall), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Text(stringResource(R.string.debug_max_overall), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp))
Slider(
value = maxOverall.toFloat(),
onValueChange = { manager.setMaxConnectionsOverall(it.toInt().coerceAtLeast(1)) },
@ -447,7 +447,7 @@ fun DebugSettingsSheet(
}
Text(
stringResource(R.string.debug_roles_hint),
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
@ -461,12 +461,12 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50))
Text("Transports", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text("Transports", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
Spacer(Modifier.width(8.dp))
Text("BLE", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Text("BLE", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f))
Switch(checked = bleEnabled, onCheckedChange = {
manager.setBleEnabled(it)
})
@ -474,7 +474,7 @@ fun DebugSettingsSheet(
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Wifi, contentDescription = null, tint = Color(0xFF9C27B0))
Spacer(Modifier.width(8.dp))
Text("WiFi Aware", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Text("WiFi Aware", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f))
val wifiSwitchEnabled = wifiAwareSupported
Text(
when {
@ -482,7 +482,7 @@ fun DebugSettingsSheet(
wifiAwareAvailable -> "available"
else -> "unavailable"
},
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
@ -497,7 +497,7 @@ fun DebugSettingsSheet(
}
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.width(24.dp))
Text("WiFi Aware verbose", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Text("WiFi Aware verbose logging", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f))
Switch(checked = wifiAwareVerbose, onCheckedChange = { manager.setWifiAwareVerbose(it) })
}
}
@ -512,7 +512,7 @@ fun DebugSettingsSheet(
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500))
Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_packet_relay), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Switch(checked = packetRelayed, onCheckedChange = { manager.setPacketRelayEnabled(it) })
}
@ -648,10 +648,10 @@ fun DebugSettingsSheet(
// Helper functions moved to top-level composable below to avoid scope issues
// Render two blocks: Incoming and Outgoing
Text("Incoming", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text("Incoming", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(
"${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}",
fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
)
DrawGraphBlock(
title = "Incoming",
@ -704,10 +704,10 @@ fun DebugSettingsSheet(
if (graphMode != GraphMode.OVERALL && stackedKeysIncoming.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ }
Spacer(Modifier.height(8.dp))
Text("Outgoing", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text("Outgoing", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(
"${relayStats.lastSecondOutgoing}/s • ${relayStats.lastMinuteOutgoing}/m • ${relayStats.last15MinuteOutgoing}/15m • total ${relayStats.totalOutgoingCount}",
fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
)
DrawGraphBlock(
title = "Outgoing",
@ -770,7 +770,7 @@ fun DebugSettingsSheet(
val running by com.bitchat.android.wifiaware.WifiAwareController.running.collectAsState()
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.WifiTethering, contentDescription = null, tint = Color(0xFF9C27B0))
Text("WiFi Aware", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text("WiFi Aware", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
val wifiStatusText = when {
!wifiAwareSupported -> "unsupported"
@ -778,12 +778,12 @@ fun DebugSettingsSheet(
!wifiAwareAvailable -> "unavailable"
else -> "stopped"
}
Text(wifiStatusText, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(wifiStatusText, fontFamily = BitchatFontFamily, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
if (!wifiAwareSupported) {
Text(
wifiAwareSupportStatus?.reason ?: "Wi-Fi Aware is not supported on this device",
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
@ -801,21 +801,21 @@ fun DebugSettingsSheet(
label = { Text("Announce") }
)
}
Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = BitchatFontFamily, fontSize = 12.sp)
if (wifiAwareDiscovered.isEmpty()) {
Text("No discoveries yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
Text("No discoveries yet", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
wifiAwareDiscovered.entries.take(50).forEach { (peer, nick) ->
Text("${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text("${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = BitchatFontFamily, fontSize = 12.sp)
}
}
Divider()
Text("Connected: ${wifiAwareConnected.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text("Connected: ${wifiAwareConnected.size}", fontFamily = BitchatFontFamily, fontSize = 12.sp)
if (wifiAwareConnected.isEmpty()) {
Text("No active sockets", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
Text("No active sockets", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
wifiAwareConnected.entries.take(50).forEach { (peer, ip) ->
Text("${peer.take(8)}… @ $ip", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text("${peer.take(8)}… @ $ip", fontFamily = BitchatFontFamily, fontSize = 12.sp)
}
}
}
@ -828,17 +828,17 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF9C27B0))
Text("sync settings", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text("Sync settings", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Slider(value = seenCapacity.toFloat(), onValueChange = { manager.setSeenPacketCapacity(it.toInt()) }, valueRange = 10f..1000f, steps = 99)
Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Slider(value = gcsMaxBytes.toFloat(), onValueChange = { manager.setGcsMaxBytes(it.toInt()) }, valueRange = 128f..1024f, steps = 0)
Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Slider(value = gcsFpr.toFloat(), onValueChange = { manager.setGcsFprPercent(it.toDouble()) }, valueRange = 0.1f..5.0f, steps = 49)
val p = remember(gcsFpr) { com.bitchat.android.sync.GCSFilter.deriveP(gcsFpr / 100.0) }
val nmax = remember(gcsFpr, gcsMaxBytes) { com.bitchat.android.sync.GCSFilter.estimateMaxElementsForSize(gcsMaxBytes, p) }
Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
}
}
@ -849,22 +849,22 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50))
Text(stringResource(R.string.debug_connected_devices), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_connected_devices), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
val localAddr = remember { meshService.connectionManager.getLocalAdapterAddress() }
Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
if (connectedDevices.isEmpty()) {
Text("none", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
Text("None", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
connectedDevices.forEach { dev ->
Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) {
Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text((dev.peerID ?: stringResource(R.string.unknown)) + "${dev.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text((dev.peerID ?: stringResource(R.string.unknown)) + "${dev.deviceAddress}", fontFamily = BitchatFontFamily, fontSize = 12.sp)
val roleLabel = if (dev.connectionType == ConnectionType.GATT_SERVER) stringResource(R.string.debug_role_server) else stringResource(R.string.debug_role_client)
Text("${dev.nickname ?: ""}" + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + "$roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text("${dev.nickname ?: ""}" + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + "$roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = BitchatFontFamily, modifier = Modifier.clickable {
meshService.connectionManager.disconnectAddress(dev.deviceAddress)
})
}
@ -881,19 +881,19 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
Text(stringResource(R.string.debug_recent_scan_results), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_recent_scan_results), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
if (scanResults.isEmpty()) {
Text(stringResource(R.string.debug_none), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
Text(stringResource(R.string.debug_none), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
scanResults.forEach { res ->
Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) {
Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text((res.peerID ?: stringResource(R.string.unknown)) + "${res.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text((res.peerID ?: stringResource(R.string.unknown)) + "${res.deviceAddress}", fontFamily = BitchatFontFamily, fontSize = 12.sp)
Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = BitchatFontFamily, modifier = Modifier.clickable {
meshService.connectionManager.connectToAddress(res.deviceAddress)
})
}
@ -910,15 +910,15 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500))
Text(stringResource(R.string.debug_debug_console), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_debug_console), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = BitchatFontFamily, modifier = Modifier.clickable {
manager.clearDebugMessages()
})
}
Column(Modifier.heightIn(max = 260.dp).background(colorScheme.surface.copy(alpha = 0.5f)).padding(8.dp)) {
debugMessages.takeLast(100).reversed().forEach { msg ->
Text("${msg.content}", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
Text("${msg.content}", fontFamily = BitchatFontFamily, fontSize = 11.sp)
}
}
}
@ -1039,7 +1039,7 @@ private fun DrawGraphBlock(
Box(Modifier.width(leftGutter).fillMaxHeight()) {
Text(
"p/s",
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f)
@ -1053,14 +1053,14 @@ private fun DrawGraphBlock(
}
Text(
topLabel,
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp)
)
Text(
"0",
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp)
@ -1085,8 +1085,8 @@ private fun DrawGraphBlock(
) {
Box(Modifier.size(10.dp).background(swatchColor, RoundedCornerShape(2.dp)))
Column {
Text(legendTitleFor(key), fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f))
Text(legendMetricsFor(key), fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f))
Text(legendTitleFor(key), fontFamily = BitchatFontFamily, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f))
Text(legendMetricsFor(key), fontFamily = BitchatFontFamily, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f))
}
}
}

View File

@ -1,14 +1,14 @@
package com.bitchat.android.ui.debug
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.outlined.Bluetooth
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.outlined.Bluetooth
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier

View File

@ -1,11 +1,11 @@
package com.bitchat.android.ui.media
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -13,14 +13,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import androidx.compose.material3.ColorScheme
import com.bitchat.android.ui.theme.LocalBitchatPalette
import java.text.SimpleDateFormat
@Composable
@ -33,8 +34,10 @@ fun AudioMessageItem(
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
showSender: Boolean = true
) {
val palette = LocalBitchatPalette.current
val path = message.content.trim()
// Derive sending progress if applicable
val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) {
@ -50,9 +53,11 @@ fun AudioMessageItem(
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
myPeerID = meshService.myPeerID,
palette = palette,
contentColor = colorScheme.onSurface,
timeFormatter = timeFormatter,
includeSender = showSender
)
val haptic = LocalHapticFeedback.current
AnnotatedClickableText(
@ -68,7 +73,7 @@ fun AudioMessageItem(
}
},
onLongPress = { onMessageLongPress?.invoke(message) },
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
color = colorScheme.onSurface,
)

View File

@ -1,5 +1,7 @@
package com.bitchat.android.ui.media
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Description
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -12,8 +14,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Description
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon

View File

@ -1,11 +1,11 @@
package com.bitchat.android.ui.media
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Attachment
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Attachment
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable

View File

@ -1,5 +1,7 @@
package com.bitchat.android.ui.media
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Description
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
@ -11,8 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Description
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import kotlinx.coroutines.delay
@ -96,7 +97,7 @@ fun FileSendingAnimation(
androidx.compose.material3.Text(
text = revealedText,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
fontFamily = BitchatFontFamily,
color = Color.White
),
modifier = Modifier.padding(end = 2.dp)
@ -107,7 +108,7 @@ fun FileSendingAnimation(
androidx.compose.material3.Text(
text = stringResource(R.string.underscore),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
fontFamily = BitchatFontFamily,
color = Color.White
)
)
@ -149,7 +150,7 @@ private fun FileProgressBars(
androidx.compose.material3.Text(
text = progressString,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
fontFamily = BitchatFontFamily,
color = Color(0xFF00FF7F) // Matrix green
),
modifier = modifier

View File

@ -1,5 +1,8 @@
package com.bitchat.android.ui.media
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import android.content.ContentValues
import android.os.Build
import android.provider.MediaStore
@ -10,9 +13,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.res.stringResource
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import java.io.File
@ -101,7 +102,7 @@ fun FullScreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onClo
text = stringResource(R.string.image_counter, (pagerState.currentPage ?: 0) + 1, imagePaths.size),
color = Color.White,
fontSize = 14.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
fontFamily = BitchatFontFamily
)
}
}

View File

@ -1,12 +1,12 @@
package com.bitchat.android.ui.media
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
@ -21,12 +21,13 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.font.FontFamily
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import androidx.compose.material3.ColorScheme
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
import com.bitchat.android.ui.theme.LocalBitchatPalette
import java.text.SimpleDateFormat
@Composable
@ -41,16 +42,20 @@ fun ImageMessageItem(
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
onImageClick: ((String, List<String>, Int) -> Unit)?,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
showSender: Boolean = true
) {
val palette = LocalBitchatPalette.current
val path = message.content.trim()
Column(modifier = modifier.fillMaxWidth()) {
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
myPeerID = meshService.myPeerID,
palette = palette,
contentColor = colorScheme.onSurface,
timeFormatter = timeFormatter,
includeSender = showSender
)
val haptic = LocalHapticFeedback.current
AnnotatedClickableText(
@ -66,7 +71,7 @@ fun ImageMessageItem(
}
},
onLongPress = { onMessageLongPress?.invoke(message) },
fontFamily = FontFamily.Monospace,
fontFamily = BitchatFontFamily,
color = colorScheme.onSurface,
)
@ -138,7 +143,7 @@ fun ImageMessageItem(
}
}
} else {
Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray)
Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = BitchatFontFamily, color = Color.Gray)
}
}
}

View File

@ -1,5 +1,9 @@
package com.bitchat.android.ui.media
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Camera
import androidx.compose.material.icons.filled.Photo
import androidx.compose.material.icons.filled.PhotoCamera
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
@ -7,23 +11,20 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Camera
import androidx.compose.material.icons.filled.Photo
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.bitchat.android.features.media.ImageUtils
import com.bitchat.android.ui.ComposerActionSurface
import com.bitchat.android.ui.ComposerIconSize
import java.io.File
@OptIn(ExperimentalFoundationApi::class)
@ -86,26 +87,31 @@ fun ImagePickerButton(
}
}
Box(
modifier = modifier
.size(32.dp)
.combinedClickable(
onClick = { imagePicker.launch("image/*") },
onLongClick = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
startCameraCapture()
} else {
permissionLauncher.launch(Manifest.permission.CAMERA)
}
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
// Shares the composer's button treatment so camera, microphone and send read as one set.
ComposerActionSurface(
isActive = false,
isPressed = isPressed,
modifier = modifier.combinedClickable(
interactionSource = interactionSource,
indication = null,
onClick = { imagePicker.launch("image/*") },
onLongClick = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
startCameraCapture()
} else {
permissionLauncher.launch(Manifest.permission.CAMERA)
}
),
contentAlignment = Alignment.Center
) {
}
)
) { tint ->
Icon(
imageVector = Icons.Filled.PhotoCamera,
contentDescription = stringResource(com.bitchat.android.R.string.pick_image),
tint = Color.Gray,
modifier = Modifier.size(20.dp)
tint = tint,
modifier = Modifier.size(ComposerIconSize)
)
}

View File

@ -1,5 +1,8 @@
package com.bitchat.android.ui.media
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Description
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -9,9 +12,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Description
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable

View File

@ -1,13 +1,14 @@
package com.bitchat.android.ui.media
import com.bitchat.android.ui.theme.BitchatFontFamily
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import android.media.MediaPlayer
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -18,7 +19,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.font.FontFamily
@Composable
fun VoiceNotePlayer(
@ -110,7 +110,7 @@ fun VoiceNotePlayer(
onSeek = seekTo
)
val durText = if (durationMs > 0) String.format("%02d:%02d", (durationMs / 1000) / 60, (durationMs / 1000) % 60) else "--:--"
Text(text = durText, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text(text = durText, fontFamily = BitchatFontFamily, fontSize = 12.sp)
}
}

View File

@ -0,0 +1,91 @@
package com.bitchat.android.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
/**
* Bitchat-specific color tokens that do not have a faithful Material 3 semantic role.
*
* Standard backgrounds, surfaces, text, outlines, primary/secondary accents, and errors belong
* to [androidx.compose.material3.MaterialTheme.colorScheme]. Keeping only the extra app semantics
* here lets Material components inherit correct defaults without losing Bitchat's identity.
*/
@Immutable
data class BitchatPalette(
// MARK: - Form controls
/**
* Resting border for text inputs. Deliberately a neutral grey rather than the green-tinted
* Material outline: the composer is the one surface the user stares at while typing.
*/
val inputOutline: Color,
/** Border for a focused text input. A step brighter, still neutral. */
val inputOutlineFocused: Color,
/**
* Fill for text inputs. Near-black / near-white and completely untinted, for the same reason
* as [inputOutline] and because the composer sits on top of a green-tinted scrim, so any
* tint of its own compounds into something muddy.
*/
val inputSurface: Color,
/** Fill for a focused text input. A barely perceptible lift. */
val inputSurfaceFocused: Color,
/** Resting disc behind the composer's action glyphs. Neutral grey. */
val inputButton: Color,
// MARK: - Extra semantics
/** Timestamps, placeholders, section labels, disabled states. */
val textTertiary: Color,
/** Self, mentions targeting you, unread DMs. */
val accentOrange: Color,
/** Nostr reachability. */
val accentPurple: Color,
// MARK: - Deterministic peer colors
/** Chroma applied after deriving a peer's stable hue. */
val peerColorSaturation: Float,
/** Brightness applied after deriving a peer's stable hue. */
val peerColorValue: Float,
)
val DarkBitchatPalette = BitchatPalette(
inputOutline = Color(0xFF333635),
inputOutlineFocused = Color(0xFF5A605D),
inputSurface = Color(0xFF0B0B0B),
inputSurfaceFocused = Color(0xFF151515),
inputButton = Color(0xFF1E1E1E),
textTertiary = Color(0xFF6B776B),
accentOrange = Color(0xFFFF9F0A),
accentPurple = Color(0xFFBF5AF2),
peerColorSaturation = 1f,
peerColorValue = 1f,
)
val LightBitchatPalette = BitchatPalette(
inputOutline = Color(0xFFCFD3D1),
inputOutlineFocused = Color(0xFF8E9490),
inputSurface = Color(0xFFFAFAFA),
inputSurfaceFocused = Color(0xFFF2F2F2),
inputButton = Color(0xFFE8E8E8),
textTertiary = Color(0xFF757F75),
accentOrange = Color(0xFFFF9500),
accentPurple = Color(0xFFAF52DE),
peerColorSaturation = 0.85f,
peerColorValue = 0.45f,
)
val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette }
/**
* Motion tokens. The redesign leans on short, snappy transitions: long durations read as
* sluggish on a chat surface where the user is scanning quickly.
*/
object BitchatMotion {
/** Icon tints, text colors, small fills. */
const val QUICK_MS = 120
/** Tab indicators, pill growth, chip reveals. */
const val STANDARD_MS = 180
/** Sheet-level fades and scroll-driven top bars. */
const val EMPHASIZED_MS = 240
}

View File

@ -0,0 +1,64 @@
package com.bitchat.android.ui.theme
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.R
/**
* The bundled Geist Mono family used throughout the app.
*
* Keeping the fonts in the APK preserves offline behavior and guarantees that the design-spec
* metrics do not depend on which monospace family a device happens to provide.
*/
internal val BitchatFontFamily = FontFamily(
Font(R.font.geist_mono_regular, FontWeight.Normal),
Font(R.font.geist_mono_medium, FontWeight.Medium),
Font(R.font.geist_mono_semibold, FontWeight.SemiBold),
Font(R.font.geist_mono_bold, FontWeight.Bold),
)
/** Exact typography, spacing, and opacity values exported for the chat transcript. */
internal object ChatVisualTokens {
val MessageBodyFontSize: TextUnit = 14.sp
val MessageBodyLineHeight: TextUnit = 20.sp
val SenderFontSize: TextUnit = 14.sp
val SenderLineHeight: TextUnit = 16.sp
val SystemActionFontSize: TextUnit = 12.sp
val SystemActionLineHeight: TextUnit = 16.sp
val SystemTimeFontSize: TextUnit = 10.sp
val MessageItemSpacing: Dp = 8.dp
val SenderTopPadding: Dp = 8.dp
val SenderToBodySpacing: Dp = 4.dp
const val SenderSuffixAlpha: Float = 0.60f
const val HighlightAlpha: Float = 0.20f
const val MutedTextAlpha: Float = 0.50f
val MessageBodyStyle = TextStyle(
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Normal,
fontSize = MessageBodyFontSize,
lineHeight = MessageBodyLineHeight,
)
val SenderStyle = TextStyle(
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = SenderFontSize,
lineHeight = SenderLineHeight,
)
val SystemActionStyle = TextStyle(
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
fontSize = SystemActionFontSize,
lineHeight = SystemActionLineHeight,
)
}

View File

@ -0,0 +1,30 @@
package com.bitchat.android.ui.theme
import androidx.compose.ui.graphics.Color
import com.bitchat.android.ui.PeerIdentity
import kotlin.math.abs
/**
* The single identity-to-color boundary used by chat, people sheets, and mentions.
*
* The djb2 hash and hue adjustment are byte-identical to the iOS implementation. Orange is
* avoided because it is reserved for the current user.
*/
fun colorForPeer(identity: PeerIdentity, palette: BitchatPalette): Color {
var hash = 5381UL
for (byte in identity.stableKey.toByteArray()) {
hash = ((hash shl 5) + hash) + byte.toUByte().toULong()
}
var hue = (hash % 360UL).toDouble() / 360.0
val orange = 30.0 / 360.0
if (abs(hue - orange) < 0.05) {
hue = (hue + 0.12) % 1.0
}
return Color.hsv(
hue = (hue * 360).toFloat(),
saturation = palette.peerColorSaturation,
value = palette.peerColorValue
)
}

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