mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 22:45:20 +00:00
3a983c5767
* wifi aware wip * wifi aware wip * starting to work * werk * dms work * wip * fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork * Merge branch 'upstream/main' into fix/wifi-aware-socket-binding * Fix Wi-Fi Aware connectivity and UI integration post-merge - Replace bindProcessToNetwork with bindSocket for VPN compatibility. - Implement Scoped IPv6 address resolution (aware0) for mesh routing. - Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility. - Fix syntax errors and variable name conflicts in Debug UI. * Enhance Wi-Fi Aware robustness and debug UI display - Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection. - Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI. - Fix Map type mismatch warning in ChatViewModel bridge. - Filter self-ID from peer cleanup tables to prevent recursive self-removal. * Share GossipSyncManager across transports to prevent redundant message synchronization - Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder. - Modified WifiAwareMeshService to use the shared GossipSyncManager if available. - Added background cleanup for peer mappings on socket disconnection. - Fixed Kotlin type mismatch during nickname map merging. * Restore VPN acquisition logic and improve peer cleanup - Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active. - Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs. - Switch cleanup logging to debug level to reduce log noise. * Fix wifi aware socket binding 2 (#536) * Background persistence (#505) * persistence step 1 * fix build * messages in the background work, notifications not yet * app state store * DM icon shows up * notification launches when app is closed! * keep ui updated * lifecycle fixes * extensive logging, maybe revert later * send nickname in announcement * quit in notification * setting in about sheet * fix quit bitchat * lifecycle fixes * power mode based on background state * stats for both direciotns * fix graph persistence * better counting * count per device * only compute when debug sheet is open? untested * fix read receipts * fix read receipts fully * fix unread badge if messages have been read in focus * foreground promotion fix * fix app kill in notification * adjust to new tor * nice * about sheet design * bump version 1.6.0 (#524) * Automated update of relay data - Sun Dec 14 06:06:53 UTC 2025 * bump targetSdk (#526) * Automated update of relay data - Sun Dec 21 06:06:56 UTC 2025 * Automated update of relay data - Sun Dec 28 06:07:12 UTC 2025 * Prevent quit notification from reappearing (#530) * shutdown sequence * Prevent quit notification from reappearing * Restrict force-finish broadcast * Cancel quit shutdown on relaunch * fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork * Merge branch 'upstream/main' into fix/wifi-aware-socket-binding * Fix Wi-Fi Aware connectivity and UI integration post-merge - Replace bindProcessToNetwork with bindSocket for VPN compatibility. - Implement Scoped IPv6 address resolution (aware0) for mesh routing. - Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility. - Fix syntax errors and variable name conflicts in Debug UI. * Enhance Wi-Fi Aware robustness and debug UI display - Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection. - Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI. - Fix Map type mismatch warning in ChatViewModel bridge. - Filter self-ID from peer cleanup tables to prevent recursive self-removal. * Share GossipSyncManager across transports to prevent redundant message synchronization - Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder. - Modified WifiAwareMeshService to use the shared GossipSyncManager if available. - Added background cleanup for peer mappings on socket disconnection. - Fixed Kotlin type mismatch during nickname map merging. * Restore VPN acquisition logic and improve peer cleanup - Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active. - Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs. - Switch cleanup logging to debug level to reduce log noise. --------- Co-authored-by: GitHub Action <action@github.com> Co-authored-by: aidenvalue <> * Fix wifi aware routing (#534) * Background persistence (#505) * persistence step 1 * fix build * messages in the background work, notifications not yet * app state store * DM icon shows up * notification launches when app is closed! * keep ui updated * lifecycle fixes * extensive logging, maybe revert later * send nickname in announcement * quit in notification * setting in about sheet * fix quit bitchat * lifecycle fixes * power mode based on background state * stats for both direciotns * fix graph persistence * better counting * count per device * only compute when debug sheet is open? untested * fix read receipts * fix read receipts fully * fix unread badge if messages have been read in focus * foreground promotion fix * fix app kill in notification * adjust to new tor * nice * about sheet design * bump version 1.6.0 (#524) * Automated update of relay data - Sun Dec 14 06:06:53 UTC 2025 * bump targetSdk (#526) * Automated update of relay data - Sun Dec 21 06:06:56 UTC 2025 * Automated update of relay data - Sun Dec 28 06:07:12 UTC 2025 * Prevent quit notification from reappearing (#530) * shutdown sequence * Prevent quit notification from reappearing * Restrict force-finish broadcast * Cancel quit shutdown on relaunch * fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork * Merge branch 'upstream/main' into fix/wifi-aware-socket-binding * Fix Wi-Fi Aware connectivity and UI integration post-merge - Replace bindProcessToNetwork with bindSocket for VPN compatibility. - Implement Scoped IPv6 address resolution (aware0) for mesh routing. - Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility. - Fix syntax errors and variable name conflicts in Debug UI. * Enhance Wi-Fi Aware robustness and debug UI display - Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection. - Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI. - Fix Map type mismatch warning in ChatViewModel bridge. - Filter self-ID from peer cleanup tables to prevent recursive self-removal. * Share GossipSyncManager across transports to prevent redundant message synchronization - Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder. - Modified WifiAwareMeshService to use the shared GossipSyncManager if available. - Added background cleanup for peer mappings on socket disconnection. - Fixed Kotlin type mismatch during nickname map merging. * Restore VPN acquisition logic and improve peer cleanup - Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active. - Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs. - Switch cleanup logging to debug level to reduce log noise. * fix wifi aware routing --------- Co-authored-by: GitHub Action <action@github.com> Co-authored-by: aidenvalue <> * unify connection tracker for ble and wifi (#537) * unify connection tracker for ble and wifi * cleanup nicely * Wifi aware skip bluetooth (#538) * unify connection tracker for ble and wifi * bluetooth optional * refactor mesh core * tests * share gossip * refresh peer list more often * patches: wifi aware mesh refactor core (#549) * Refactor WifiAware memory management and lifecycle handling * Fix Wi-Fi Aware reconnection by ensuring callback unregistration and session watchdog * fix(wifiaware): allow WifiAwareController to restart service if session drops * Enhance WiFi Aware logging with network request timeouts and detailed callback status * Ensure explicit release of WiFi Aware network callbacks on failure or disconnection --------- Co-authored-by: aidenvalue <> * fix: wifi aware mesh private dms (#561) * fix(wifi-aware): restore peer check and remove aggressive session restarts * synchronized access to sockets. * clear old peer socket `onClientConnected` if one was found. * address second round of review * fix: Restart publish session on termination in WifiAwareMeshService * fix: cleanup connection tracker resources when handling peer disconnection without active socket --------- Co-authored-by: aidenvalu3 <> * fix(mesh): address memory leaks and background persistence issues in Wi-Fi Aware - Move message persistence and background notifications for Wi-Fi Aware into the service layer via MeshCore hooks. - Fix memory leak in MainActivity by properly detaching WifiAwareMeshDelegate on pause. - Ensure BluetoothMeshService is initialized before WifiAwareMeshService to guarantee shared GossipSyncManager instance. - Deduplicate persistence logic from MainActivity delegate. * wifi aware working * more robust * fragments work * new files * wifi improvements * handshake works * improve * wifi mesh more robust * fix review * check for support * wifi aware fixes --------- Co-authored-by: aidenvalue <> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: aidenvalu3 <erendentman@gmail.com> Co-authored-by: a1denvalu3 <43107113+a1denvalu3@users.noreply.github.com> Co-authored-by: CC <cc@ggg.local>
765 lines
37 KiB
Kotlin
765 lines
37 KiB
Kotlin
package com.bitchat.android.ui
|
||
|
||
import android.content.Intent
|
||
import android.net.Uri
|
||
import android.provider.Settings
|
||
import androidx.compose.animation.core.animateFloatAsState
|
||
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.text.BasicTextField
|
||
import androidx.compose.material.icons.Icons
|
||
import androidx.compose.material.icons.filled.Bookmark
|
||
import androidx.compose.material.icons.filled.Check
|
||
import androidx.compose.material.icons.filled.Map
|
||
import androidx.compose.material.icons.filled.PinDrop
|
||
import androidx.compose.material.icons.outlined.BookmarkBorder
|
||
import androidx.compose.material3.*
|
||
import androidx.compose.runtime.*
|
||
import androidx.compose.ui.Alignment
|
||
import androidx.compose.ui.Modifier
|
||
import androidx.compose.ui.focus.onFocusChanged
|
||
import androidx.compose.ui.graphics.Color
|
||
import androidx.compose.ui.platform.LocalContext
|
||
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.activity.compose.rememberLauncherForActivityResult
|
||
import androidx.activity.result.contract.ActivityResultContracts
|
||
import com.bitchat.android.geohash.ChannelID
|
||
import kotlinx.coroutines.launch
|
||
import com.bitchat.android.geohash.GeohashChannel
|
||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||
import com.bitchat.android.geohash.LocationChannelManager
|
||
import com.bitchat.android.geohash.GeohashBookmarksStore
|
||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||
import androidx.compose.ui.res.stringResource
|
||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||
import com.bitchat.android.R
|
||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||
|
||
/**
|
||
* Location Channels Sheet for selecting geohash-based location channels
|
||
* Direct port from iOS LocationChannelsSheet for 100% compatibility
|
||
*/
|
||
@OptIn(ExperimentalMaterial3Api::class)
|
||
@Composable
|
||
fun LocationChannelsSheet(
|
||
isPresented: Boolean,
|
||
onDismiss: () -> Unit,
|
||
viewModel: ChatViewModel,
|
||
modifier: Modifier = Modifier
|
||
) {
|
||
val context = LocalContext.current
|
||
val locationManager = LocationChannelManager.getInstance(context)
|
||
val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) }
|
||
|
||
// Observe location manager state
|
||
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
|
||
val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
|
||
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
|
||
val appLocationEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
|
||
val systemLocationEnabled by locationManager.systemLocationEnabled.collectAsStateWithLifecycle()
|
||
val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle()
|
||
|
||
// Observe bookmarks state
|
||
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
|
||
val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle()
|
||
|
||
// Observe reactive participant counts
|
||
val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle()
|
||
|
||
// UI state
|
||
var customGeohash by remember { mutableStateOf("") }
|
||
var customError by remember { mutableStateOf<String?>(null) }
|
||
var isInputFocused by remember { mutableStateOf(false) }
|
||
|
||
// Bottom sheet state
|
||
val sheetState = rememberModalBottomSheetState(
|
||
skipPartiallyExpanded = true
|
||
)
|
||
val coroutineScope = rememberCoroutineScope()
|
||
|
||
// Scroll state for LazyColumn with animated top bar
|
||
val listState = rememberLazyListState()
|
||
val isScrolled by remember {
|
||
derivedStateOf {
|
||
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
|
||
}
|
||
}
|
||
val topBarAlpha by animateFloatAsState(
|
||
targetValue = if (isScrolled) 0.95f else 0f,
|
||
label = "topBarAlpha"
|
||
)
|
||
|
||
val mapPickerLauncher = rememberLauncherForActivityResult(
|
||
contract = ActivityResultContracts.StartActivityForResult()
|
||
) { result ->
|
||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||
val gh = result.data?.getStringExtra(GeohashPickerActivity.EXTRA_RESULT_GEOHASH)
|
||
if (!gh.isNullOrBlank()) {
|
||
customGeohash = gh
|
||
customError = null
|
||
}
|
||
}
|
||
}
|
||
|
||
// iOS system colors (matches iOS exactly)
|
||
val colorScheme = MaterialTheme.colorScheme
|
||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||
val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green
|
||
val standardBlue = Color(0xFF007AFF) // iOS blue
|
||
|
||
if (isPresented) {
|
||
BitchatBottomSheet(
|
||
modifier = modifier,
|
||
onDismissRequest = onDismiss,
|
||
sheetState = sheetState,
|
||
) {
|
||
Box(modifier = Modifier.fillMaxWidth()) {
|
||
LazyColumn(
|
||
state = listState,
|
||
modifier = Modifier.fillMaxSize(),
|
||
contentPadding = PaddingValues(top = 64.dp, bottom = 16.dp)
|
||
) {
|
||
// Header Section
|
||
item(key = "header") {
|
||
Text(
|
||
text = stringResource(R.string.location_channels_desc),
|
||
fontSize = 12.sp,
|
||
fontFamily = FontFamily.Monospace,
|
||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
|
||
modifier = Modifier
|
||
.padding(horizontal = 24.dp)
|
||
)
|
||
}
|
||
|
||
// Permission controls if services enabled
|
||
if (locationServicesEnabled) {
|
||
item(key = "permissions") {
|
||
Column(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(horizontal = 24.dp)
|
||
.padding(bottom = 8.dp),
|
||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||
) {
|
||
when (permissionState) {
|
||
LocationChannelManager.PermissionState.DENIED -> {
|
||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||
Text(
|
||
text = stringResource(R.string.location_permission_denied),
|
||
fontSize = 11.sp,
|
||
fontFamily = FontFamily.Monospace,
|
||
color = Color.Red.copy(alpha = 0.8f)
|
||
)
|
||
TextButton(
|
||
onClick = {
|
||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||
data = Uri.fromParts("package", context.packageName, null)
|
||
}
|
||
context.startActivity(intent)
|
||
}
|
||
) {
|
||
Text(
|
||
text = stringResource(R.string.open_settings),
|
||
fontSize = 11.sp,
|
||
fontFamily = FontFamily.Monospace
|
||
)
|
||
}
|
||
}
|
||
}
|
||
LocationChannelManager.PermissionState.AUTHORIZED -> {
|
||
Text(
|
||
text = stringResource(R.string.location_permission_granted),
|
||
fontSize = 11.sp,
|
||
fontFamily = FontFamily.Monospace,
|
||
color = standardGreen
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Mesh option first
|
||
item(key = "mesh") {
|
||
ChannelRow(
|
||
title = meshTitleWithCount(viewModel),
|
||
subtitle = stringResource(R.string.location_bluetooth_subtitle, bluetoothRangeString()),
|
||
isSelected = selectedChannel is ChannelID.Mesh,
|
||
titleColor = standardBlue,
|
||
titleBold = meshCount(viewModel) > 0,
|
||
trailingContent = null,
|
||
onClick = {
|
||
locationManager.select(ChannelID.Mesh)
|
||
onDismiss()
|
||
}
|
||
)
|
||
}
|
||
|
||
// Nearby options (only show if location services are enabled)
|
||
// CRITICAL: Filter out .building level (precision 8) - iOS pattern
|
||
// iOS: let nearby = manager.availableChannels.filter { $0.level != .building }
|
||
if (availableChannels.isNotEmpty() && locationServicesEnabled) {
|
||
val nearbyChannels = availableChannels.filter { it.level != GeohashChannelLevel.BUILDING }
|
||
items(nearbyChannels) { channel ->
|
||
val coverage = coverageString(channel.geohash.length)
|
||
val nameBase = locationNames[channel.level]
|
||
val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
|
||
val subtitlePrefix = "#${channel.geohash} • $coverage"
|
||
val participantCount = geohashParticipantCounts[channel.geohash] ?: 0
|
||
val highlight = participantCount > 0
|
||
val isBookmarked = bookmarksStore.isBookmarked(channel.geohash)
|
||
|
||
ChannelRow(
|
||
title = geohashTitleWithCount(channel, participantCount),
|
||
subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""),
|
||
isSelected = isChannelSelected(channel, selectedChannel),
|
||
titleColor = standardGreen,
|
||
titleBold = highlight,
|
||
trailingContent = {
|
||
IconButton(onClick = { bookmarksStore.toggle(channel.geohash) }) {
|
||
Icon(
|
||
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
|
||
contentDescription = if (isBookmarked) stringResource(R.string.cd_remove_bookmark) else stringResource(R.string.cd_add_bookmark),
|
||
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
|
||
)
|
||
}
|
||
},
|
||
onClick = {
|
||
// Selecting a suggested nearby channel is not a teleport
|
||
locationManager.setTeleported(false)
|
||
locationManager.select(ChannelID.Location(channel))
|
||
onDismiss()
|
||
}
|
||
)
|
||
}
|
||
} else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
||
item {
|
||
Row(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(horizontal = 32.dp, vertical = 12.dp),
|
||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
CircularProgressIndicator(
|
||
modifier = Modifier.size(16.dp),
|
||
strokeWidth = 2.dp,
|
||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||
)
|
||
Text(
|
||
text = stringResource(R.string.finding_nearby_channels),
|
||
fontSize = 12.sp,
|
||
fontFamily = FontFamily.Monospace
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Bookmarked geohashes
|
||
if (bookmarks.isNotEmpty()) {
|
||
item(key = "bookmarked_header") {
|
||
Text(
|
||
text = stringResource(R.string.bookmarked),
|
||
style = MaterialTheme.typography.labelLarge,
|
||
fontFamily = FontFamily.Monospace,
|
||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(horizontal = 24.dp)
|
||
.padding(top = 8.dp, bottom = 4.dp)
|
||
)
|
||
}
|
||
items(bookmarks) { gh ->
|
||
val level = levelForLength(gh.length)
|
||
val channel = GeohashChannel(level = level, geohash = gh)
|
||
val coverage = coverageString(gh.length)
|
||
val subtitlePrefix = "#${gh} • $coverage"
|
||
val name = bookmarkNames[gh]
|
||
val subtitle = subtitlePrefix + (name?.let { " • ${formattedNamePrefix(level)}$it" } ?: "")
|
||
val participantCount = geohashParticipantCounts[gh] ?: 0
|
||
val title = geohashHashTitleWithCount(gh, participantCount)
|
||
|
||
ChannelRow(
|
||
title = title,
|
||
subtitle = subtitle,
|
||
isSelected = isChannelSelected(channel, selectedChannel),
|
||
titleColor = null,
|
||
titleBold = participantCount > 0,
|
||
trailingContent = {
|
||
IconButton(onClick = { bookmarksStore.toggle(gh) }) {
|
||
Icon(
|
||
imageVector = Icons.Filled.Bookmark,
|
||
contentDescription = stringResource(R.string.cd_remove_bookmark),
|
||
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
|
||
)
|
||
}
|
||
},
|
||
onClick = {
|
||
// For bookmarked selection, mark teleported based on regional membership
|
||
val inRegional = availableChannels.any { it.geohash == gh }
|
||
if (!inRegional && availableChannels.isNotEmpty()) {
|
||
locationManager.setTeleported(true)
|
||
} else {
|
||
locationManager.setTeleported(false)
|
||
}
|
||
locationManager.select(ChannelID.Location(channel))
|
||
onDismiss()
|
||
}
|
||
)
|
||
LaunchedEffect(gh) { bookmarksStore.resolveNameIfNeeded(gh) }
|
||
}
|
||
}
|
||
|
||
// Custom geohash teleport (iOS-style inline form)
|
||
item(key = "custom_geohash") {
|
||
Surface(
|
||
color = Color.Transparent,
|
||
shape = MaterialTheme.shapes.medium,
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(horizontal = 24.dp, vertical = 2.dp)
|
||
) {
|
||
Row(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
Text(
|
||
text = stringResource(R.string.hash_symbol),
|
||
fontSize = BASE_FONT_SIZE.sp,
|
||
fontFamily = FontFamily.Monospace,
|
||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||
)
|
||
|
||
BasicTextField(
|
||
value = customGeohash,
|
||
onValueChange = { newValue ->
|
||
// iOS-style geohash validation (base32 characters only)
|
||
val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
|
||
val filtered = newValue
|
||
.lowercase()
|
||
.replace("#", "")
|
||
.filter { it in allowed }
|
||
.take(12)
|
||
|
||
customGeohash = filtered
|
||
customError = null
|
||
},
|
||
textStyle = androidx.compose.ui.text.TextStyle(
|
||
fontSize = BASE_FONT_SIZE.sp,
|
||
fontFamily = FontFamily.Monospace,
|
||
color = MaterialTheme.colorScheme.onSurface
|
||
),
|
||
modifier = Modifier
|
||
.weight(1f)
|
||
.onFocusChanged { focusState ->
|
||
isInputFocused = focusState.isFocused
|
||
if (focusState.isFocused) {
|
||
coroutineScope.launch {
|
||
sheetState.expand()
|
||
// Scroll to bottom to show input and remove button
|
||
listState.animateScrollToItem(
|
||
index = listState.layoutInfo.totalItemsCount - 1
|
||
)
|
||
}
|
||
}
|
||
},
|
||
singleLine = true,
|
||
decorationBox = { innerTextField ->
|
||
if (customGeohash.isEmpty()) {
|
||
Text(
|
||
text = stringResource(R.string.geohash_placeholder),
|
||
fontSize = BASE_FONT_SIZE.sp,
|
||
fontFamily = FontFamily.Monospace,
|
||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||
)
|
||
}
|
||
innerTextField()
|
||
}
|
||
)
|
||
|
||
val normalized = customGeohash.trim().lowercase().replace("#", "")
|
||
|
||
// Map picker button
|
||
IconButton(onClick = {
|
||
val initial = when {
|
||
normalized.isNotBlank() -> normalized
|
||
selectedChannel is ChannelID.Location -> (selectedChannel as ChannelID.Location).channel.geohash
|
||
else -> ""
|
||
}
|
||
val intent = Intent(context, GeohashPickerActivity::class.java).apply {
|
||
putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial)
|
||
}
|
||
mapPickerLauncher.launch(intent)
|
||
}) {
|
||
Icon(
|
||
imageVector = Icons.Filled.Map,
|
||
contentDescription = stringResource(R.string.cd_open_map),
|
||
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
|
||
)
|
||
}
|
||
|
||
val isValid = validateGeohash(normalized)
|
||
|
||
// iOS-style teleport button
|
||
Button(
|
||
onClick = {
|
||
if (isValid) {
|
||
val level = levelForLength(normalized.length)
|
||
val channel = GeohashChannel(level = level, geohash = normalized)
|
||
// Mark this selection as a manual teleport
|
||
locationManager.setTeleported(true)
|
||
locationManager.select(ChannelID.Location(channel))
|
||
onDismiss()
|
||
} else {
|
||
customError = context.getString(R.string.invalid_geohash)
|
||
}
|
||
},
|
||
enabled = isValid,
|
||
colors = ButtonDefaults.buttonColors(
|
||
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
||
contentColor = MaterialTheme.colorScheme.onSurface
|
||
)
|
||
) {
|
||
Row(
|
||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
Text(
|
||
text = stringResource(R.string.teleport),
|
||
fontSize = BASE_FONT_SIZE.sp,
|
||
fontFamily = FontFamily.Monospace
|
||
)
|
||
Icon(
|
||
imageVector = Icons.Filled.PinDrop,
|
||
contentDescription = stringResource(R.string.cd_teleport),
|
||
modifier = Modifier.size(14.dp),
|
||
tint = MaterialTheme.colorScheme.onSurface
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Error message for custom geohash
|
||
if (customError != null) {
|
||
item(key = "geohash_error") {
|
||
Text(
|
||
text = customError!!,
|
||
fontSize = 12.sp,
|
||
fontFamily = FontFamily.Monospace,
|
||
color = Color.Red,
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(horizontal = 24.dp)
|
||
)
|
||
}
|
||
}
|
||
|
||
// Location services toggle button
|
||
item(key = "location_toggle") {
|
||
Column(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(horizontal = 24.dp)
|
||
.padding(top = 8.dp)
|
||
) {
|
||
Button(
|
||
onClick = {
|
||
if (locationServicesEnabled) {
|
||
locationManager.disableLocationServices()
|
||
} else {
|
||
locationManager.enableLocationServices()
|
||
}
|
||
},
|
||
colors = ButtonDefaults.buttonColors(
|
||
containerColor = if (locationServicesEnabled) {
|
||
Color.Red.copy(alpha = 0.08f)
|
||
} else {
|
||
standardGreen.copy(alpha = 0.12f)
|
||
},
|
||
contentColor = if (locationServicesEnabled) {
|
||
Color(0xFFBF1A1A)
|
||
} else {
|
||
standardGreen
|
||
}
|
||
),
|
||
modifier = Modifier.fillMaxWidth()
|
||
) {
|
||
Text(
|
||
text = if (locationServicesEnabled) stringResource(R.string.disable_location_services) else stringResource(R.string.enable_location_services),
|
||
fontSize = 12.sp,
|
||
fontFamily = FontFamily.Monospace
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TopBar (animated)
|
||
BitchatSheetTopBar(
|
||
onClose = onDismiss,
|
||
modifier = modifier.align(Alignment.TopCenter),
|
||
title = {
|
||
BitchatSheetTitle(
|
||
text = stringResource(R.string.location_channels_title)
|
||
)
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Lifecycle management: when presented, manage location updates
|
||
DisposableEffect(isPresented, permissionState, locationServicesEnabled) {
|
||
if (isPresented && permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
||
locationManager.refreshChannels()
|
||
locationManager.beginLiveRefresh()
|
||
}
|
||
|
||
onDispose {
|
||
locationManager.endLiveRefresh()
|
||
}
|
||
}
|
||
|
||
// Sampling management: update sampling when channels/bookmarks change
|
||
LaunchedEffect(isPresented, availableChannels, bookmarks) {
|
||
if (isPresented) {
|
||
val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList()
|
||
viewModel.beginGeohashSampling(geohashes)
|
||
} else {
|
||
viewModel.endGeohashSampling()
|
||
}
|
||
}
|
||
|
||
// Ensure cleanup when the composable is destroyed (e.g. removed from parent composition)
|
||
DisposableEffect(Unit) {
|
||
onDispose {
|
||
viewModel.endGeohashSampling()
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun ChannelRow(
|
||
title: String,
|
||
subtitle: String,
|
||
isSelected: Boolean,
|
||
titleColor: Color? = null,
|
||
titleBold: Boolean = false,
|
||
trailingContent: (@Composable (() -> Unit))? = null,
|
||
onClick: () -> Unit
|
||
) {
|
||
// iOS-style list row (plain button, no card background)
|
||
Surface(
|
||
onClick = onClick,
|
||
color = if (isSelected) {
|
||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||
} else {
|
||
Color.Transparent
|
||
},
|
||
shape = MaterialTheme.shapes.medium,
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(horizontal = 24.dp, vertical = 2.dp)
|
||
) {
|
||
Row(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||
horizontalArrangement = Arrangement.SpaceBetween,
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
Column(
|
||
modifier = Modifier.weight(1f),
|
||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||
) {
|
||
// Split title to handle count part with smaller font (iOS style)
|
||
val (baseTitle, countSuffix) = splitTitleAndCount(title)
|
||
|
||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||
Text(
|
||
text = baseTitle,
|
||
fontSize = BASE_FONT_SIZE.sp,
|
||
fontFamily = FontFamily.Monospace,
|
||
fontWeight = if (titleBold) FontWeight.Bold else FontWeight.Normal,
|
||
color = titleColor ?: MaterialTheme.colorScheme.onSurface
|
||
)
|
||
|
||
countSuffix?.let { count ->
|
||
Text(
|
||
text = count,
|
||
fontSize = 11.sp,
|
||
fontFamily = FontFamily.Monospace,
|
||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||
)
|
||
}
|
||
}
|
||
|
||
Text(
|
||
text = subtitle,
|
||
fontSize = 12.sp,
|
||
fontFamily = FontFamily.Monospace,
|
||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||
)
|
||
}
|
||
|
||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||
if (isSelected) {
|
||
Icon(
|
||
imageVector = Icons.Filled.Check,
|
||
contentDescription = stringResource(R.string.cd_selected),
|
||
tint = Color(0xFF32D74B), // iOS green for checkmark
|
||
modifier = Modifier.size(20.dp)
|
||
)
|
||
}
|
||
|
||
if (trailingContent != null) {
|
||
trailingContent()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Helper Functions (matching iOS implementation)
|
||
|
||
private fun splitTitleAndCount(title: String): Pair<String, String?> {
|
||
val lastBracketIndex = title.lastIndexOf('[')
|
||
return if (lastBracketIndex != -1) {
|
||
val prefix = title.substring(0, lastBracketIndex).trim()
|
||
val suffix = title.substring(lastBracketIndex)
|
||
Pair(prefix, suffix)
|
||
} else {
|
||
Pair(title, null)
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun meshTitleWithCount(viewModel: ChatViewModel): String {
|
||
val meshCount = meshCount(viewModel)
|
||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, meshCount, meshCount)
|
||
val meshLabel = stringResource(com.bitchat.android.R.string.mesh_label)
|
||
return "$meshLabel [$peopleText]"
|
||
}
|
||
|
||
private fun meshCount(viewModel: ChatViewModel): Int {
|
||
val myID = viewModel.myPeerID
|
||
return viewModel.connectedPeers.value?.count { peerID ->
|
||
peerID != myID
|
||
} ?: 0
|
||
}
|
||
|
||
@Composable
|
||
private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String {
|
||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||
|
||
// For high precision channels (Neighborhood, Block) where we don't broadcast presence,
|
||
// show "? people" instead of "0 people" to avoid misleading "nobody is here" indication.
|
||
val isHighPrecision = channel.level.precision > 5
|
||
val peopleText = if (isHighPrecision && participantCount == 0) {
|
||
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?")
|
||
} else {
|
||
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
||
}
|
||
|
||
val levelName = when (channel.level) {
|
||
com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes
|
||
com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block)
|
||
com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD -> stringResource(com.bitchat.android.R.string.location_level_neighborhood)
|
||
com.bitchat.android.geohash.GeohashChannelLevel.CITY -> stringResource(com.bitchat.android.R.string.location_level_city)
|
||
com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE -> stringResource(com.bitchat.android.R.string.location_level_province)
|
||
com.bitchat.android.geohash.GeohashChannelLevel.REGION -> stringResource(com.bitchat.android.R.string.location_level_region)
|
||
}
|
||
return "$levelName [$peopleText]"
|
||
}
|
||
|
||
@Composable
|
||
private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String {
|
||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||
val level = levelForLength(geohash.length)
|
||
val isHighPrecision = level.precision > 5
|
||
|
||
val peopleText = if (isHighPrecision && participantCount == 0) {
|
||
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?")
|
||
} else {
|
||
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
||
}
|
||
|
||
return "#$geohash [$peopleText]"
|
||
}
|
||
|
||
private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean {
|
||
return when (selectedChannel) {
|
||
is ChannelID.Location -> selectedChannel.channel == channel
|
||
else -> false
|
||
}
|
||
}
|
||
|
||
private fun validateGeohash(geohash: String): Boolean {
|
||
if (geohash.isEmpty() || geohash.length > 12) return false
|
||
val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
|
||
return geohash.all { it in allowed }
|
||
}
|
||
|
||
private fun levelForLength(length: Int): GeohashChannelLevel {
|
||
return when (length) {
|
||
in 0..2 -> GeohashChannelLevel.REGION
|
||
in 3..4 -> GeohashChannelLevel.PROVINCE
|
||
5 -> GeohashChannelLevel.CITY
|
||
6 -> GeohashChannelLevel.NEIGHBORHOOD
|
||
7 -> GeohashChannelLevel.BLOCK
|
||
8 -> GeohashChannelLevel.BUILDING // iOS: precision 8 for building-level
|
||
else -> if (length > 8) GeohashChannelLevel.BUILDING else GeohashChannelLevel.BLOCK
|
||
}
|
||
}
|
||
|
||
private fun coverageString(precision: Int): String {
|
||
// Approximate max cell dimension at equator for a given geohash length
|
||
val maxMeters = when (precision) {
|
||
2 -> 1_250_000.0
|
||
3 -> 156_000.0
|
||
4 -> 39_100.0
|
||
5 -> 4_890.0
|
||
6 -> 1_220.0
|
||
7 -> 153.0
|
||
8 -> 38.2
|
||
9 -> 4.77
|
||
10 -> 1.19
|
||
else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble())
|
||
}
|
||
|
||
// Use metric system for simplicity (could be made locale-aware)
|
||
val km = maxMeters / 1000.0
|
||
return "~${formatDistance(km)} km"
|
||
}
|
||
|
||
private fun formatDistance(value: Double): String {
|
||
return when {
|
||
value >= 100 -> String.format("%.0f", value)
|
||
value >= 10 -> String.format("%.1f", value)
|
||
else -> String.format("%.1f", value)
|
||
}
|
||
}
|
||
|
||
private fun bluetoothRangeString(): String {
|
||
// Approximate Bluetooth LE range for typical mobile devices
|
||
return "~10–50 m"
|
||
}
|
||
|
||
private fun formattedNamePrefix(level: GeohashChannelLevel): String {
|
||
return "~"
|
||
}
|