From e1d07f99f546590fcf248b51040d9168ce0c6f2b Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:56:59 +0200 Subject: [PATCH 01/70] first pass --- .../ui/component/button/BitChatBrandButton.kt | 4 +- .../com/bitchat/android/ui/AboutSections.kt | 622 ++++++++++++++++++ .../java/com/bitchat/android/ui/AboutSheet.kt | 380 +++++------ .../java/com/bitchat/android/ui/ChatHeader.kt | 384 ++++++----- .../java/com/bitchat/android/ui/ChatScreen.kt | 96 +-- .../com/bitchat/android/ui/ChatUIUtils.kt | 482 ++++++-------- .../bitchat/android/ui/GeohashPeopleList.kt | 135 ++-- .../com/bitchat/android/ui/InputComponents.kt | 395 ++++++----- .../android/ui/LocationChannelsSheet.kt | 330 ++++++---- .../bitchat/android/ui/LocationNotesButton.kt | 7 +- .../android/ui/MatrixEncryptionAnimation.kt | 4 + .../bitchat/android/ui/MeshPeerListSheet.kt | 124 ++-- .../bitchat/android/ui/MessageComponents.kt | 139 ++-- .../com/bitchat/android/ui/MessageGrouping.kt | 79 +++ .../android/ui/VoiceInputComponents.kt | 2 +- .../android/ui/media/AudioMessageItem.kt | 12 +- .../android/ui/media/ImageMessageItem.kt | 12 +- .../android/ui/media/ImagePickerButton.kt | 9 +- .../android/ui/theme/BitchatPalette.kt | 106 +++ .../com/bitchat/android/ui/theme/Theme.kt | 42 +- .../bitchat/android/ui/theme/Typography.kt | 41 +- app/src/main/res/values-ar/strings.xml | 1 - app/src/main/res/values-bn/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-fa/strings.xml | 1 - app/src/main/res/values-fil/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-hi/strings.xml | 1 - app/src/main/res/values-id/strings.xml | 1 - app/src/main/res/values-it/strings.xml | 1 - app/src/main/res/values-ja/strings.xml | 1 - app/src/main/res/values-ka/strings.xml | 1 - app/src/main/res/values-ko/strings.xml | 1 - app/src/main/res/values-mg/strings.xml | 1 - app/src/main/res/values-ne/strings.xml | 1 - app/src/main/res/values-nl/strings.xml | 1 - app/src/main/res/values-pa-rPK/strings.xml | 1 - app/src/main/res/values-pt-rBR/strings.xml | 1 - app/src/main/res/values-pt/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-sv/strings.xml | 1 - app/src/main/res/values-th/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-ur/strings.xml | 1 - app/src/main/res/values-vi/strings.xml | 1 - app/src/main/res/values-zh/strings.xml | 1 - app/src/main/res/values/strings.xml | 64 +- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 303 ++++++++- 49 files changed, 2547 insertions(+), 1251 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/AboutSections.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt index 94529d77..15b2b40e 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt @@ -63,7 +63,9 @@ fun BitChatBrandButton( imageVector = BitChatIcon, contentDescription = contentDescription, tint = tint, - modifier = Modifier.size(16.dp), + // 20.dp rather than 16.dp: the pixel-art bubble has heavy internal negative space, + // so it reads noticeably smaller than a solid glyph at the same nominal size. + modifier = Modifier.size(20.dp), ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt new file mode 100644 index 00000000..73403b6d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -0,0 +1,622 @@ +package com.bitchat.android.ui + +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.material.icons.Icons +import androidx.compose.material.icons.filled.AlternateEmail +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material.icons.outlined.BookmarkBorder +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.VisibilityOff +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.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 +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) + +/** Two top-level views of the sheet. */ +enum class AboutTab { + HowToUse, + BasicInfo, +} + +/** + * 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 = FontFamily.Monospace, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = palette.textTertiary, + modifier = modifier.padding(start = AboutHorizontalPadding, top = 24.dp, bottom = 8.dp) + ) +} + +/** + * 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 = FontFamily.Monospace, + 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 = FontFamily.Monospace, + fontSize = 16.sp, + color = palette.textSecondary + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = stringResource(R.string.version_prefix, versionName), + fontFamily = FontFamily.Monospace, + 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 palette = LocalBitchatPalette.current + 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.HowToUse) 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_how_to_use), + isSelected = selected == AboutTab.HowToUse, + onClick = { onSelect(AboutTab.HowToUse) }, + modifier = Modifier.weight(1f) + ) + AboutTabLabel( + text = stringResource(R.string.about_tab_basic_info), + isSelected = selected == AboutTab.BasicInfo, + onClick = { onSelect(AboutTab.BasicInfo) }, + modifier = Modifier.weight(1f) + ) + } + + Box(modifier = Modifier.fillMaxWidth()) { + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + Box( + modifier = Modifier + .offset(x = indicatorOffset) + .width(tabWidth) + .height(2.dp) + .background(colorScheme.primary) + ) + } + } +} + +@Composable +private fun AboutTabLabel( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + + val color by animateColorAsState( + targetValue = if (isSelected) colorScheme.primary else palette.textSecondary, + 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 = FontFamily.Monospace, + 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( + icon: ImageVector, + text: String +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(18.dp) + ) + Text( + text = text, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + lineHeight = 20.sp, + color = palette.textPrimary + ) + } +} + +/** + * 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 = FontFamily.Monospace, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = colorScheme.primary, + modifier = Modifier.padding( + start = AboutHorizontalPadding, + top = 20.dp, + bottom = 8.dp + ) + ) + + AboutInstructionRow( + icon = Icons.Filled.AlternateEmail, + text = stringResource(R.string.about_howto_nickname) + ) + AboutInstructionRow( + icon = Icons.Outlined.Public, + text = stringResource(R.string.about_howto_channels) + ) + AboutInstructionRow( + icon = Icons.Filled.Groups, + text = stringResource(R.string.about_howto_people) + ) + AboutInstructionRow( + icon = Icons.Outlined.BookmarkBorder, + text = stringResource(R.string.about_howto_bookmark) + ) + AboutInstructionRow( + icon = Icons.Filled.AlternateEmail, + text = stringResource(R.string.about_howto_mention) + ) + AboutInstructionRow( + icon = Icons.Filled.Terminal, + text = stringResource(R.string.about_howto_commands) + ) + AboutInstructionRow( + icon = Icons.Filled.DeleteForever, + text = stringResource(R.string.about_howto_panic) + ) + } +} + +/** + * The six-item capability list from the design. + * + * Icons are Material approximations of the designer's custom line art; swap in the exported + * SVGs when they are available. + */ +@Composable +internal fun AboutFeatureCard(modifier: Modifier = Modifier) { + val palette = LocalBitchatPalette.current + + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + val features = listOf( + Triple( + Icons.Filled.WifiOff, + R.string.about_offline_comm_title, + R.string.about_offline_comm_desc + ), + Triple( + Icons.Outlined.Lock, + R.string.about_e2e_title, + R.string.about_e2e_desc + ), + Triple( + Icons.Outlined.Public, + R.string.about_local_channels_title, + R.string.about_local_channels_desc + ), + Triple( + Icons.Outlined.VisibilityOff, + R.string.about_no_tracking_title, + R.string.about_no_tracking_desc + ), + Triple( + Icons.Filled.Shuffle, + R.string.about_ephemeral_title, + R.string.about_ephemeral_desc + ), + Triple( + Icons.Filled.DeleteForever, + R.string.about_panic_title, + R.string.about_panic_desc + ), + ) + + features.forEachIndexed { index, (icon, titleRes, descRes) -> + if (index > 0) { + HorizontalDivider( + // Inset to align with the text column, not the icon. + modifier = Modifier.padding(start = 54.dp), + thickness = 1.dp, + color = palette.outlineVariant + ) + } + AboutFeatureRow( + icon = icon, + title = stringResource(titleRes), + subtitle = stringResource(descRes) + ) + } + } + } +} + +@Composable +private fun AboutFeatureRow( + icon: ImageVector, + title: String, + subtitle: String +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 13.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 1.dp) + .size(22.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = palette.textPrimary + ) + Text( + text = subtitle, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 17.sp, + color = palette.textSecondary + ) + } + } +} + +/** + * Security-audit warning banner. + * + * Deliberately styled as a tinted, outlined card rather than plain red text: it needs to be + * impossible to skim past, but it is also permanent until the audit lands, so a full-bleed + * alarm would quickly become wallpaper. + */ +@Composable +internal fun AboutWarningCard(modifier: Modifier = Modifier) { + val palette = LocalBitchatPalette.current + + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.accentRed.copy(alpha = 0.12f), + border = BorderStroke(1.dp, palette.accentRed.copy(alpha = 0.35f)), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = palette.accentRed, + modifier = Modifier.size(16.dp) + ) + Text( + text = stringResource(R.string.about_warning_title).uppercase(), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + color = palette.accentRed + ) + } + Text( + text = stringResource(R.string.about_warning_body), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 17.sp, + color = palette.accentRed.copy(alpha = 0.85f) + ) + } + } +} + +/** + * Small uppercase pill, e.g. the `RECOMMENDED` badge beside the Tor routing toggle. + */ +@Composable +internal fun BitchatBadge( + text: String, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + Box( + modifier = modifier + .background(palette.accentGreen.copy(alpha = 0.15f), RoundedCornerShape(4.dp)) + .padding(horizontal = 5.dp, vertical = 2.dp) + ) { + Text( + text = text.uppercase(), + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + color = palette.accentGreen + ) + } +} + +// 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 = FontFamily.Monospace, + 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 + val palette = LocalBitchatPalette.current + Box( + modifier = modifier + .size(44.dp) + .background(palette.surface, androidx.compose.foundation.shape.CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(24.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 palette = LocalBitchatPalette.current + val accent = if (isDestructive) palette.accentRed else palette.accentGreen + + 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 = FontFamily.Monospace, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = accent + ) + } + } +} + diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index f137ac63..ce170343 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -1,6 +1,9 @@ package com.bitchat.android.ui +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.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -35,6 +38,8 @@ import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette /** * Feature row for displaying app capabilities @@ -46,11 +51,12 @@ private fun FeatureRow( subtitle: String ) { val colorScheme = MaterialTheme.colorScheme - + val palette = LocalBitchatPalette.current + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), + .padding(horizontal = 16.dp, vertical = 13.dp), verticalAlignment = Alignment.Top ) { Icon( @@ -58,22 +64,24 @@ private fun FeatureRow( contentDescription = null, tint = colorScheme.primary, modifier = Modifier - .padding(top = 2.dp) + .padding(top = 1.dp) .size(22.dp) ) - Spacer(modifier = Modifier.width(14.dp)) + Spacer(modifier = Modifier.width(16.dp)) Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( text = title, - style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = colorScheme.onSurface + color = palette.textPrimary ) Text( text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface.copy(alpha = 0.6f), - lineHeight = 18.sp + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = palette.textSecondary, + lineHeight = 17.sp ) } } @@ -89,18 +97,26 @@ private fun ThemeChip( onClick: () -> Unit, modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - + val palette = LocalBitchatPalette.current + + // 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) palette.accentGreen else palette.surfaceVariant, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "themeChipContainer" + ) + val labelColor by animateColorAsState( + targetValue = if (selected) Color.White else palette.textSecondary, + 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 @@ -110,9 +126,10 @@ private fun ThemeChip( ) { Text( text = label, - style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + 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 ) } } @@ -133,23 +150,23 @@ 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 + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), + .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 = if (enabled) colorScheme.primary else palette.textTertiary, 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) @@ -160,31 +177,33 @@ private fun SettingsToggleRow( ) { Text( text = title, - style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = if (enabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f) + color = if (enabled) palette.textPrimary else palette.textTertiary ) 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 = FontFamily.Monospace, + fontSize = 12.sp, + color = if (enabled) palette.textSecondary else palette.textTertiary, + lineHeight = 17.sp ) } - + Spacer(modifier = Modifier.width(16.dp)) - + Switch( checked = checked, onCheckedChange = { if (enabled) onCheckedChange(it) }, enabled = enabled, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, - checkedTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), + checkedTrackColor = palette.accentGreen, uncheckedThumbColor = Color.White, - uncheckedTrackColor = colorScheme.surfaceVariant + uncheckedTrackColor = palette.surfaceVariant ) ) } @@ -221,12 +240,15 @@ 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 + val isDark = palette.isDark + var selectedTab by remember { mutableStateOf(AboutTab.HowToUse) } + if (isPresented) { BitchatBottomSheet( modifier = modifier, @@ -236,103 +258,48 @@ 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 ?: "") + } + + item(key = "tabs") { + AboutTabBar( + selected = selectedTab, + onSelect = { selectedTab = it }, + modifier = Modifier.padding(top = 24.dp) + ) + } + + if (selectedTab == AboutTab.HowToUse) { + item(key = "how_to_use") { + AboutHowToUseSection() } } + if (selectedTab == AboutTab.BasicInfo) { // 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) - ) - } - } + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_about)) + AboutFeatureCard() } } // 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(), - color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape ) { Row( modifier = Modifier @@ -374,18 +341,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(), - color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape ) { Column { // Background Mode Toggle @@ -404,12 +367,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 = palette.outlineVariant ) - + // Proof of Work Toggle SettingsToggleRow( icon = Icons.Filled.Speed, @@ -418,16 +382,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 = palette.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 -> @@ -440,9 +405,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 -> palette.accentGreen + torStatus.running -> palette.accentOrange + else -> palette.accentRed } Surface( color = statusColor, @@ -454,15 +419,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) + color = palette.textTertiary, + modifier = Modifier.padding( + start = AboutHorizontalPadding + 16.dp, + top = 8.dp + ) ) } } @@ -472,13 +440,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(), - color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape ) { Column( modifier = Modifier.padding(16.dp), @@ -490,30 +460,36 @@ fun AboutSheet( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Difficulty", - style = MaterialTheme.typography.bodyMedium, + text = stringResource(R.string.about_difficulty), + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = colorScheme.onSurface + color = palette.textPrimary ) Text( - text = "$powDifficulty bits • ${NostrProofOfWork.estimateMiningTime(powDifficulty)}", - style = MaterialTheme.typography.bodySmall, + text = stringResource( + R.string.about_difficulty_value, + powDifficulty, + NostrProofOfWork.estimateMiningTime(powDifficulty) + ), fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) + fontSize = 12.sp, + color = palette.textSecondary ) } - + 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 = palette.accentGreen, + activeTrackColor = palette.accentGreen, + inactiveTrackColor = palette.surfaceVariant ) ) - + Text( text = when { powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none) @@ -526,7 +502,7 @@ fun AboutSheet( }, fontSize = 12.sp, fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f) + color = palette.textTertiary ) } } @@ -539,13 +515,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(), - color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape ) { Column( modifier = Modifier.padding(16.dp), @@ -556,16 +534,21 @@ 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 -> palette.accentGreen + torStatus.running -> palette.accentOrange + else -> palette.accentRed } 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 = FontFamily.Monospace, + fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = colorScheme.onSurface + color = palette.textPrimary ) } if (torStatus.lastLogLine.isNotEmpty()) { @@ -573,7 +556,7 @@ fun AboutSheet( text = torStatus.lastLogLine.take(120), fontSize = 11.sp, fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f), + color = palette.textTertiary, maxLines = 2 ) } @@ -583,49 +566,22 @@ fun AboutSheet( } } - // Emergency Warning + // Security audit 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) - ) - } - } - } + AboutWarningCard(modifier = Modifier.padding(top = 24.dp)) } + } // end BasicInfo 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) ) { @@ -641,9 +597,9 @@ fun AboutSheet( } Text( text = stringResource(R.string.about_footer), - fontSize = 12.sp, + fontSize = 11.sp, fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.4f) + color = palette.textTertiary ) Spacer(modifier = Modifier.height(20.dp)) } @@ -656,7 +612,7 @@ fun AboutSheet( .align(Alignment.TopCenter) .fillMaxWidth() .height(64.dp) - .background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha)) + .background(palette.background.copy(alpha = topBarAlpha)) ) { CloseButton( onClick = onDismiss, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index e91e30be..45657cef 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -2,20 +2,27 @@ package com.bitchat.android.ui import android.util.Log +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween 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.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.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalFocusManager @@ -30,28 +37,71 @@ import androidx.compose.foundation.Canvas import androidx.compose.ui.geometry.Offset import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.core.ui.component.button.BitChatBrandButton +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 40.dp tap targets fit properly. */ +val ChatHeaderHeight = 48.dp +/** Standard icon size in the header. */ +private val HeaderIconSize = 18.dp +/** Minimum tap target for every interactive element in the header. */ +private val HeaderTapTarget = 40.dp + +/** Corner radius for the header's tappable label+icon clusters. */ +private val HeaderClusterShape = RoundedCornerShape(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) + .clickable(onClickLabel = contentDescription) { onClick() }, + contentAlignment = Alignment.Center + ) { + content() + } +} @Composable fun TorStatusDot( modifier: Modifier = Modifier ) { + val palette = LocalBitchatPalette.current 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 + val targetColor = when { + torStatus.running && torStatus.bootstrapPercent < 100 -> palette.accentOrange + torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen + else -> palette.accentRed } + // Cross-fade rather than snap: Tor flips through bootstrapping states frequently and a + // hard colour cut in the corner of the screen reads as a glitch. + val dotColor by animateColorAsState( + targetValue = targetColor, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "torDotColor" + ) Canvas( modifier = modifier ) { @@ -125,7 +175,7 @@ fun NicknameEditor( Text( text = stringResource(R.string.at_symbol), style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary.copy(alpha = 0.8f) + color = colorScheme.primary.copy(alpha = 0.7f) ) BasicTextField( @@ -144,7 +194,7 @@ fun NicknameEditor( } ), modifier = Modifier - .widthIn(max = 120.dp) + .widthIn(max = 140.dp) .horizontalScroll(scrollState) ) } @@ -161,45 +211,56 @@ fun PeerCounter( onClick: () -> Unit, modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme - + val palette = LocalBitchatPalette.current + // 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) palette.accentGreen 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) palette.accentBlue 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(4.dp), + modifier = modifier + .clip(HeaderClusterShape) + .clickable { onClick() } + .height(HeaderTapTarget) + .padding(horizontal = 6.dp) ) { Icon( - imageVector = Icons.Default.Group, + // `Groups` reads as a crowd at 18.dp, where `Group`'s two-person silhouette turns + // into an indistinct blob. + imageVector = Icons.Filled.Groups, 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", style = MaterialTheme.typography.bodyMedium, - color = countColor, - fontSize = 16.sp, + color = animatedCountColor, fontWeight = FontWeight.Medium ) @@ -207,8 +268,7 @@ fun PeerCounter( Text( text = stringResource(R.string.channel_count_prefix) + "${joinedChannels.size}", style = MaterialTheme.typography.bodyMedium, - color = if (isConnected) Color(0xFF00C851) else Color.Red, - fontSize = 16.sp, + color = if (isConnected) palette.accentGreen else palette.accentRed, fontWeight = FontWeight.Medium ) } @@ -266,48 +326,36 @@ private fun ChannelHeader( onSidebarClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - + val palette = LocalBitchatPalette.current + Box(modifier = Modifier.fillMaxWidth()) { - // Back button - positioned all the way to the left with minimal margin - Button( + // Back: a chevron alone is unambiguous here and buys back ~40.dp of title space that + // the old "< back" label consumed. + HeaderIconButton( 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.back), + modifier = Modifier.align(Alignment.CenterStart) ) { - 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 - ) - } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + modifier = Modifier.size(20.dp), + tint = colorScheme.primary + ) } - + // Title - perfectly centered regardless of other elements Text( - text = stringResource(R.string.chat_channel_prefix, channel), + text = "#$channel", style = MaterialTheme.typography.titleMedium, - color = Color(0xFFFF9500), // Orange to match input field + color = colorScheme.primary, modifier = Modifier .align(Alignment.Center) + .clip(HeaderClusterShape) .clickable { onSidebarClick() } + .padding(horizontal = 8.dp, vertical = 4.dp) ) - + // Leave button - positioned on the right TextButton( onClick = onLeaveChannel, @@ -315,8 +363,8 @@ private fun ChannelHeader( ) { Text( text = stringResource(R.string.chat_leave), - style = MaterialTheme.typography.bodySmall, - color = Color.Red + style = MaterialTheme.typography.labelMedium, + color = palette.accentRed ) } } @@ -334,6 +382,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() @@ -349,80 +398,49 @@ private fun MainHeader( Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.fillMaxHeight(), - verticalAlignment = Alignment.CenterVertically - ) { - BitChatBrandButton( - onClick = onTitleClick, - onTripleClick = onTripleTitleClick, - contentDescription = stringResource(R.string.cd_open_about), - ) + // MARK: - Identity cluster + BitChatBrandButton( + onClick = onTitleClick, + onTripleClick = onTripleTitleClick, + contentDescription = stringResource(R.string.cd_open_about), + ) - Text( - text = "/", - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary, - ) + Text( + text = "/", + style = MaterialTheme.typography.bodyMedium, + // 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(horizontal = 2.dp) + ) - Spacer(modifier = Modifier.width(2.dp)) - - NicknameEditor( - value = nickname, - onValueChange = onNicknameChange - ) - } - - // Right section with location channels button and peer counter + NicknameEditor( + value = nickname, + onValueChange = onNicknameChange + ) + + Spacer(modifier = Modifier.weight(1f)) + + // MARK: - Status cluster. Order matches the design: bookmark, channel, people. Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp) + // Tight, because every child below is its own >=40.dp tap target. + horizontalArrangement = Arrangement.spacedBy(2.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) - ) - } - - // 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) - ) - } + HeaderIconButton( + onClick = { viewModel.openLatestUnreadPrivateChat() }, + contentDescription = stringResource(R.string.cd_unread_private_messages) + ) { + Icon( + imageVector = Icons.Filled.Email, + contentDescription = stringResource(R.string.cd_unread_private_messages), + modifier = Modifier.size(HeaderIconSize), + tint = palette.accentOrange + ) } } @@ -432,19 +450,43 @@ private fun MainHeader( onClick = onLocationNotesClick ) - // Tor status dot when Tor is enabled - TorStatusDot( - modifier = Modifier - .size(8.dp) - .padding(start = 0.dp, end = 2.dp) - ) - + // Tor status dot. Wrapped in a fixed box so its optical position no longer depends + // on ad-hoc asymmetric padding. + Box(modifier = Modifier.size(16.dp), contentAlignment = Alignment.Center) { + TorStatusDot(modifier = Modifier.size(6.dp)) + } + // PoW status indicator PoWStatusIndicator( modifier = Modifier, style = PoWIndicatorStyle.COMPACT ) - Spacer(modifier = Modifier.width(2.dp)) + + // 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) + HeaderIconButton( + onClick = { bookmarksStore.toggle(currentGeohash) }, + contentDescription = stringResource(R.string.cd_toggle_bookmark) + ) { + Icon( + imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, + contentDescription = stringResource(R.string.cd_toggle_bookmark), + tint = if (isBookmarked) palette.accentGreen else palette.textSecondary, + modifier = Modifier.size(HeaderIconSize) + ) + } + } + + LocationChannelsButton( + viewModel = viewModel, + onClick = onLocationChannelsClick + ) + PeerCounter( connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, joinedChannels = joinedChannels, @@ -458,56 +500,64 @@ private fun MainHeader( } } +/** + * Current channel indicator: a globe for geohash channels, a Bluetooth 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 - + val palette = LocalBitchatPalette.current + // 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) { + is com.bitchat.android.geohash.ChannelID.Location -> "#${channel.channel.geohash}" + else -> "#mesh" } - - 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) + val badgeColor = if (isLocation) palette.accentGreen else palette.accentBlue + val badgeIcon = if (isLocation) Icons.Outlined.Public else Icons.Filled.Bluetooth + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .clip(HeaderClusterShape) + .clickable(onClickLabel = stringResource(R.string.location_channels_title)) { onClick() } + .height(HeaderTapTarget) + .padding(horizontal = 6.dp) ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = badgeText, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace - ), - color = badgeColor, - maxLines = 1 + Icon( + imageVector = badgeIcon, + contentDescription = null, + modifier = Modifier.size(HeaderIconSize), + tint = badgeColor + ) + + Text( + text = badgeText, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = badgeColor, + maxLines = 1 + ) + + // Teleportation indicator (like iOS) + if (teleported) { + Icon( + imageVector = Icons.Default.PinDrop, + contentDescription = stringResource(R.string.cd_teleported), + modifier = Modifier.size(12.dp), + tint = badgeColor ) - - // 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 - ) - } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 78d5b571..340f180c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -5,6 +5,8 @@ package com.bitchat.android.ui 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.* @@ -26,6 +28,8 @@ import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.model.BitchatMessage import com.bitchat.android.ui.media.FullScreenImageViewer +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette /** * Main ChatScreen - REFACTORED to use component-based architecture @@ -40,6 +44,7 @@ import com.bitchat.android.ui.media.FullScreenImageViewer @Composable fun ChatScreen(viewModel: ChatViewModel) { val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current val messages by viewModel.messages.collectAsStateWithLifecycle() val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() val nickname by viewModel.nickname.collectAsStateWithLifecycle() @@ -114,7 +119,7 @@ fun ChatScreen(viewModel: ChatViewModel) { .fillMaxSize() .background(colorScheme.background) // Extend background to fill entire screen including status bar ) { - val headerHeight = 42.dp + val headerHeight = ChatHeaderHeight // Main content area that responds to keyboard/window insets Column( @@ -263,14 +268,23 @@ fun ChatScreen(viewModel: ChatViewModel) { .windowInsetsPadding(WindowInsets.statusBars) .offset(y = headerHeight) .zIndex(1f), - color = colorScheme.outline.copy(alpha = 0.3f) + thickness = 1.dp, + color = palette.outlineVariant ) // 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) @@ -280,16 +294,16 @@ fun ChatScreen(viewModel: ChatViewModel) { ) { Surface( shape = CircleShape, - color = colorScheme.background, + color = palette.surface, tonalElevation = 3.dp, shadowElevation = 6.dp, - border = BorderStroke(2.dp, Color(0xFF00C851)) + border = BorderStroke(1.dp, palette.accentGreen) ) { IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) { Icon( imageVector = Icons.Filled.ArrowDownward, contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom), - tint = Color(0xFF00C851) + tint = palette.accentGreen ) } } @@ -394,12 +408,14 @@ fun ChatInputSection( colorScheme: ColorScheme, showMediaButtons: Boolean ) { + val palette = LocalBitchatPalette.current Surface( modifier = Modifier.fillMaxWidth(), color = colorScheme.background ) { + // No divider above the composer: the pill's own border provides the separation, and a + // full-width rule on top of it read as a double line. Column { - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) // Command suggestions box if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { CommandSuggestionsBox( @@ -407,7 +423,7 @@ fun ChatInputSection( onSuggestionClick = onCommandSuggestionClick, modifier = Modifier.fillMaxWidth() ) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f)) + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) } // Mention suggestions box if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) { @@ -416,7 +432,7 @@ fun ChatInputSection( onSuggestionClick = onMentionSuggestionClick, modifier = Modifier.fillMaxWidth() ) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f)) + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) } MessageInput( value = messageText, @@ -434,7 +450,6 @@ fun ChatInputSection( } } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChatFloatingHeader( headerHeight: Dp, @@ -459,35 +474,38 @@ private fun ChatFloatingHeader( .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() + // A plain Row rather than M3's TopAppBar. TopAppBar 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. + Row( + modifier = Modifier + .fillMaxWidth() + .height(headerHeight) + .padding(start = 12.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ChatHeaderContent( + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + viewModel = viewModel, + onBackClick = { + when { + selectedPrivatePeer != null -> viewModel.endPrivateChat() + currentChannel != null -> viewModel.switchToChannel(null) } - ) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent - ), - modifier = Modifier.height(headerHeight) // Ensure compact header height - ) + }, + onSidebarClick = onSidebarToggle, + onTripleClick = onPanicClear, + onShowAppInfo = onShowAppInfo, + onLocationChannelsClick = onLocationChannelsClick, + onLocationNotesClick = { + // Ensure location is loaded before showing sheet + locationManager.refreshChannels() + onLocationNotesClick() + } + ) + } } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index d227eb41..2f945593 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -8,8 +8,8 @@ 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 java.text.SimpleDateFormat import java.util.* @@ -18,6 +18,15 @@ 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 = 0.55f + +/** Background opacity for a mention chip referring to somebody else. */ +internal const val MENTION_CHIP_ALPHA = 0.18f + +/** Background opacity for a mention chip referring to you. Slightly stronger to catch the eye. */ +internal const val MENTION_CHIP_ALPHA_SELF = 0.22f + /** * Get RSSI-based color for signal strength visualization */ @@ -32,146 +41,21 @@ 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 + 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 senderColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark) + val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold val (baseName, suffix) = splitSuffix(message.sender) builder.pushStyle( @@ -198,7 +82,7 @@ fun formatTextMessageSender( if (suffix.isNotEmpty()) { builder.pushStyle( SpanStyle( - color = senderColor.copy(alpha = 0.6f), + color = senderColor.copy(alpha = SUFFIX_ALPHA), fontSize = BASE_FONT_SIZE.sp, fontWeight = senderWeight ) @@ -212,6 +96,9 @@ 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 [appendTrailingTimestamp]. */ fun formatTextMessageMetadata( message: BitchatMessage, @@ -233,66 +120,124 @@ 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 appendTrailingTimestamp( + builder: AnnotatedString.Builder, + message: BitchatMessage, + palette: BitchatPalette, + timeFormatter: SimpleDateFormat +) { + builder.pushStyle( + SpanStyle( + color = palette.textTertiary, + fontSize = (BASE_FONT_SIZE - 4).sp, + fontWeight = FontWeight.Normal + ) + ) + builder.append(" ") + builder.append(timeFormatter.format(message.timestamp)) + message.powDifficulty?.takeIf { it > 0 }?.let { bits -> + builder.append(" ⛨${bits}b") + } + 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, + timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", 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, ) + + if (includeTimestamp) { + appendTrailingTimestamp(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, + palette: BitchatPalette, + timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) +): AnnotatedString { + val builder = AnnotatedString.Builder() + builder.pushStyle( + SpanStyle( + color = palette.textSecondary, + fontSize = (BASE_FONT_SIZE - 2).sp, + fontWeight = FontWeight.Normal + ) + ) + builder.append("// ") + builder.append(message.content) + builder.pop() + + appendTrailingTimestamp(builder, message, palette, 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()) + palette: BitchatPalette, + timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", 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, meshService.myPeerID) - if (message.sender != "system") { - val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) + if (message.sender == "system") { + return formatSystemMessage(message, palette, timeFormatter) + } + + if (includeSender) { + val baseColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark) + val weight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold 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 = BASE_FONT_SIZE.sp, + fontWeight = weight + ) + ) + builder.append("@") val nicknameStart = builder.length builder.append(truncateNickname(baseName)) val nicknameEnd = builder.length @@ -306,53 +251,20 @@ 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 = BASE_FONT_SIZE.sp, + fontWeight = weight + ) + ) 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() } + appendTrailingTimestamp(builder, message, palette, timeFormatter) return builder.toAnnotatedString() } @@ -385,7 +297,12 @@ fun getPeerColor(message: BitchatMessage, isDark: Boolean): Color { } /** - * Generate consistent peer color using djb2 hash (matches iOS algorithm exactly) + * Generate consistent peer color using djb2 hash. + * + * The hash and hue derivation are byte-identical to the iOS implementation, so a given peer + * resolves to the same hue on both platforms. Saturation and value are deliberately higher + * than iOS: the redesigned chat surface renders message bodies in neutral near-white, so + * nicknames need more chroma to stay distinguishable at a glance. */ fun colorForPeerSeed(seed: String, isDark: Boolean): Color { // djb2 hash algorithm (matches iOS implementation) @@ -402,8 +319,8 @@ fun colorForPeerSeed(seed: String, isDark: Boolean): Color { hue = (hue + 0.12) % 1.0 } - val saturation = if (isDark) 0.50 else 0.70 - val brightness = if (isDark) 0.85 else 0.35 + val saturation = if (isDark) 0.62 else 0.85 + val brightness = if (isDark) 0.92 else 0.45 return Color.hsv( hue = (hue * 360).toFloat(), @@ -430,17 +347,27 @@ fun splitSuffix(name: String): Pair { } /** - * iOS-style content formatting with proper hashtag and mention handling + * iOS-style content formatting with proper hashtag and mention handling. + * + * Redesign notes: + * - Plain text renders in [BitchatPalette.textPrimary]; colour is reserved for `@mentions`, + * links and geohashes. + * - Mentions get a tinted background chip so they read as a distinct token inside a sentence. + * The chip is tinted by *the mentioned peer's* colour, not the sender's, so `@alice` looks + * the same everywhere she is referenced. + * - Neither "self" nor "you were mentioned" bolds the whole body any more. Bolding entire + * paragraphs was the single biggest source of visual noise in the old layout; the mention + * chip carries that emphasis instead. */ private fun appendIOSFormattedContent( builder: AnnotatedString.Builder, content: String, - mentions: List?, currentUserNickname: String, - baseColor: Color, - isSelf: Boolean, - contentColor: Color = baseColor, + palette: BitchatPalette, ) { + val contentColor = palette.textPrimary + val linkColor = palette.accentBlue + // 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() @@ -507,28 +434,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 +467,55 @@ 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 { + // Tint by the *mentioned* peer so a given name looks identical everywhere. + colorForPeerSeed(mentionWithoutAt.lowercase(), palette.isDark) + } + 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 +528,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 +542,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 +555,8 @@ private fun appendIOSFormattedContent( // Add remaining text if (lastEnd < content.length) { val remainingText = content.substring(lastEnd) - builder.pushStyle(SpanStyle( - color = contentColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) - if (isMentioned) { - builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - builder.append(remainingText) - builder.pop() - } else { - builder.append(remainingText) - } + builder.pushStyle(plainStyle) + builder.append(remainingText) builder.pop() } } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 0bdc85ee..1eccc152 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import android.util.Log import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.Explore @@ -17,6 +18,7 @@ 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 com.bitchat.android.ui.theme.LocalBitchatPalette import java.util.* import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource @@ -52,41 +54,21 @@ fun GeohashPeopleList( 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) - ) - } - + val palette = LocalBitchatPalette.current + + Column(modifier = modifier) { if (geohashPeople.isEmpty()) { + SheetSectionLabel(text = stringResource(R.string.section_on_location)) // 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) + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = palette.textTertiary, + modifier = Modifier.padding( + horizontal = SheetHorizontalPadding + 14.dp, + vertical = 12.dp + ) ) } else { // Get current geohash identity for "me" detection @@ -129,12 +111,20 @@ fun GeohashPeopleList( counts } - val firstID = orderedPeople.firstOrNull()?.id - - orderedPeople.forEach { person -> + // Split by presence: people physically in the geohash versus people who teleported + // in. Mixing them hid the fact that a "nearby" channel can contain remote users. + fun personTeleported(person: GeoPerson): Boolean = if (person.id == myHex) { + isTeleported + } else { + viewModel.isPersonTeleported(person.id) + } + + val (teleportedPeople, localPeople) = orderedPeople.partition { personTeleported(it) } + + @Composable + fun personRow(person: GeoPerson) { GeohashPersonItem( person = person, - isFirst = person.id == firstID, isMe = myHex != null && person.id == myHex, hasUnreadDM = unreadPrivateMessages.contains("nostr_${person.id.take(16)}"), isTeleported = person.id != myHex && viewModel.isPersonTeleported(person.id), @@ -153,6 +143,16 @@ fun GeohashPeopleList( } ) } + + if (localPeople.isNotEmpty()) { + SheetSectionLabel(text = stringResource(R.string.section_on_location)) + localPeople.forEach { personRow(it) } + } + + if (teleportedPeople.isNotEmpty()) { + SheetSectionLabel(text = stringResource(R.string.section_teleported_in)) + teleportedPeople.forEach { personRow(it) } + } } } } @@ -160,7 +160,6 @@ fun GeohashPeopleList( @Composable private fun GeohashPersonItem( person: GeoPerson, - isFirst: Boolean, isMe: Boolean, hasUnreadDM: Boolean, isTeleported: Boolean, @@ -171,12 +170,21 @@ private fun GeohashPersonItem( showHashSuffix: Boolean, onTap: () -> Unit ) { + val palette = LocalBitchatPalette.current + + Surface( + onClick = onTap, + color = palette.surface, + shape = RoundedCornerShape(10.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) + ) { Row( modifier = Modifier .fillMaxWidth() - .clickable { onTap() } - .padding(horizontal = 24.dp, vertical = 4.dp) - .padding(top = if (isFirst) 10.dp else 0.dp), + .heightIn(min = 44.dp) + .padding(horizontal = 14.dp), verticalAlignment = Alignment.CenterVertically ) { // Icon logic matching iOS exactly @@ -185,16 +193,16 @@ private fun GeohashPersonItem( Icon( imageVector = Icons.Filled.Email, contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(12.dp), - tint = Color(0xFFFF9500) // iOS orange + modifier = Modifier.size(16.dp), + tint = palette.accentOrange ) } 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 + isMe && isMyTeleported -> "face.dashed" to palette.accentOrange + isTeleported -> "face.dashed" to palette.textSecondary + isMe -> "face.smiling" to palette.accentOrange + else -> "face.smiling" to palette.textSecondary } // Use appropriate Material icon (closest match to iOS SF Symbols) @@ -206,7 +214,7 @@ private fun GeohashPersonItem( Icon( imageVector = icon, contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User", - modifier = Modifier.size(12.dp), + modifier = Modifier.size(16.dp), tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) // Make dashed faces slightly transparent ) } @@ -219,9 +227,8 @@ private fun GeohashPersonItem( 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 + val assignedColor = viewModel.colorForNostrPubkey(person.id, palette.isDark) + val baseColor = if (isMe) palette.accentOrange else assignedColor Row( modifier = Modifier.weight(1f), @@ -230,42 +237,36 @@ private fun GeohashPersonItem( // 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 = FontFamily.Monospace, + 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 = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, + 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 = FontFamily.Monospace, + fontSize = 14.sp, color = baseColor ) } } - - Spacer(modifier = Modifier.width(8.dp)) + } } } diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 91555b38..3fac4761 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -2,6 +2,9 @@ package com.bitchat.android.ui // [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -15,6 +18,7 @@ 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.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource @@ -38,6 +42,8 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.withStyle import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.features.voice.normalizeAmplitudeSample import com.bitchat.android.features.voice.AudioWaveformExtractor import com.bitchat.android.ui.media.RealtimeScrollingWaveform @@ -53,7 +59,10 @@ import com.bitchat.android.ui.media.FilePickerButton * VisualTransformation that styles slash commands with background and color * while preserving cursor positioning and click handling */ -class SlashCommandVisualTransformation : VisualTransformation { +class SlashCommandVisualTransformation( + private val commandColor: Color, + private val commandBackground: Color, +) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { val slashCommandRegex = Regex("(/\\w+)(?=\\s|$)") val annotatedString = buildAnnotatedString { @@ -68,10 +77,10 @@ class SlashCommandVisualTransformation : VisualTransformation { // Add the styled slash command withStyle( style = SpanStyle( - color = Color(0xFF00FF7F), // Bright green + color = commandColor, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium, - background = Color(0xFF2D2D2D) // Dark gray background + background = commandBackground ) ) { append(match.value) @@ -97,7 +106,10 @@ class SlashCommandVisualTransformation : VisualTransformation { * VisualTransformation that styles mentions with background and color * while preserving cursor positioning and click handling */ -class MentionVisualTransformation : VisualTransformation { +class MentionVisualTransformation( + private val mentionColor: Color, + private val mentionBackground: Color, +) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { val mentionRegex = Regex("@([a-zA-Z0-9_]+)") val annotatedString = buildAnnotatedString { @@ -112,9 +124,12 @@ class MentionVisualTransformation : VisualTransformation { // Add the styled mention withStyle( style = SpanStyle( - color = Color(0xFFFF9500), // Orange + color = mentionColor, fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, + // Mirrors the mention chip used in rendered messages, so what you type + // looks like what everyone will see. + background = mentionBackground ) ) { append(match.value) @@ -159,6 +174,24 @@ class CombinedVisualTransformation(private val transformations: List - isFocused.value = focusState.isFocused - } - ) + // Recording turns the pill's outline red; a cross-fade keeps that from flashing. + val borderColor by animateColorAsState( + targetValue = when { + isRecording -> palette.accentRed.copy(alpha = 0.6f) + isFocused.value -> palette.outline + else -> palette.outlineVariant + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerBorder" + ) - // Show placeholder when there's no text and not recording - if (value.text.isEmpty() && !isRecording) { - Text( - text = stringResource(R.string.type_a_message_placeholder), - style = MaterialTheme.typography.bodyMedium.copy( + Row( + modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), + // Bottom-aligned so that when the field grows to several lines the send button and the + // media buttons stay pinned next to the last line, as in the design's long-text state. + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // MARK: - The pill. Holds the field and the send button as one visual object. + Row( + modifier = Modifier + .weight(1f) + .heightIn(min = ComposerMinHeight) + .background(palette.surfaceVariant, ComposerShape) + .border(1.dp, borderColor, ComposerShape), + verticalAlignment = Alignment.Bottom + ) { + Box( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp) + ) { + // Always keep the text field mounted to retain focus and avoid IME collapse + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = colorScheme.primary, fontFamily = FontFamily.Monospace ), - color = colorScheme.onSurface.copy(alpha = 0.5f), // Muted grey - modifier = Modifier.fillMaxWidth() + cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { + if (hasText) onSend() // Only send if there's text + }), + // Cap the growth so a pasted wall of text cannot swallow the message list. + maxLines = 6, + visualTransformation = remember(palette) { + CombinedVisualTransformation( + listOf( + SlashCommandVisualTransformation( + commandColor = palette.accentGreen, + commandBackground = palette.accentGreen.copy(alpha = 0.14f), + ), + MentionVisualTransformation( + mentionColor = palette.accentOrange, + mentionBackground = palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA), + ), + ) + ) + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + isFocused.value = focusState.isFocused + } ) - } - // Overlay the real-time scrolling waveform while recording - if (isRecording) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - RealtimeScrollingWaveform( - modifier = Modifier.weight(1f).height(32.dp), - amplitudeNorm = normalizeAmplitudeSample(amplitude) - ) - Spacer(Modifier.width(20.dp)) - val secs = (elapsedMs / 1000).toInt() - val mm = secs / 60 - val ss = secs % 60 - val maxSecs = 10 // 10 second max recording time - val maxMm = maxSecs / 60 - val maxSs = maxSecs % 60 + // Show placeholder when there's no text and not recording + if (value.text.isEmpty() && !isRecording) { Text( - text = String.format("%02d:%02d / %02d:%02d", mm, ss, maxMm, maxSs), - fontFamily = FontFamily.Monospace, - color = colorScheme.primary, - fontSize = (BASE_FONT_SIZE - 4).sp + text = stringResource(R.string.type_a_message_placeholder), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ), + color = palette.textTertiary, + modifier = Modifier.fillMaxWidth() ) } - } - } - - Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing - - // Voice and image buttons when no text (only visible in Mesh chat) - if (value.text.isEmpty() && showMediaButtons) { - // Hold-to-record microphone - val bg = if (colorScheme.background == Color.Black) Color(0xFF00FF00).copy(alpha = 0.75f) else Color(0xFF008000).copy(alpha = 0.75f) + // Overlay the real-time scrolling waveform while recording + if (isRecording) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + RealtimeScrollingWaveform( + modifier = Modifier.weight(1f).height(24.dp), + amplitudeNorm = normalizeAmplitudeSample(amplitude) + ) + Spacer(Modifier.width(12.dp)) + val secs = (elapsedMs / 1000).toInt() + val mm = secs / 60 + val ss = secs % 60 + val maxSecs = 10 // 10 second max recording time + val maxMm = maxSecs / 60 + val maxSs = maxSecs % 60 + Text( + text = String.format("%02d:%02d / %02d:%02d", mm, ss, maxMm, maxSs), + fontFamily = FontFamily.Monospace, + color = palette.accentRed, + fontSize = (BASE_FONT_SIZE - 4).sp + ) + } + } + } + + SendButton( + hasText = hasText, + isAccented = selectedPrivatePeer != null || currentChannel != null, + onSend = onSend, + modifier = Modifier.padding(end = 6.dp, bottom = 6.dp) + ) + } + + // MARK: - Media affordances, outside the pill, only while the field is empty. + if (value.text.isEmpty() && showMediaButtons) { // Ensure latest values are used when finishing recording val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer) val latestChannel = rememberUpdatedState(currentChannel) @@ -265,26 +338,15 @@ fun MessageInput( // Image button (image picker) - hide during recording if (!isRecording) { - // Revert to original separate buttons: round File button (left) and the old Image plus button (right) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - // DISABLE FILE PICKER - //FilePickerButton( - // onFileReady = { path -> - // onSendFileNote(latestSelectedPeer.value, latestChannel.value, path) - // } - //) - ImagePickerButton( - onImageReady = { outPath -> - onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath) - } - ) - } + ImagePickerButton( + onImageReady = { outPath -> + onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath) + } + ) } - Spacer(Modifier.width(1.dp)) - VoiceRecordButton( - backgroundColor = bg, + backgroundColor = colorScheme.primary, onStart = { isRecording = true elapsedMs = 0L @@ -313,72 +375,81 @@ fun MessageInput( ) } ) - - } else { - // Send button with enabled/disabled state - IconButton( - onClick = { if (hasText) onSend() }, // Only execute if there's text - enabled = hasText, // Enable only when there's text - modifier = Modifier.size(32.dp) - ) { - // Update send button to match input field colors - Box( - modifier = Modifier - .size(30.dp) - .background( - color = if (!hasText) { - // Disabled state - muted grey - colorScheme.onSurface.copy(alpha = 0.3f) - } else if (selectedPrivatePeer != null || currentChannel != null) { - // Orange for both private messages and channels when enabled - Color(0xFFFF9500).copy(alpha = 0.75f) - } else if (colorScheme.background == Color.Black) { - Color(0xFF00FF00).copy(alpha = 0.75f) // Bright green for dark theme - } else { - Color(0xFF008000).copy(alpha = 0.75f) // Dark green for light theme - }, - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Filled.ArrowUpward, - contentDescription = stringResource(id = R.string.send_message), - modifier = Modifier.size(20.dp), - tint = if (!hasText) { - // Disabled state - muted grey icon - colorScheme.onSurface.copy(alpha = 0.5f) - } else if (selectedPrivatePeer != null || currentChannel != null) { - // Black arrow on orange for both private and channel modes - Color.Black - } else if (colorScheme.background == Color.Black) { - Color.Black // Black arrow on bright green in dark theme - } else { - Color.White // White arrow on dark green in light theme - } - ) - } - } } } // Auto-stop handled inside VoiceRecordButton } +/** + * Circular send affordance nested in the bottom-right of the composer pill. + * + * Goes from a flat grey disc to a solid accent the moment there is something to send, which is + * the clearest possible signal that the return key will do something. + */ +@Composable +private fun SendButton( + hasText: Boolean, + isAccented: Boolean, + onSend: () -> Unit, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + + val targetBackground = when { + !hasText -> palette.outline + isAccented -> palette.accentOrange + else -> colorScheme.primary + } + val targetTint = when { + !hasText -> palette.textTertiary + // Both accents are bright enough that black is the only legible arrow colour. + else -> Color.Black + } + + val background by animateColorAsState( + targetValue = targetBackground, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + label = "sendButtonBackground" + ) + val tint by animateColorAsState( + targetValue = targetTint, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + label = "sendButtonTint" + ) + + Box( + modifier = modifier + .size(SendButtonSize) + .background(background, CircleShape) + .clip(CircleShape) + .clickable(enabled = hasText) { onSend() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.ArrowUpward, + contentDescription = stringResource(id = R.string.send_message), + modifier = Modifier.size(18.dp), + tint = tint + ) + } +} + @Composable fun CommandSuggestionsBox( suggestions: List, onSuggestionClick: (CommandSuggestion) -> Unit, modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current Column( modifier = modifier .verticalScroll(rememberScrollState()) - .background(colorScheme.surface) - .border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp)) - .padding(vertical = 8.dp) + .background(palette.surface) + .border(1.dp, palette.outlineVariant, RoundedCornerShape(8.dp)) + .padding(vertical = 6.dp) ) { suggestions.forEach { suggestion: CommandSuggestion -> CommandSuggestionItem( @@ -395,13 +466,14 @@ fun CommandSuggestionItem( onClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current Row( modifier = Modifier .fillMaxWidth() .clickable { onClick() } - .padding(horizontal = 12.dp, vertical = 3.dp) - .background(Color.Gray.copy(alpha = 0.1f)), + // Roomier rows: at 3.dp vertical these were below a comfortable tap height. + .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -419,7 +491,7 @@ fun CommandSuggestionItem( fontWeight = FontWeight.Medium ), color = colorScheme.primary, - fontSize = (BASE_FONT_SIZE - 4).sp + fontSize = (BASE_FONT_SIZE - 2).sp ) // Show syntax if any @@ -429,8 +501,8 @@ fun CommandSuggestionItem( style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace ), - color = colorScheme.onSurface.copy(alpha = 0.8f), - fontSize = (BASE_FONT_SIZE - 5).sp + color = palette.textSecondary, + fontSize = (BASE_FONT_SIZE - 4).sp ) } @@ -440,8 +512,8 @@ fun CommandSuggestionItem( style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace ), - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 5).sp, + color = palette.textTertiary, + fontSize = (BASE_FONT_SIZE - 4).sp, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -454,13 +526,13 @@ fun MentionSuggestionsBox( onSuggestionClick: (String) -> Unit, modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme - + val palette = LocalBitchatPalette.current + Column( modifier = modifier - .background(colorScheme.surface) - .border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp)) - .padding(vertical = 8.dp) + .background(palette.surface) + .border(1.dp, palette.outlineVariant, RoundedCornerShape(8.dp)) + .padding(vertical = 6.dp) ) { suggestions.forEach { suggestion: String -> MentionSuggestionItem( @@ -476,14 +548,13 @@ fun MentionSuggestionItem( suggestion: String, onClick: () -> Unit ) { - val colorScheme = MaterialTheme.colorScheme - + val palette = LocalBitchatPalette.current + Row( modifier = Modifier .fillMaxWidth() .clickable { onClick() } - .padding(horizontal = 12.dp, vertical = 3.dp) - .background(Color.Gray.copy(alpha = 0.1f)), + .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -492,19 +563,19 @@ fun MentionSuggestionItem( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.SemiBold ), - color = Color(0xFFFF9500), // Orange like mentions - fontSize = (BASE_FONT_SIZE - 4).sp + color = palette.accentOrange, + fontSize = (BASE_FONT_SIZE - 2).sp ) - + Spacer(modifier = Modifier.weight(1f)) - + Text( text = stringResource(R.string.mention), style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace ), - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 5).sp + color = palette.textTertiary, + fontSize = (BASE_FONT_SIZE - 4).sp ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 99ed10cc..26cdb767 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -3,11 +3,17 @@ package com.bitchat.android.ui import android.content.Intent import android.net.Uri import android.provider.Settings +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.foundation.background 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.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark @@ -15,6 +21,7 @@ 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.material.icons.outlined.Public import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -41,6 +48,11 @@ import com.bitchat.android.R import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.TorMode +import com.bitchat.android.net.TorPreferenceManager +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette /** * Location Channels Sheet for selecting geohash-based location channels @@ -109,11 +121,10 @@ fun LocationChannelsSheet( } } - // iOS system colors (matches iOS exactly) val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green - val standardBlue = Color(0xFF007AFF) // iOS blue + val palette = LocalBitchatPalette.current + val standardGreen = palette.accentGreen + val standardBlue = palette.accentBlue if (isPresented) { BitchatBottomSheet( @@ -129,14 +140,28 @@ fun LocationChannelsSheet( ) { // 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), + Column( modifier = Modifier - .padding(horizontal = 24.dp) - ) + .fillMaxWidth() + .padding(horizontal = SheetHorizontalPadding + 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + SheetHeaderBadge(icon = Icons.Outlined.Public) + Text( + text = stringResource(R.string.location_channels_heading), + fontSize = 20.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = colorScheme.primary + ) + Text( + text = stringResource(R.string.location_channels_desc), + fontSize = 12.sp, + lineHeight = 17.sp, + fontFamily = FontFamily.Monospace, + color = palette.textSecondary + ) + } } // Permission controls if services enabled @@ -156,7 +181,7 @@ fun LocationChannelsSheet( text = stringResource(R.string.location_permission_denied), fontSize = 11.sp, fontFamily = FontFamily.Monospace, - color = Color.Red.copy(alpha = 0.8f) + color = palette.accentRed ) TextButton( onClick = { @@ -208,6 +233,11 @@ fun LocationChannelsSheet( // iOS: let nearby = manager.availableChannels.filter { $0.level != .building } if (availableChannels.isNotEmpty() && locationServicesEnabled) { val nearbyChannels = availableChannels.filter { it.level != GeohashChannelLevel.BUILDING } + if (nearbyChannels.isNotEmpty()) { + item(key = "nearby_header") { + SheetSectionLabel(text = stringResource(R.string.section_nearby)) + } + } items(nearbyChannels) { channel -> val coverage = coverageString(channel.geohash.length) val nameBase = locationNames[channel.level] @@ -224,13 +254,14 @@ fun LocationChannelsSheet( 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), - ) - } + 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 = if (isBookmarked) palette.accentGreen else palette.textSecondary, + modifier = Modifier.size(18.dp) + ) + } }, onClick = { // Selecting a suggested nearby channel is not a teleport @@ -252,12 +283,13 @@ fun LocationChannelsSheet( CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + color = palette.textSecondary ) Text( text = stringResource(R.string.finding_nearby_channels), fontSize = 12.sp, - fontFamily = FontFamily.Monospace + fontFamily = FontFamily.Monospace, + color = palette.textSecondary ) } } @@ -266,16 +298,7 @@ fun LocationChannelsSheet( // 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) - ) + SheetSectionLabel(text = stringResource(R.string.section_bookmarked)) } items(bookmarks) { gh -> val level = levelForLength(gh.length) @@ -298,7 +321,8 @@ fun LocationChannelsSheet( Icon( imageVector = Icons.Filled.Bookmark, contentDescription = stringResource(R.string.cd_remove_bookmark), - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + tint = palette.accentGreen, + modifier = Modifier.size(18.dp) ) } }, @@ -321,16 +345,17 @@ fun LocationChannelsSheet( // Custom geohash teleport (iOS-style inline form) item(key = "custom_geohash") { Surface( - color = Color.Transparent, - shape = MaterialTheme.shapes.medium, + color = palette.surface, + shape = RoundedCornerShape(10.dp), modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), + .heightIn(min = 44.dp) + .padding(start = 14.dp, end = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -338,7 +363,7 @@ fun LocationChannelsSheet( text = stringResource(R.string.hash_symbol), fontSize = BASE_FONT_SIZE.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + color = palette.textTertiary ) BasicTextField( @@ -358,8 +383,9 @@ fun LocationChannelsSheet( textStyle = androidx.compose.ui.text.TextStyle( fontSize = BASE_FONT_SIZE.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface + color = colorScheme.primary ), + cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary), modifier = Modifier .weight(1f) .onFocusChanged { focusState -> @@ -381,7 +407,7 @@ fun LocationChannelsSheet( text = stringResource(R.string.geohash_placeholder), fontSize = BASE_FONT_SIZE.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + color = palette.textTertiary ) } innerTextField() @@ -405,14 +431,17 @@ fun LocationChannelsSheet( Icon( imageVector = Icons.Filled.Map, contentDescription = stringResource(R.string.cd_open_map), - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + tint = palette.textSecondary, + modifier = Modifier.size(18.dp) ) } val isValid = validateGeohash(normalized) - // iOS-style teleport button - Button( + // Compact teleport pill. Dims rather than disappearing when the + // geohash is incomplete so its position stays predictable. + val teleportColor = if (isValid) colorScheme.primary else palette.textTertiary + Surface( onClick = { if (isValid) { val level = levelForLength(normalized.length) @@ -426,25 +455,29 @@ fun LocationChannelsSheet( } }, enabled = isValid, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), - contentColor = MaterialTheme.colorScheme.onSurface - ) + shape = RoundedCornerShape(8.dp), + color = palette.surfaceVariant ) { Row( + modifier = Modifier + .height(32.dp) + .padding(horizontal = 10.dp), 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 + modifier = Modifier.size(12.dp), + tint = teleportColor + ) + Text( + text = stringResource(R.string.teleport).uppercase(), + fontSize = 11.sp, + letterSpacing = 0.8.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily.Monospace, + color = teleportColor ) } } @@ -459,51 +492,103 @@ fun LocationChannelsSheet( text = customError!!, fontSize = 12.sp, fontFamily = FontFamily.Monospace, - color = Color.Red, + color = palette.accentRed, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) + .padding(horizontal = SheetHorizontalPadding + 14.dp, vertical = 4.dp) ) } } + // Tor routing. Mirrors the toggle in About: this is where users actually + // think about IP exposure, because location channels are the feature that + // leaks it. + item(key = "tor_routing") { + val torProvider = remember { ArtiTorManager.getInstance() } + val torAvailable = remember { torProvider.isTorAvailable() } + var torMode by remember { mutableStateOf(TorPreferenceManager.get(context)) } + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) + .padding(top = 16.dp), + color = palette.surface, + shape = RoundedCornerShape(10.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = stringResource(R.string.location_tor_routing_title), + fontSize = 14.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = if (torAvailable) palette.textPrimary else palette.textTertiary + ) + BitchatBadge(text = stringResource(R.string.badge_recommended)) + } + Text( + text = stringResource(R.string.location_tor_routing_desc), + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = palette.textSecondary + ) + } + + Switch( + checked = torMode == TorMode.ON, + onCheckedChange = { enabled -> + if (torAvailable) { + torMode = if (enabled) TorMode.ON else TorMode.OFF + TorPreferenceManager.set(context, torMode) + } + }, + enabled = torAvailable, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = palette.accentGreen, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = palette.surfaceVariant + ) + ) + } + } + } + // 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 - ) - } - } + SheetDestructiveButton( + text = if (locationServicesEnabled) { + stringResource(R.string.remove_location_access) + } else { + stringResource(R.string.enable_location_services) + }, + isDestructive = locationServicesEnabled, + onClick = { + if (locationServicesEnabled) { + locationManager.disableLocationServices() + } else { + locationManager.enableLocationServices() + } + }, + modifier = Modifier.padding( + start = SheetHorizontalPadding, + end = SheetHorizontalPadding, + top = 16.dp + ) + ) } } @@ -561,26 +646,41 @@ private fun ChannelRow( trailingContent: (@Composable (() -> Unit))? = null, onClick: () -> Unit ) { - // iOS-style list row (plain button, no card background) + val palette = LocalBitchatPalette.current + + // Selected rows step up to surfaceVariant rather than showing a trailing checkmark: with a + // leading status dot the fill alone communicates state and keeps the row's right edge free + // for the bookmark control. + val containerColor by animateColorAsState( + targetValue = if (isSelected) palette.surfaceVariant else palette.surface, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "channelRowContainer" + ) + Surface( onClick = onClick, - color = if (isSelected) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - Color.Transparent - }, - shape = MaterialTheme.shapes.medium, + color = containerColor, + shape = RoundedCornerShape(10.dp), modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), + .padding(horizontal = 14.dp, vertical = 10.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { + if (isSelected) { + Box( + modifier = Modifier + .padding(end = 10.dp) + .size(6.dp) + .background(palette.accentGreen, CircleShape) + ) + } + Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) @@ -588,13 +688,16 @@ private fun ChannelRow( // Split title to handle count part with smaller font (iOS style) val (baseTitle, countSuffix) = splitTitleAndCount(title) - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { Text( text = baseTitle, - fontSize = BASE_FONT_SIZE.sp, + fontSize = 14.sp, fontFamily = FontFamily.Monospace, - fontWeight = if (titleBold) FontWeight.Bold else FontWeight.Normal, - color = titleColor ?: MaterialTheme.colorScheme.onSurface + fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium, + color = titleColor ?: palette.textPrimary ) countSuffix?.let { count -> @@ -602,32 +705,21 @@ private fun ChannelRow( text = count, fontSize = 11.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + color = palette.textSecondary ) } } Text( text = subtitle, - fontSize = 12.sp, + fontSize = 11.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + color = palette.textSecondary ) } - 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() - } + if (trailingContent != null) { + trailingContent() } } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 6ad9bdd8..af1198bc 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -19,6 +19,7 @@ import com.bitchat.android.R import com.bitchat.android.geohash.ChannelID import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.nostr.LocationNotesManager +import com.bitchat.android.ui.theme.LocalBitchatPalette /** * Location Notes button component for MainHeader @@ -54,13 +55,13 @@ fun LocationNotesButton( val hasNotes = notesCount > 0 IconButton( onClick = onClick, - modifier = modifier.size(24.dp) + modifier = modifier.size(40.dp) ) { 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 + modifier = Modifier.size(18.dp), + tint = if (hasNotes) colorScheme.primary else LocalBitchatPalette.current.textSecondary ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt index 4ca8674f..52632f93 100644 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt @@ -81,6 +81,7 @@ fun MessageWithMatrixAnimation( onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, modifier: Modifier = Modifier, + showSender: Boolean = true, ) { AnimatedMessageDisplay( message = message, @@ -91,6 +92,7 @@ fun MessageWithMatrixAnimation( onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, modifier = modifier, + showSender = showSender, ) } @@ -107,6 +109,7 @@ private fun AnimatedMessageDisplay( onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, modifier: Modifier = Modifier, + showSender: Boolean = true, ) { var animatedContent by remember(message.id, message.content) { mutableStateOf(message.content) @@ -174,6 +177,7 @@ private fun AnimatedMessageDisplay( onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, modifier = modifier, + showSender = showSender, bodyContent = animatedContent, ) } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index ec137437..71bfc62a 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* @@ -36,6 +37,7 @@ 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.LocalBitchatPalette import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.services.ContactDirectory @@ -68,6 +70,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() } @@ -100,19 +104,33 @@ fun MeshPeerListSheet( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp) ) { + // Header: badge + live participant count + item(key = "people_header") { + val peopleCount = when (selectedLocationChannel) { + is ChannelID.Location -> geohashPeopleCount + else -> connectedPeers.count { it != viewModel.myPeerID } + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SheetHorizontalPadding + 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + SheetHeaderBadge(icon = Icons.Filled.Groups) + Text( + text = stringResource(R.string.people_count_title, peopleCount), + fontSize = 20.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = colorScheme.primary + ) + } + } + // 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) - ) + SheetSectionLabel(text = stringResource(R.string.section_channels)) } items( @@ -222,22 +240,21 @@ private fun ChannelRow( onChannelClick: () -> Unit, onLeaveChannel: () -> Unit, ) { + val palette = LocalBitchatPalette.current + Surface( onClick = onChannelClick, - color = if (isSelected) { - colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - Color.Transparent - }, - shape = MaterialTheme.shapes.medium, + color = if (isSelected) palette.surfaceVariant else palette.surface, + shape = RoundedCornerShape(10.dp), modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .heightIn(min = 44.dp) + .padding(start = 14.dp, end = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -253,15 +270,13 @@ private fun ChannelRow( 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 + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + color = if (isSelected) colorScheme.primary else palette.textPrimary, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium ) } @@ -298,29 +313,20 @@ 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) - ) + SheetSectionLabel(text = stringResource(R.string.section_mesh)) 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), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = palette.textTertiary, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 40.dp, vertical = 12.dp) + .padding(horizontal = SheetHorizontalPadding + 14.dp, vertical = 12.dp) ) } @@ -547,26 +553,23 @@ 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 = viewModel.colorForMeshPeer(peerID, palette.isDark) + 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, + color = if (isSelected) palette.surfaceVariant else palette.surface, + shape = RoundedCornerShape(10.dp), modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .heightIn(min = 44.dp) + .padding(start = 14.dp, end = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -622,11 +625,9 @@ private fun PeerItem( // 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 = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, color = baseColor, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -636,11 +637,10 @@ private fun PeerItem( 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 = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, + color = baseColor.copy(alpha = SUFFIX_ALPHA) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 73fac04d..14f7b4f3 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -14,7 +14,7 @@ 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.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -41,6 +41,7 @@ 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.dp import androidx.compose.ui.unit.sp import com.bitchat.android.R @@ -50,6 +51,10 @@ 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.LocalBitchatPalette +import com.bitchat.android.ui.theme.MessageBodyTextStyle +import com.bitchat.android.ui.theme.MessageSenderTextStyle import java.text.SimpleDateFormat import java.util.Locale @@ -116,25 +121,42 @@ fun MessagesList( 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, end = 16.dp, top = 8.dp, bottom = 12.dp), + // Spacing is owned by each item so that a continuation of the same author can sit + // tighter than the start of a new speaker's run. + 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) + + MessageItem( + message = message, + messages = messages, + currentUserNickname = currentUserNickname, + meshService = meshService, + showSender = !isGrouped, + topSpacing = MessageGrouping.topSpacingFor( + isGrouped = isGrouped, + isFirstInList = originalIndex == 0 + ), + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + onCancelTransfer = onCancelTransfer, + onImageClick = onImageClick + ) } } } @@ -146,6 +168,8 @@ fun MessageItem( currentUserNickname: String, meshService: MeshService, messages: List = emptyList(), + showSender: Boolean = true, + topSpacing: Dp = 0.dp, onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, onCancelTransfer: ((BitchatMessage) -> Unit)? = null, @@ -155,7 +179,9 @@ fun MessageItem( val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) } Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(top = topSpacing), verticalArrangement = Arrangement.spacedBy(0.dp) ) { Box(modifier = Modifier.fillMaxWidth()) { @@ -174,6 +200,7 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -211,12 +238,15 @@ fun MessageItem( meshService: MeshService, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, + showSender: Boolean, onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, onImageClick: ((String, List, Int) -> Unit)?, modifier: Modifier = Modifier ) { + val palette = LocalBitchatPalette.current + // Image special rendering if (message.type == BitchatMessageType.Image) { com.bitchat.android.ui.media.ImageMessageItem( @@ -226,6 +256,7 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -243,6 +274,7 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -269,8 +301,9 @@ fun MessageItem( message = message, currentUserNickname = currentUserNickname, meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter + palette = palette, + timeFormatter = timeFormatter, + includeSender = showSender ) val haptic = LocalHapticFeedback.current AnnotatedClickableText( @@ -287,7 +320,7 @@ fun MessageItem( }, onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface, + color = palette.textPrimary, ) // Try to load the file packet from the path @@ -363,19 +396,20 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, 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 - ) + // Background narration: `// Tor started. Routing all chats…` + val annotatedText = remember(message, palette) { + formatSystemMessage( + message = message, + palette = palette, + timeFormatter = timeFormatter + ) + } val haptic = LocalHapticFeedback.current Text( @@ -391,8 +425,10 @@ fun MessageItem( fontFamily = FontFamily.Monospace, softWrap = true, overflow = TextOverflow.Visible, - style = androidx.compose.ui.text.TextStyle( - color = colorScheme.onSurface + style = MessageBodyTextStyle.copy( + fontSize = (BASE_FONT_SIZE - 2).sp, + lineHeight = (BASE_FONT_SIZE + 3).sp, + color = palette.textSecondary ) ) } else { @@ -402,6 +438,7 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, modifier = modifier, @@ -419,32 +456,30 @@ internal fun TextMessageLayout( 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, + 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, timeFormatter) { formatTextMessageBody( message = displayMessage, currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, + palette = palette, + timeFormatter = timeFormatter, ) } val isSelf = message.isFromSelf(currentUserNickname, myPeerId) @@ -457,12 +492,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 +508,11 @@ internal fun TextMessageLayout( } }, onLongPress = handleLongPress, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), fontFamily = FontFamily.Monospace, softWrap = false, overflow = TextOverflow.Ellipsis, - ) - AnnotatedClickableText( - text = metadataText, - annotationTags = emptyList(), - onAnnotationClick = { _, _ -> false }, - onLongPress = handleLongPress, - fontFamily = FontFamily.Monospace, - softWrap = false, + style = MessageSenderTextStyle, ) } @@ -515,7 +540,7 @@ internal fun TextMessageLayout( fontFamily = FontFamily.Monospace, softWrap = true, overflow = TextOverflow.Visible, - style = androidx.compose.ui.text.TextStyle(color = colorScheme.onSurface), + style = MessageBodyTextStyle.copy(color = palette.textPrimary), ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt new file mode 100644 index 00000000..f03e1b27 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt @@ -0,0 +1,79 @@ +package com.bitchat.android.ui + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.bitchat.android.model.BitchatMessage + +/** + * 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. Grouped messages sit + * closer together; a new group gets extra breathing room above it. + */ +object MessageGrouping { + + /** Space above the first message of a group (i.e. one that renders its sender label). */ + val NEW_GROUP_SPACING: Dp = 10.dp + + /** Space above a continuation message inside an existing group. */ + val GROUPED_SPACING: Dp = 4.dp + + /** Gap between the sender label and the first line of the body. */ + val SENDER_TO_BODY_SPACING: Dp = 2.dp + + /** + * Messages further apart than this always start a new group, even from the same sender: + * a reply an hour later is a new thought, not a continuation. + */ + const val GROUPING_WINDOW_MS: Long = 5 * 60 * 1000L + + private const val SYSTEM_SENDER = "system" + + /** + * Whether [current] should be rendered as a continuation of [previous], hiding its sender + * label. + * + * [previous] is the message immediately *before* [current] in chronological order. Note the + * message list renders with `reverseLayout = true`, so callers must be careful to resolve + * the chronological predecessor rather than the visually preceding item. + */ + fun shouldGroup(previous: BitchatMessage?, current: BitchatMessage): Boolean { + if (previous == null) return false + + // System/action lines never participate in grouping in either direction: they are + // narration, and folding a real message into them would attribute it to "system". + if (previous.sender == SYSTEM_SENDER || current.sender == SYSTEM_SENDER) return false + + // Never group across the public/private boundary or between different channels. + if (previous.isPrivate != current.isPrivate) return false + if (previous.channel != current.channel) return false + + if (!isSameSender(previous, current)) return false + + val elapsed = current.timestamp.time - previous.timestamp.time + return elapsed in 0..GROUPING_WINDOW_MS + } + + /** + * Identity comparison. Peer IDs are authoritative when both messages carry one, because two + * different peers can share a nickname. Falls back to the display name (which includes the + * `#abcd` suffix) when peer IDs are unavailable, e.g. for locally injected messages. + */ + private fun isSameSender(previous: BitchatMessage, current: BitchatMessage): Boolean { + val previousPeerID = previous.senderPeerID + val currentPeerID = current.senderPeerID + return if (previousPeerID != null && currentPeerID != null) { + previousPeerID.equals(currentPeerID, ignoreCase = true) + } else { + previous.sender == current.sender + } + } + + /** Top padding for a message given whether it continues the previous author's run. */ + fun topSpacingFor(isGrouped: Boolean, isFirstInList: Boolean): Dp = when { + isFirstInList -> 0.dp + isGrouped -> GROUPED_SPACING + else -> NEW_GROUP_SPACING + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index c61f2f9a..1c005b46 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -56,7 +56,7 @@ fun VoiceRecordButton( Box( modifier = modifier - .size(32.dp) + .size(36.dp) .background(backgroundColor, CircleShape) .pointerInput(Unit) { detectTapGestures( diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 7c0a1ac6..5db41f17 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -21,6 +21,7 @@ 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) { @@ -51,8 +54,9 @@ fun AudioMessageItem( message = message, currentUserNickname = currentUserNickname, meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter + palette = palette, + timeFormatter = timeFormatter, + includeSender = showSender ) val haptic = LocalHapticFeedback.current AnnotatedClickableText( @@ -69,7 +73,7 @@ fun AudioMessageItem( }, onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface, + color = palette.textPrimary, ) Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 35b0bbea..1691bb9b 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -27,6 +27,7 @@ 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,19 @@ fun ImageMessageItem( onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, onImageClick: ((String, List, Int) -> Unit)?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showSender: Boolean = true ) { + val palette = LocalBitchatPalette.current val path = message.content.trim() Column(modifier = modifier.fillMaxWidth()) { val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter + palette = palette, + timeFormatter = timeFormatter, + includeSender = showSender ) val haptic = LocalHapticFeedback.current AnnotatedClickableText( @@ -67,7 +71,7 @@ fun ImageMessageItem( }, onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface, + color = palette.textPrimary, ) val context = LocalContext.current diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt index eb1f5d86..172ad50d 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable 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.Camera import androidx.compose.material.icons.filled.Photo @@ -17,6 +18,7 @@ import androidx.compose.material3.Icon 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.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -24,6 +26,7 @@ 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.theme.LocalBitchatPalette import java.io.File @OptIn(ExperimentalFoundationApi::class) @@ -88,7 +91,9 @@ fun ImagePickerButton( Box( modifier = modifier - .size(32.dp) + // 40.dp to match the header's tap targets and the composer's send button. + .size(40.dp) + .clip(CircleShape) .combinedClickable( onClick = { imagePicker.launch("image/*") }, onLongClick = { @@ -104,7 +109,7 @@ fun ImagePickerButton( Icon( imageVector = Icons.Filled.PhotoCamera, contentDescription = stringResource(com.bitchat.android.R.string.pick_image), - tint = Color.Gray, + tint = LocalBitchatPalette.current.textSecondary, modifier = Modifier.size(20.dp) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt new file mode 100644 index 00000000..bd4280eb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -0,0 +1,106 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +/** + * Semantic design tokens for the bitchat redesign. + * + * Material3's [androidx.compose.material3.ColorScheme] cannot express what this app needs: + * `onSurface` is the terminal green used for chrome, while message bodies must render in a + * neutral near-white. Rather than repurposing M3 slots (which would repaint ~40 unrelated + * files unpredictably), the redesign exposes an explicit palette through + * [LocalBitchatPalette]. + * + * Consumers should prefer these tokens over ad-hoc hex literals so accents stay consistent + * and light/dark pairs are defined in exactly one place. + */ +@Immutable +data class BitchatPalette( + /** Authoritative dark-mode flag. Prefer this over summing background channels. */ + val isDark: Boolean, + + // MARK: - Surfaces + /** Screen, header and composer background. Slightly green-tinted near-black. */ + val background: Color, + /** Sheets, cards and list rows: one step above [background]. */ + val surface: Color, + /** Composer pill fill, chips, selected rows: one step above [surface]. */ + val surfaceVariant: Color, + /** Borders, drawn at full opacity (no `.copy(alpha = ...)` needed). */ + val outline: Color, + /** Hairline dividers. */ + val outlineVariant: Color, + + // MARK: - Text + /** Message bodies and row titles. Neutral, not green. */ + val textPrimary: Color, + /** Subtitles, secondary metadata, system/action messages. */ + val textSecondary: Color, + /** Timestamps, placeholders, section labels, disabled states. */ + val textTertiary: Color, + + // MARK: - Accents + /** Geohash channels, bookmark-on, connected/OK states. */ + val accentGreen: Color, + /** Mesh channel, links, geohash references. */ + val accentBlue: Color, + /** Self, mentions targeting you, unread DMs. */ + val accentOrange: Color, + /** Errors, warning card, destructive actions. */ + val accentRed: Color, + /** Nostr reachability. */ + val accentPurple: Color, +) + +val DarkBitchatPalette = BitchatPalette( + isDark = true, + background = Color(0xFF070D07), + surface = Color(0xFF0E150E), + surfaceVariant = Color(0xFF182118), + outline = Color(0xFF2A3A2A), + outlineVariant = Color(0xFF1C271C), + textPrimary = Color(0xFFE8EDE8), + textSecondary = Color(0xFF9AA69A), + textTertiary = Color(0xFF6B776B), + accentGreen = Color(0xFF32D74B), + accentBlue = Color(0xFF0A84FF), + accentOrange = Color(0xFFFF9F0A), + accentRed = Color(0xFFFF453A), + accentPurple = Color(0xFFBF5AF2), +) + +val LightBitchatPalette = BitchatPalette( + isDark = false, + background = Color(0xFFFBFDFB), + surface = Color(0xFFF2F6F2), + surfaceVariant = Color(0xFFE7EDE7), + outline = Color(0xFFCBD6CB), + outlineVariant = Color(0xFFDEE6DE), + textPrimary = Color(0xFF131A13), + textSecondary = Color(0xFF4C574C), + textTertiary = Color(0xFF757F75), + accentGreen = Color(0xFF248A3D), + accentBlue = Color(0xFF007AFF), + accentOrange = Color(0xFFFF9500), + accentRed = Color(0xFFD70015), + accentPurple = Color(0xFFAF52DE), +) + +val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette } + +/** + * Motion tokens. The redesign leans on short, snappy transitions: long durations read as + * sluggish on a chat surface where the user is scanning quickly. + */ +object BitchatMotion { + /** Icon tints, text colors, small fills. */ + const val QUICK_MS = 120 + + /** Tab indicators, pill growth, chip reveals. */ + const val STANDARD_MS = 180 + + /** Sheet-level fades and scroll-driven top bars. */ + const val EMPHASIZED_MS = 240 +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt index 76ef3130..09d03959 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -16,17 +17,23 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalView -// Colors that match the iOS bitchat theme +// Colors that match the iOS bitchat theme. +// Surface/outline/error slots are derived from BitchatPalette so that screens which have not +// yet migrated to the explicit palette still pick up the redesigned tinted surfaces. private val DarkColorScheme = darkColorScheme( primary = Color(0xFF39FF14), // Bright green (terminal-like) onPrimary = Color.Black, secondary = Color(0xFF2ECB10), // Darker green onSecondary = Color.Black, - background = Color.Black, - onBackground = Color(0xFF39FF14), // Green on black - surface = Color(0xFF111111), // Very dark gray + background = DarkBitchatPalette.background, + onBackground = Color(0xFF39FF14), // Green on near-black + surface = DarkBitchatPalette.surface, onSurface = Color(0xFF39FF14), // Green text - error = Color(0xFFFF5555), // Red for errors + surfaceVariant = DarkBitchatPalette.surfaceVariant, + onSurfaceVariant = DarkBitchatPalette.textSecondary, + outline = DarkBitchatPalette.outline, + outlineVariant = DarkBitchatPalette.outlineVariant, + error = DarkBitchatPalette.accentRed, onError = Color.Black ) @@ -35,11 +42,15 @@ private val LightColorScheme = lightColorScheme( onPrimary = Color.White, secondary = Color(0xFF006600), // Even darker green onSecondary = Color.White, - background = Color.White, - onBackground = Color(0xFF008000), // Dark green on white - surface = Color(0xFFF8F8F8), // Very light gray + background = LightBitchatPalette.background, + onBackground = Color(0xFF008000), // Dark green on off-white + surface = LightBitchatPalette.surface, onSurface = Color(0xFF008000), // Dark green text - error = Color(0xFFCC0000), // Dark red for errors + surfaceVariant = LightBitchatPalette.surfaceVariant, + onSurfaceVariant = LightBitchatPalette.textSecondary, + outline = LightBitchatPalette.outline, + outlineVariant = LightBitchatPalette.outlineVariant, + error = LightBitchatPalette.accentRed, onError = Color.White ) @@ -61,6 +72,7 @@ fun BitchatTheme( } val colorScheme = if (shouldUseDark) DarkColorScheme else LightColorScheme + val palette = if (shouldUseDark) DarkBitchatPalette else LightBitchatPalette val view = LocalView.current SideEffect { @@ -83,9 +95,11 @@ fun BitchatTheme( } } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) + CompositionLocalProvider(LocalBitchatPalette provides palette) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) + } } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt index a8c68a4b..66236747 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt @@ -9,6 +9,26 @@ import androidx.compose.ui.unit.sp // Base font size for consistent scaling across the app internal const val BASE_FONT_SIZE = com.bitchat.android.util.AppConstants.UI.BASE_FONT_SIZE_SP // sp - increased from 14sp for better readability +/** + * Message body style. The generous leading (1.4x) is what gives the redesigned chat surface + * its readable rhythm; without an explicit lineHeight, Compose falls back to the font's own + * metrics and lines sit too tightly for long paragraphs. + */ +val MessageBodyTextStyle = TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Normal, + fontSize = BASE_FONT_SIZE.sp, + lineHeight = (BASE_FONT_SIZE + 6).sp +) + +/** Sender label above a message group. Single line, never wraps. */ +val MessageSenderTextStyle = TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + fontSize = BASE_FONT_SIZE.sp, + lineHeight = (BASE_FONT_SIZE + 5).sp +) + // Typography matching the iOS monospace design - using BASE_FONT_SIZE for consistency val Typography = Typography( bodyLarge = TextStyle( @@ -21,7 +41,7 @@ val Typography = Typography( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Normal, fontSize = BASE_FONT_SIZE.sp, - lineHeight = (BASE_FONT_SIZE + 3).sp + lineHeight = (BASE_FONT_SIZE + 6).sp ), bodySmall = TextStyle( fontFamily = FontFamily.Monospace, @@ -35,12 +55,31 @@ val Typography = Typography( fontSize = (BASE_FONT_SIZE + 3).sp, lineHeight = (BASE_FONT_SIZE + 9).sp ), + // Previously unset, which leaked the Roboto default into onboarding + sheet titles. + headlineLarge = TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = (BASE_FONT_SIZE + 13).sp, + lineHeight = (BASE_FONT_SIZE + 21).sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + fontSize = (BASE_FONT_SIZE + 5).sp, + lineHeight = (BASE_FONT_SIZE + 13).sp + ), titleMedium = TextStyle( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE + 1).sp, lineHeight = (BASE_FONT_SIZE + 7).sp ), + labelLarge = TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + fontSize = (BASE_FONT_SIZE - 1).sp, + lineHeight = (BASE_FONT_SIZE + 5).sp + ), labelMedium = TextStyle( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 4d2ffec0..bd8949cb 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -96,7 +96,6 @@ اختيار صورة - دردشة شبكية لامركزية مع تشفير طرف إلى طرف دردشة شبكية دون اتصال تواصل مباشرة عبر Bluetooth LE بدون إنترنت أو خوادم. تُعاد توجيه الرسائل عبر الأجهزة القريبة لتوسيع النطاق. قنوات geohash عبر الإنترنت diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index c5902ee2..f978e0ce 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -96,7 +96,6 @@ ছবি নির্বাচন করুন - এন্ড-টু-এন্ড এনক্রিপশন সহ বিকেন্দ্রীভূত মেশ চ্যাট অফলাইন মেশ চ্যাট ইন্টারনেট বা সার্ভার ছাড়াই Bluetooth LE এর মাধ্যমে সরাসরি যোগাযোগ করুন। পরিসর বাড়ানোর জন্য কাছাকাছি ডিভাইসের মাধ্যমে বার্তা রিলে করা হয়। অনলাইন জিওহ্যাশ চ্যানেল diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9002f9bd..9fe6301e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -96,7 +96,6 @@ Bild auswählen - dezentraler Mesh‑Chat mit Ende‑zu‑Ende‑Verschlüsselung Offline‑Mesh‑Chat Kommuniziere direkt über Bluetooth LE ohne Internet oder Server. Nachrichten werden über nahe Geräte weitergeleitet, um die Reichweite zu erhöhen. Online‑Geohash‑Kanäle diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index cf6b336e..89d907da 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -96,7 +96,6 @@ Elegir imagen - chat mesh descentralizado con cifrado de extremo a extremo Chat mesh sin conexión Comunícate directamente a través de Bluetooth LE sin internet o servidores. Los mensajes se retransmiten a través de dispositivos cercanos para extender el alcance. Canales geohash en línea diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 84273529..5f895797 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -96,7 +96,6 @@ انتخاب تصویر - گفت‌وگوی مِش غیرمتمرکز با رمزگذاری سرتاسری گفت‌وگوی مِش آفلاین بدون اینترنت یا سرور، مستقیماً از طریق Bluetooth LE ارتباط برقرار کنید. پیام‌ها از طریق دستگاه‌های نزدیک رله می‌شوند تا بُرد افزایش یابد. کانال‌های geohash آنلاین diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 65e4cbd2..dc231763 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -96,7 +96,6 @@ Pumili ng larawan - desentralisadong mesh messaging na may end‑to‑end encryption Offline Mesh Chat Makipag‑usap direkta sa Bluetooth LE nang walang internet o server. Ang mga mensahe ay nire‑relay ng mga kalapit na device. Online na Geohash na mga Channel diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index aee12bf1..9d2b515f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -96,7 +96,6 @@ Choisir une image - messagerie mesh décentralisée avec chiffrement de bout en bout Chat Mesh hors‑ligne Communique directement via Bluetooth LE sans internet ni serveurs. Les messages sont relayés par les appareils proches pour étendre la portée. Canaux Geohash en ligne diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 926cc3a4..3ec6ac27 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -96,7 +96,6 @@ छवि चुनें - विकेन्द्रीकृत मेष चैट • एंड‑टू‑एंड एन्क्रिप्शन ऑफ़लाइन मेष चैट इंटरनेट या सर्वर के बिना Bluetooth LE के माध्यम से सीधे संवाद करें। संदेशों को पास के उपकरणों के माध्यम से रिले किया जाता है ताकि पहुँच बढ़े। ऑनलाइन geohash चैनल diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index bb15e3bb..048d40b2 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -96,7 +96,6 @@ Pilih gambar - chat mesh terdesentralisasi dengan enkripsi end-to-end Chat Mesh Offline Berkomunikasi langsung melalui Bluetooth LE tanpa Internet atau server. Pesan diteruskan melalui perangkat terdekat untuk memperluas jangkauan. Channel Geohash Online diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c10a8575..434f1dc6 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -96,7 +96,6 @@ Scegli immagine - messaggistica mesh decentralizzata con crittografia end-to-end Chat mesh offline Comunica direttamente tramite Bluetooth LE senza internet o server. I messaggi vengono inoltrati attraverso i dispositivi vicini per estendere la portata. Canali geohash online diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index eb9d6ba1..1a3df392 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -96,7 +96,6 @@ 画像を選択 - 分散型メッシュチャット・エンドツーエンド暗号化 オフライン・メッシュチャット インターネットやサーバーなしで、Bluetooth LE 経由で直接通信します。近隣のデバイスを経由してメッセージを中継し、到達範囲を広げます。 オンライン Geohash チャンネル diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 1e3187d0..cf08947b 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -96,7 +96,6 @@ სურათის არჩევა - დეცენტრალიზებული mesh-ჩათი ბოლო-მდე დაშიფვრით ოფლაინ mesh-ჩათი ესაუბრეთ პირდაპირ Bluetooth LE-ის საშუალებით ინტერნეტის ან სერვერების გარეშე. შეტყობინებები გადაიგზავნება ახლომდებარე მოწყობილობებზე, რათა გაიზარდოს დაფარვა. ონლაინ geohash არხები diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 829cd1f5..5be26395 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -96,7 +96,6 @@ 이미지 선택 - 종단간 암호화를 갖춘 탈중앙 메쉬 채팅 오프라인 메쉬 채팅 인터넷이나 서버 없이 Bluetooth LE로 직접 소통합니다. 범위를 확장하기 위해 근처 기기를 통해 메시지가 중계됩니다. 온라인 geohash 채널 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index a7edd747..68b640f9 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -96,7 +96,6 @@ Hifidy sary - hafatra mesh tsy misy foibe misy fandokoana farany amin\'ny farany Mesh Chat tsy an-tserasera Mifandraisa mivantana amin\'ny alalan\'ny Bluetooth LE tsy misy Internet na mpizara. Ny hafatra dia mamindra amin\'ny alalan\'ny fitaovana akaiky mba hanitarana ny elanelana. Fantsona Geohash an-tserasera diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index f1dbb3c6..d460c037 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -96,7 +96,6 @@ तस्वीर छान्नुहोस् - विकेन्द्रित मेष सन्देश अन्त्यदेखि अन्त्य इन्क्रिप्सनसहित अफलाइन मेष च्याट इन्टरनेट वा सर्भर बिना सिधै Bluetooth LE मार्फत सञ्चार गर्नुहोस्। नजिकका यन्त्रहरूबाट सन्देश रिल레이 हुन्छ। अनलाइन Geohash च्यानलहरू diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 6a014f87..4cf60a3d 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -96,7 +96,6 @@ Afbeelding kiezen - gedecentraliseerde meshcommunicatie met end-to-end-versleuteling Offline meshchat Communiceer rechtstreeks via Bluetooth LE zonder internet of servers. Berichten worden doorgestuurd via apparaten in de buurt om het bereik te vergroten. Online geohash-kanalen diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 3b94bd8c..553e35ca 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -96,7 +96,6 @@ تصویر چُنو - اینڈ ٹو اینڈ انکرپشن نال غیر مرکزی میش چیٹ آف لائن میش چیٹ انٹرنیٹ یا سرور توں بغیر Bluetooth LE راہین سیدھا رابطہ کرو۔ رینج ودھاؤن لئی پیغام نیڑے ڈیوائساں راہین ریلے ہوندے نیں۔ آن لائن geohash چینل diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 45f41512..86e238cb 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -96,7 +96,6 @@ Escolher imagem - chat mesh descentralizado com criptografia de ponta a ponta Chat mesh offline Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas por dispositivos próximos para ampliar o alcance. Canais geohash online diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 274bbaae..2aeb5352 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -96,7 +96,6 @@ Escolher imagem - chat mesh descentralizado com encriptação ponto a ponto Chat mesh offline Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas através de dispositivos próximos para estender o alcance. Canais geohash online diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d1d27acd..76b332ff 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -86,7 +86,6 @@ ❌ Закрыть Выбрать изображение - децентрализованный mesh‑чат со сквозным шифрованием Оффлайн Mesh‑чат Общение напрямую по Bluetooth LE без интернета и серверов. Сообщения ретранслируются близлежащими устройствами. Онлайн‑каналы Geohash diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index aa3194ed..e465e4da 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -86,7 +86,6 @@ ❌ Stäng Välj bild - decentraliserad mesh‑meddelanden med ända‑till‑ända‑kryptering Offline Mesh‑chatt Kommunicera direkt via Bluetooth LE utan internet eller servrar. Meddelanden reläas av enheter i närheten. Online Geohash‑kanaler diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 5b2ba927..33d45309 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -96,7 +96,6 @@ เลือกภาพ - แชทเมชแบบกระจายศูนย์พร้อมการเข้ารหัสแบบปลายทางถึงปลายทาง แชทเมชออฟไลน์ สื่อสารโดยตรงผ่าน Bluetooth LE โดยไม่ใช้อินเทอร์เน็ตหรือเซิร์ฟเวอร์ ข้อความจะถูกส่งต่อผ่านอุปกรณ์ใกล้เคียงเพื่อขยายระยะ ช่อง geohash ออนไลน์ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index d27d1b59..0eccd05f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -86,7 +86,6 @@ ❌ Kapat Görsel seç - uçtan uca şifrelemeli merkezsiz mesh mesajlaşma Çevrimdışı Mesh Sohbeti İnternet/ sunucu olmadan doğrudan Bluetooth LE ile iletişim kur. Mesajlar yakındaki cihazlarca aktarılır. Çevrimiçi Geohash Kanalları diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 1d8399de..df6d1f4f 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -96,7 +96,6 @@ تصویر منتخب کریں - غیر مرکزی میش چیٹ • اینڈ‑ٹو‑اینڈ انکرپشن آف لائن میش چیٹ انٹرنیٹ یا سرورز کے بغیر Bluetooth LE کے ذریعے براہ راست بات چیت کریں۔ پیغامات قریبی آلات کے ذریعے ریلے ہوتے ہیں تاکہ رسائی بڑھے۔ آن لائن geohash چینلز diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 6ab72813..b5cd2a6e 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -96,7 +96,6 @@ Chọn hình ảnh - trò chuyện mesh phi tập trung với mã hóa đầu cuối Trò chuyện Mesh ngoại tuyến Liên lạc trực tiếp qua Bluetooth LE mà không cần Internet hoặc máy chủ. Tin nhắn được chuyển tiếp qua các thiết bị gần để mở rộng phạm vi. Kênh Geohash trực tuyến diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 8a28e5f7..05143855 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -96,7 +96,6 @@ 选择图片 - 去中心化 mesh 消息,端到端加密 离线 Mesh 聊天 无需网络或服务器,通过 Bluetooth LE 直接通信。利用附近设备中继消息以扩展范围。 在线 Geohash 频道 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e674112c..e20a4b49 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -109,7 +109,50 @@ Pick file - decentralized mesh messaging with end-to-end encryption + The sidegroup chat + + + How to use + Basic info + + + How To Use + Set your nickname by tapping it + Tap the channel name to change channels + Tap the people icon to see who\'s in the channel + Bookmark a channel to keep it in your list + Type @ to mention someone + Type / to see the available commands + Triple-tap the logo to wipe all data + + + About + Theme + Settings + + + Offline Communication + Chat over a Bluetooth mesh with no internet and no servers. Messages relay through nearby devices to extend range. + Local Channels + Geohash channels connect you with people nearby, extending the mesh over public relays. + No Tracking + No servers, accounts, or data collection. + Ephemeral Identity + A new peer ID is generated regularly. + Panic Mode + Triple-tap the logo to instantly clear all data. + + + Warning + Private message security has not yet been fully audited. Do not use for critical situations until this warning disappears. + + + Tor Network + Difficulty + %1$d bits • %2$s + Connected (%1$d%%) + Disconnected + Offline Mesh Chat Communicate directly via Bluetooth LE without internet or servers. Messages relay through nearby devices to extend range. Online Geohash Channels @@ -213,7 +256,24 @@ #location channels + Location Channels chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. do not screenshot or share this screen to protect your privacy. + + + Bookmarked + Nearby + Tor Routing + Hides your IP for location channels. + Recommended + Remove location access + + + People (%1$d) + On location + Teleported in + Channels + Mesh + grant location permission location permission denied. enable in settings to use location channels. @@ -378,7 +438,7 @@ (you) pan and zoom to select a geohash select - type a message... + Type a message… @%1$s mention %1$d / %2$d diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index 304166c0..44072412 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -1,10 +1,16 @@ package com.bitchat.android.ui +import androidx.compose.ui.text.SpanStyle import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.theme.DarkBitchatPalette +import com.bitchat.android.ui.theme.LightBitchatPalette import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class ChatUIUtilsTest { @@ -12,33 +18,298 @@ class ChatUIUtilsTest { timeZone = java.util.TimeZone.getTimeZone("UTC") } + private val palette = DarkBitchatPalette + + private fun message( + content: String, + sender: String = "alice", + powDifficulty: Int? = null, + ) = BitchatMessage( + sender = sender, + content = content, + timestamp = Date(0), + powDifficulty = powDifficulty, + ) + + // MARK: - Timestamp metadata + @Test fun `text message metadata separates PoW badge with one space`() { - val message = BitchatMessage( - sender = "alice", - content = "hello", - timestamp = Date(0), - powDifficulty = 12, - ) - assertEquals( "00:00:00 ⛨12b", - formatTextMessageMetadata(message, timeFormatter).text, + formatTextMessageMetadata(message("hello", powDifficulty = 12), timeFormatter).text, ) } @Test fun `text message metadata omits non-positive PoW difficulty`() { - val message = BitchatMessage( - sender = "alice", - content = "hello", - timestamp = Date(0), - powDifficulty = 0, - ) - assertEquals( "00:00:00", - formatTextMessageMetadata(message, timeFormatter).text, + formatTextMessageMetadata(message("hello", powDifficulty = 0), timeFormatter).text, ) } + + // MARK: - Body with inline trailing timestamp + + @Test + fun `body appends timestamp inline after the message text`() { + val body = formatTextMessageBody( + message = message("hello there"), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + ) + + assertEquals("hello there 00:00:00", body.text) + } + + @Test + fun `body appends PoW badge after the inline timestamp`() { + val body = formatTextMessageBody( + message = message("mined", powDifficulty = 8), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + ) + + assertEquals("mined 00:00:00 ⛨8b", body.text) + } + + @Test + fun `body can omit the timestamp for callers that render it separately`() { + val body = formatTextMessageBody( + message = message("hello there"), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + assertEquals("hello there", body.text) + } + + @Test + fun `body renders plain text in the neutral palette color, not terminal green`() { + val body = formatTextMessageBody( + message = message("plain words"), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val textStyle = body.spanStyles.first { it.start == 0 } + assertEquals(palette.textPrimary, textStyle.item.color) + } + + @Test + fun `body does not bold plain text for the sender's own messages`() { + // Regression guard: the old renderer bolded the entire body when the message was yours, + // and again when you were mentioned, which is what made busy channels unreadable. + val body = formatTextMessageBody( + message = message("my own words", sender = "bob"), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + assertTrue( + "no span should force a bold weight", + body.spanStyles.none { it.item.fontWeight?.weight?.let { w -> w >= 700 } == true } + ) + } + + // MARK: - Mention chips + + private fun mentionChipSpans(body: androidx.compose.ui.text.AnnotatedString) = + body.spanStyles.filter { it.item.background.isSpecified() } + + private fun androidx.compose.ui.graphics.Color.isSpecified() = + this != androidx.compose.ui.graphics.Color.Unspecified && alpha > 0f + + @Test + fun `mention renders as a single contiguous background chip`() { + val body = formatTextMessageBody( + message = message("hey @carol#04af what's up"), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val chips = mentionChipSpans(body) + assertEquals("expected exactly one chip", 1, chips.size) + + // The chip must cover "@carol#04af" as one run so it paints without seams. + val chip = chips.single() + assertEquals("@carol#04af", body.text.substring(chip.start, chip.end)) + } + + @Test + fun `mention targeting the current user uses the orange accent`() { + val body = formatTextMessageBody( + message = message("ping @bob now"), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val chip = mentionChipSpans(body).single() + assertEquals(palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA_SELF), chip.item.background) + + val nameStyle: SpanStyle = body.spanStyles + .first { it.start == chip.start && it.item.color == palette.accentOrange } + .item + assertEquals(palette.accentOrange, nameStyle.color) + } + + @Test + fun `mention of another user is tinted by that user's own peer color`() { + val body = formatTextMessageBody( + message = message("cc @carol#04af"), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val expected = colorForPeerSeed("carol#04af", palette.isDark) + val chip = mentionChipSpans(body).single() + assertEquals(expected.copy(alpha = MENTION_CHIP_ALPHA), chip.item.background) + } + + @Test + fun `message without mentions has no background chips`() { + val body = formatTextMessageBody( + message = message("no mentions in here"), + currentUserNickname = "bob", + palette = palette, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + assertTrue(mentionChipSpans(body).isEmpty()) + } + + // MARK: - System / action messages + + @Test + fun `system message uses a double-slash prefix and no brackets`() { + val text = formatSystemMessage( + message = message("Tor started. Routing all chats via Tor", sender = "system"), + palette = palette, + timeFormatter = timeFormatter, + ).text + + assertEquals("// Tor started. Routing all chats via Tor 00:00:00", text) + } + + @Test + fun `system message is not italic`() { + // The old treatment was `* italic asterisks *`, which competed visually with real + // messages despite being lower-priority narration. + val annotated = formatSystemMessage( + message = message("tor restarting", sender = "system"), + palette = palette, + timeFormatter = timeFormatter, + ) + + assertTrue(annotated.spanStyles.all { it.item.fontStyle == null }) + } + + // MARK: - Sender label + + @Test + fun `sender label drops angle brackets and dims the hash suffix`() { + val sender = formatTextMessageSender( + message = message("hi", sender = "carol#04af"), + currentUserNickname = "bob", + meshService = FakeMeshServiceIds.stub(), + palette = palette, + ) + + assertEquals("@carol#04af", sender.text) + + val suffixSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "#04af" } + val nameSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "@carol" } + assertNotNull(suffixSpan.item.color) + assertTrue( + "suffix must be dimmer than the name", + suffixSpan.item.color.alpha < nameSpan.item.color.alpha + ) + } + + @Test + fun `sender label annotates the nickname for others but not for yourself`() { + val other = formatTextMessageSender( + message = message("hi", sender = "carol#04af"), + currentUserNickname = "bob", + meshService = FakeMeshServiceIds.stub(), + palette = palette, + ) + assertEquals(1, other.getStringAnnotations("nickname_click", 0, other.length).size) + + val mine = formatTextMessageSender( + message = message("hi", sender = "bob"), + currentUserNickname = "bob", + meshService = FakeMeshServiceIds.stub(), + palette = palette, + ) + assertTrue(mine.getStringAnnotations("nickname_click", 0, mine.length).isEmpty()) + } + + // MARK: - Peer colors + + @Test + fun `peer color hue is stable across light and dark, only chroma differs`() { + // Hue derivation must stay byte-identical to iOS; only saturation/value are tuned for + // the redesigned neutral message body. + val dark = colorForPeerSeed("noise:abc", isDark = true) + val light = colorForPeerSeed("noise:abc", isDark = false) + + val darkHsv = FloatArray(3) + val lightHsv = FloatArray(3) + rgbToHsv(dark.red, dark.green, dark.blue, darkHsv) + rgbToHsv(light.red, light.green, light.blue, lightHsv) + + assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0) + } + + @Test + fun `peer color avoids the orange hue reserved for self`() { + // Sweep a range of seeds; none may land within the reserved orange band. + repeat(500) { i -> + val color = colorForPeerSeed("noise:seed$i", isDark = true) + val hsv = FloatArray(3) + rgbToHsv(color.red, color.green, color.blue, hsv) + val distanceFromOrange = kotlin.math.abs(hsv[0] - 30f) + assertTrue( + "seed$i resolved to ${hsv[0]}°, inside the reserved orange band", + distanceFromOrange >= 17f || hsv[1] < 0.01f + ) + } + } + + @Test + fun `light palette is not the dark palette`() { + assertNull(null) + assertTrue(LightBitchatPalette.textPrimary != DarkBitchatPalette.textPrimary) + assertTrue(LightBitchatPalette.isDark != DarkBitchatPalette.isDark) + } + + private fun rgbToHsv(r: Float, g: Float, b: Float, out: FloatArray) { + val max = maxOf(r, g, b) + val min = minOf(r, g, b) + val delta = max - min + out[0] = when { + delta == 0f -> 0f + max == r -> (60f * (((g - b) / delta) % 6f) + 360f) % 360f + max == g -> 60f * (((b - r) / delta) + 2f) + else -> 60f * (((r - g) / delta) + 4f) + } + out[1] = if (max == 0f) 0f else delta / max + out[2] = max + } } From ea0660e7173e10f9524b7772f412554f5e5c55f5 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:12:19 +0200 Subject: [PATCH 02/70] pass 2 --- .../com/bitchat/android/ui/AboutSections.kt | 15 +- .../java/com/bitchat/android/ui/AboutSheet.kt | 1 - .../java/com/bitchat/android/ui/ChatHeader.kt | 60 ++++--- .../com/bitchat/android/ui/ChatUIUtils.kt | 9 +- .../android/ui/LocationChannelsSheet.kt | 4 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 4 +- .../bitchat/android/ui/MessageComponents.kt | 24 ++- .../android/ui/media/AudioMessageItem.kt | 2 +- .../android/ui/media/ImageMessageItem.kt | 2 +- app/src/main/res/values-ar/strings.xml | 5 - app/src/main/res/values-bn/strings.xml | 5 - app/src/main/res/values-de/strings.xml | 5 - app/src/main/res/values-es/strings.xml | 5 - app/src/main/res/values-fa/strings.xml | 5 - app/src/main/res/values-fil/strings.xml | 5 - app/src/main/res/values-fr/strings.xml | 5 - app/src/main/res/values-hi/strings.xml | 5 - app/src/main/res/values-id/strings.xml | 5 - app/src/main/res/values-it/strings.xml | 5 - app/src/main/res/values-ja/strings.xml | 5 - app/src/main/res/values-ka/strings.xml | 5 - app/src/main/res/values-ko/strings.xml | 5 - app/src/main/res/values-mg/strings.xml | 5 - app/src/main/res/values-ne/strings.xml | 5 - app/src/main/res/values-nl/strings.xml | 5 - app/src/main/res/values-pa-rPK/strings.xml | 5 - app/src/main/res/values-pt-rBR/strings.xml | 5 - app/src/main/res/values-pt/strings.xml | 5 - app/src/main/res/values-ru/strings.xml | 5 - app/src/main/res/values-sv/strings.xml | 5 - app/src/main/res/values-th/strings.xml | 5 - app/src/main/res/values-tr/strings.xml | 5 - app/src/main/res/values-ur/strings.xml | 5 - app/src/main/res/values-vi/strings.xml | 5 - app/src/main/res/values-zh/strings.xml | 5 - app/src/main/res/values/strings.xml | 26 +-- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 13 +- .../bitchat/android/ui/MessageGroupingTest.kt | 152 ++++++++++++++++++ 38 files changed, 240 insertions(+), 202 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index 73403b6d..ae1b79a0 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -46,6 +46,7 @@ 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.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -206,7 +207,9 @@ internal fun AboutTabBar( HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) Box( modifier = Modifier - .offset(x = indicatorOffset) + // 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) @@ -355,8 +358,8 @@ internal fun AboutFeatureCard(modifier: Modifier = Modifier) { val features = listOf( Triple( Icons.Filled.WifiOff, - R.string.about_offline_comm_title, - R.string.about_offline_comm_desc + R.string.about_offline_mesh_title, + R.string.about_offline_mesh_desc ), Triple( Icons.Outlined.Lock, @@ -365,8 +368,8 @@ internal fun AboutFeatureCard(modifier: Modifier = Modifier) { ), Triple( Icons.Outlined.Public, - R.string.about_local_channels_title, - R.string.about_local_channels_desc + R.string.about_online_geohash_title, + R.string.about_online_geohash_desc ), Triple( Icons.Outlined.VisibilityOff, @@ -380,7 +383,7 @@ internal fun AboutFeatureCard(modifier: Modifier = Modifier) { ), Triple( Icons.Filled.DeleteForever, - R.string.about_panic_title, + R.string.about_emergency_title, R.string.about_panic_desc ), ) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index ce170343..2049b518 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -246,7 +246,6 @@ fun AboutSheet( val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current - val isDark = palette.isDark var selectedTab by remember { mutableStateOf(AboutTab.HowToUse) } if (isPresented) { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 45657cef..adda2378 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -120,26 +120,30 @@ fun NoiseSessionIcon( sessionState: String?, modifier: Modifier = Modifier ) { + val palette = LocalBitchatPalette.current + // 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 (icon, color, contentDescription) = when (sessionState) { "uninitialized" -> Triple( Icons.Outlined.NoEncryption, - Color(0x87878700), // Grey - ready to establish + palette.textSecondary, stringResource(R.string.cd_ready_for_handshake) ) "handshaking" -> Triple( Icons.Outlined.Sync, - Color(0x87878700), // Grey - in progress + palette.textSecondary, stringResource(R.string.cd_handshake_in_progress) ) "established" -> Triple( Icons.Filled.Lock, - Color(0xFFFF9500), // Orange - secure + palette.accentOrange, stringResource(R.string.cd_encrypted) ) else -> { // "failed" or any other state Triple( Icons.Outlined.Warning, - Color(0xFFFF4444), // Red - error + palette.accentRed, stringResource(R.string.cd_handshake_failed) ) } @@ -400,28 +404,36 @@ private fun MainHeader( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - // MARK: - Identity cluster - BitChatBrandButton( - onClick = onTitleClick, - onTripleClick = onTripleTitleClick, - contentDescription = stringResource(R.string.cd_open_about), - ) + // 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.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, - // 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(horizontal = 2.dp) - ) + Text( + text = "/", + style = MaterialTheme.typography.bodyMedium, + // 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(horizontal = 2.dp) + ) - NicknameEditor( - value = nickname, - onValueChange = onNicknameChange - ) - - Spacer(modifier = Modifier.weight(1f)) + NicknameEditor( + value = nickname, + onValueChange = onNicknameChange + ) + } // MARK: - Status cluster. Order matches the design: bookmark, channel, people. Row( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 2f945593..d1415fbe 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -7,7 +7,6 @@ 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 com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BitchatPalette import java.text.SimpleDateFormat @@ -49,11 +48,11 @@ fun getRSSIColor(rssi: Int): Color { fun formatTextMessageSender( message: BitchatMessage, currentUserNickname: String, - meshService: MeshService, + myPeerID: String, palette: BitchatPalette ): AnnotatedString { val builder = AnnotatedString.Builder() - val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) + val isSelf = message.isFromSelf(currentUserNickname, myPeerID) val senderColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark) val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold val (baseName, suffix) = splitSuffix(message.sender) @@ -213,13 +212,13 @@ fun formatSystemMessage( fun formatMessageHeaderAnnotatedString( message: BitchatMessage, currentUserNickname: String, - meshService: MeshService, + myPeerID: String, palette: BitchatPalette, timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()), includeSender: Boolean = true ): AnnotatedString { val builder = AnnotatedString.Builder() - val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) + val isSelf = message.isFromSelf(currentUserNickname, myPeerID) if (message.sender == "system") { return formatSystemMessage(message, palette, timeFormatter) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 26cdb767..98e1fb40 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -298,7 +298,7 @@ fun LocationChannelsSheet( // Bookmarked geohashes if (bookmarks.isNotEmpty()) { item(key = "bookmarked_header") { - SheetSectionLabel(text = stringResource(R.string.section_bookmarked)) + SheetSectionLabel(text = stringResource(R.string.bookmarked)) } items(bookmarks) { gh -> val level = levelForLength(gh.length) @@ -571,7 +571,7 @@ fun LocationChannelsSheet( item(key = "location_toggle") { SheetDestructiveButton( text = if (locationServicesEnabled) { - stringResource(R.string.remove_location_access) + stringResource(R.string.disable_location_services) } else { stringResource(R.string.enable_location_services) }, diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 71bfc62a..a6c0d63d 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -130,7 +130,7 @@ fun MeshPeerListSheet( // Channels section if (joinedChannels.isNotEmpty()) { item(key = "channels_header") { - SheetSectionLabel(text = stringResource(R.string.section_channels)) + SheetSectionLabel(text = stringResource(R.string.channels)) } items( @@ -316,7 +316,7 @@ fun PeopleSection( val palette = LocalBitchatPalette.current Column(modifier = modifier) { - SheetSectionLabel(text = stringResource(R.string.section_mesh)) + SheetSectionLabel(text = stringResource(R.string.people)) if (connectedPeers.isEmpty()) { Text( diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 14f7b4f3..7457c681 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -300,7 +300,7 @@ fun MessageItem( val headerText = formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, + myPeerID = meshService.myPeerID, palette = palette, timeFormatter = timeFormatter, includeSender = showSender @@ -368,15 +368,24 @@ fun MessageItem( .align(Alignment.TopEnd) .padding(4.dp) .size(22.dp) - .background(Color.Gray.copy(alpha = 0.6f), CircleShape) + .background(palette.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 = palette.textPrimary, + 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 = FontFamily.Monospace, + color = palette.textTertiary + ) } } } @@ -468,7 +477,7 @@ internal fun TextMessageLayout( formatTextMessageSender( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, + myPeerID = myPeerId, palette = palette, ) } @@ -548,6 +557,7 @@ internal fun TextMessageLayout( @Composable fun DeliveryStatusIcon(status: DeliveryStatus) { val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current when (status) { is DeliveryStatus.Sending -> { @@ -577,7 +587,7 @@ fun DeliveryStatusIcon(status: DeliveryStatus) { Text( text = stringResource(R.string.status_delivered), fontSize = 10.sp, - color = Color(0xFF007AFF), // Blue + color = palette.accentBlue, fontWeight = FontWeight.Bold ) } @@ -585,7 +595,7 @@ fun DeliveryStatusIcon(status: DeliveryStatus) { Text( text = stringResource(R.string.status_failed), fontSize = 10.sp, - color = Color.Red.copy(alpha = 0.8f) + color = palette.accentRed ) } is DeliveryStatus.PartiallyDelivered -> { diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 5db41f17..dc52f845 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -53,7 +53,7 @@ fun AudioMessageItem( val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, + myPeerID = meshService.myPeerID, palette = palette, timeFormatter = timeFormatter, includeSender = showSender diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 1691bb9b..01d119fe 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -51,7 +51,7 @@ fun ImageMessageItem( val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, + myPeerID = meshService.myPeerID, palette = palette, timeFormatter = timeFormatter, includeSender = showSender diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index bd8949cb..5ad12ff2 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -72,7 +72,6 @@ رجوع - القناة: %1$s مغادرة يمكن الوصول عبر Nostr رسائل خاصة غير مقروءة @@ -102,7 +101,6 @@ تواصل مع أشخاص بالقرب منك عبر قنوات تعتمد على geohash. وسّع الشبكة عبر مرحلات الإنترنت العامة. تشفير طرف إلى طرف الرسائل الخاصة مشفّرة. رسائل القنوات عامة. - المظهر النظام فاتح داكن @@ -126,7 +124,6 @@ حالة Tor: %1$s، التمهيد %2$d%% آخر: %1$s مسح البيانات الطارئ - تلميح: انقر ثلاث مرات على عنوان التطبيق لمسح كل البيانات المحفوظة – بما في ذلك الرسائل والمفاتيح والإعدادات. إعدادات التصحيح مفتوح المصدر • الخصوصية أولاً • لامركزي إغلاق @@ -146,7 +143,6 @@ فتح الخريطة إزالة الإشارة المرجعية انتقال - محدد مغادرة القناة يمكن الوصول عبر Nostr مفضل (غير متصل) @@ -285,7 +281,6 @@ الأذونات منح الأذونات bitchat لا يتتبع موقعك - الأشخاص لا أحد هنا… (أنت) حرّك وقرّب لاختيار geohash diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index f978e0ce..76c13dd2 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -72,7 +72,6 @@ ফিরে যান - চ্যানেল: %1$s ছেড়ে দিন Nostr এর মাধ্যমে পৌঁছানো যায় অপঠিত ব্যক্তিগত বার্তা @@ -102,7 +101,6 @@ জিওহ্যাশ-ভিত্তিক চ্যানেলের মাধ্যমে আপনার এলাকার মানুষদের সাথে সংযুক্ত হন। পাবলিক ইন্টারনেট রিলেগুলির মাধ্যমে মেশ বিস্তৃত করুন। এন্ড-টু-এন্ড এনক্রিপশন ব্যক্তিগত বার্তাগুলি এনক্রিপ্ট করা। চ্যানেল বার্তাগুলি পাবলিক। - চেহারা সিস্টেম হালকা অন্ধকার @@ -126,7 +124,6 @@ Tor অবস্থা: %1$s, বুটস্ট্র্যাপ %2$d%% শেষ: %1$s জরুরী ডেটা ক্লিয়ার - টিপ: সংরক্ষিত সমস্ত ডেটা – বার্তা, কী এবং সেটিংস সহ – সাফ করতে অ্যাপ শিরোনামে তিনবার ট্যাপ করুন। ডিবাগ সেটিংস ওপেন সোর্স • গোপনীয়তা প্রথম • বিকেন্দ্রীভূত বন্ধ করুন @@ -146,7 +143,6 @@ মানচিত্র খুলুন বুকমার্ক সরান টেলিপোর্ট - নির্বাচিত চ্যানেল ছেড়ে দিন Nostr এর মাধ্যমে পৌঁছানো যায় প্রিয় (অফলাইন) @@ -285,7 +281,6 @@ অনুমতি অনুমতি দিন bitchat আপনার অবস্থান ট্র্যাক করে না - মানুষ কেউ আশেপাশে নেই … (আপনি) জিওহ্যাশ নির্বাচন করতে প্যান এবং জুম করুন diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9fe6301e..23168837 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -72,7 +72,6 @@ zurück - Kanal: %1$s verlassen Über Nostr erreichbar Ungelesene private Nachrichten @@ -102,7 +101,6 @@ Vernetze dich mit Menschen in deiner Umgebung über geohash‑basierte Kanäle. Erweitere das Mesh über öffentliche Internet‑Relays. Ende‑zu‑Ende‑Verschlüsselung Private Nachrichten sind verschlüsselt. Kanal‑Nachrichten sind öffentlich. - Darstellung System Hell Dunkel @@ -126,7 +124,6 @@ Tor‑Status: %1$s, Bootstrap %2$d%% Zuletzt: %1$s Notfall‑Datenlöschung - Tipp: Dreifach auf den App‑Titel tippen, um alle gespeicherten Daten – einschließlich Nachrichten, Schlüssel und Einstellungen – zu löschen. Debug‑Einstellungen Open Source • Privacy First • Dezentral Schließen @@ -146,7 +143,6 @@ Karte öffnen Lesezeichen entfernen Teleportieren - Ausgewählt Kanal verlassen Über Nostr erreichbar Favorit (offline) @@ -285,7 +281,6 @@ Berechtigungen Berechtigungen erteilen bitchat verfolgt NICHT deinen Standort - PERSONEN niemand da … (du) schwenken und zoomen, um einen Geohash auszuwählen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 89d907da..836f563a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -72,7 +72,6 @@ atrás - Canal: %1$s salir Accesible vía Nostr Mensajes privados sin leer @@ -102,7 +101,6 @@ Conéctate con personas cerca de ti a través de canales basados en geohash. Extiende la red mesh a través de repetidores de internet público. Cifrado de extremo a extremo Los mensajes privados están cifrados. Los mensajes de canal son públicos. - Apariencia Sistema Claro Oscuro @@ -126,7 +124,6 @@ Estado de Tor: %1$s, bootstrap %2$d%% Último: %1$s Borrado de datos de emergencia - Consejo: Toca tres veces en el título de la app para borrar todos los datos almacenados, incluyendo mensajes, claves y configuraciones. Configuraciones de depuración Código abierto • Privacidad primero • Descentralizado Cerrar @@ -146,7 +143,6 @@ Abrir mapa Quitar marcador Teletransportar - Seleccionado Salir del canal Accesible vía Nostr Favorito (sin conexión) @@ -285,7 +281,6 @@ Permisos Otorgar permisos bitchat NO rastrea tu ubicación - PERSONAS nadie por aquí… (tú) desplaza y hace zoom para seleccionar un geohash diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 5f895797..551ca8db 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -72,7 +72,6 @@ بازگشت - کانال: %1$s خروج قابل دسترس از طریق Nostr پیام‌های خصوصی خوانده‌نشده @@ -102,7 +101,6 @@ از طریق کانال‌های مبتنی بر geohash با افراد منطقهٔ خود ارتباط برقرار کنید. مِش را از طریق رله‌های عمومی اینترنت گسترش دهید. رمزگذاری سرتاسری پیام‌های خصوصی رمزگذاری می‌شوند. پیام‌های کانال عمومی هستند. - نما سامانه روشن تیره @@ -126,7 +124,6 @@ وضعیت Tor: %1$s، بوت‌استرپ %2$d%% آخرین: %1$s پاکسازی اضطراری داده - نکته: برای پاک کردن همهٔ داده‌های ذخیره‌شده — شامل پیام‌ها، کلیدها و تنظیمات — سه بار روی عنوان برنامه ضربه بزنید. تنظیمات اشکال‌زدایی متن‌باز • حریم خصوصی در اولویت • غیرمتمرکز بستن @@ -146,7 +143,6 @@ باز کردن نقشه حذف نشانک انتقال - انتخاب شد خروج از کانال قابل دسترس از طریق Nostr دلخواه (آفلاین) @@ -285,7 +281,6 @@ مجوزها اعطای مجوزها bitchat موقعیت شما را پیگیری نمی‌کند - افراد کسی در اطراف نیست … (شما) برای انتخاب geohash، جابه‌جا و زوم کنید diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index dc231763..526cc636 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -72,7 +72,6 @@ bumalik - channel: %1$s umalis Maabot sa Nostr Hindi nabasang pribadong mensahe @@ -102,7 +101,6 @@ Makipag‑ugnayan sa mga tao sa paligid gamit ang geohash‑based na mga channel. Palawakin ang mesh sa pampublikong internet relays. End‑to‑End Encryption Ang mga pribadong mensahe ay naka‑encrypt. Ang mga mensahe sa channel ay publiko. - hitsura system light dark @@ -126,7 +124,6 @@ katayuan ng Tor: %1$s, bootstrap %2$d%% Huli: %1$s Emergency Data Deletion - Tip: i‑tap ang pamagat ng app nang 3 beses para burahin ang lahat ng data (mga mensahe, susi, setting). Debug Settings Open Source • Privacy First • Desentralisado Isara @@ -146,7 +143,6 @@ Buksan ang mapa Alisin ang bookmark Teleport - Napili Umalis sa channel Maabot sa Nostr Paborito (offline) @@ -280,7 +276,6 @@ HINDI sinusubaybayan ng bitchat ang lokasyon mo @ · ⧉ - TAO walang tao sa paligid… (ikaw) i‑pan at i‑zoom para pumili ng geohash diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9d2b515f..8da0188f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -72,7 +72,6 @@ retour - canal : %1$s quitter Joignable via Nostr Messages privés non lus @@ -102,7 +101,6 @@ Discute avec des personnes proches via des canaux basés sur geohash. Étends le mesh grâce à des relais internet publics. Chiffrement de bout en bout Les messages privés sont chiffrés. Les messages de canal sont publics. - apparence système clair sombre @@ -126,7 +124,6 @@ statut Tor : %1$s, bootstrap %2$d%% Dernier : %1$s Suppression d’urgence des données - Astuce : tapote 3 fois le titre de l’app pour supprimer toutes les données (messages, clés, paramètres). Paramètres de debug Open Source • Privacy First • Décentralisé Fermer @@ -146,7 +143,6 @@ Ouvrir la carte Retirer le signet Téléporter - Sélectionné Quitter le canal Joignable via Nostr Favori (hors‑ligne) @@ -281,7 +277,6 @@ bitchat ne suit PAS ta position @ · ⧉ - PERSONNES personne aux alentours… (toi) déplacer et zoomer pour choisir un geohash diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 3ec6ac27..6921ace7 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -72,7 +72,6 @@ वापस - चैनल: %1$s छोड़ें Nostr के माध्यम से उपलब्ध अपठित निजी संदेश @@ -102,7 +101,6 @@ geohash आधारित चैनलों के माध्यम से अपने आस‑पास के लोगों से जुड़ें। सार्वजनिक इंटरनेट रिले के माध्यम से मेष का विस्तार करें। एंड‑टू‑एंड एन्क्रिप्शन निजी संदेश एन्क्रिप्टेड हैं। चैनल संदेश सार्वजनिक हैं। - रूप सिस्टम लाइट डार्क @@ -126,7 +124,6 @@ Tor स्थिति: %1$s, बूटस्ट्रैप %2$d%% अंतिम: %1$s आपातकालीन डेटा मिटाना - संकेत: सभी सहेजे डेटा – संदेश, कुंजियाँ और सेटिंग्स सहित – मिटाने के लिए ऐप शीर्षक पर तीन बार टैप करें। डिबग सेटिंग्स ओपन सोर्स • गोपनीयता पहले • विकेन्द्रीकृत बंद करें @@ -146,7 +143,6 @@ मानचित्र खोलें बुकमार्क हटाएँ टेलीपोर्ट - चयनित चैनल छोड़ें Nostr के माध्यम से उपलब्ध पसंदीदा (ऑफ़लाइन) @@ -285,7 +281,6 @@ अनुमतियाँ अनुमतियाँ दें bitchat आपकी लोकेशन ट्रैक नहीं करती - लोग आस‑पास कोई नहीं… (आप) geohash चुनने के लिए पैन/ज़ूम करें diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 048d40b2..c5eb612c 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -72,7 +72,6 @@ kembali - Channel: %1$s keluar Dapat dijangkau melalui Nostr Pesan pribadi yang belum dibaca @@ -102,7 +101,6 @@ Terhubung dengan orang-orang di area Anda melalui channel berbasis geohash. Perluas mesh melalui relay Internet publik. Enkripsi end-to-end Pesan pribadi dienkripsi. Pesan channel bersifat publik. - Tampilan Sistem Terang Gelap @@ -126,7 +124,6 @@ Status Tor: %1$s, Bootstrap %2$d%% Terakhir: %1$s Hapus data darurat - Tips: Ketuk tiga kali pada judul aplikasi untuk menghapus semua data tersimpan – termasuk pesan, kunci, dan pengaturan. Pengaturan debug Sumber Terbuka • Privasi Pertama • Terdesentralisasi Tutup @@ -146,7 +143,6 @@ Buka peta Hapus bookmark Teleportasi - Dipilih Keluar dari channel Dapat dijangkau melalui Nostr Favorit (offline) @@ -285,7 +281,6 @@ Izin Berikan izin bitchat TIDAK melacak lokasi Anda - ORANG tidak ada orang di sekitar … (Anda) geser dan zoom untuk memilih geohash diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 434f1dc6..8c157300 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -72,7 +72,6 @@ indietro - canale: %1$s esci Raggiungibile via Nostr Messaggi privati non letti @@ -102,7 +101,6 @@ Connettiti con persone nella tua area usando canali basati su geohash. Estendi la rete mesh usando relay internet pubblici. Crittografia end-to-end I messaggi privati sono crittografati. I messaggi dei canali sono pubblici. - aspetto sistema chiaro scuro @@ -126,7 +124,6 @@ Stato Tor: %1$s, bootstrap %2$d%% Ultimo: %1$s Eliminazione dati di emergenza - Suggerimento: fai triplo clic sul titolo dell\'app per eliminare in emergenza tutti i dati memorizzati, inclusi messaggi, chiavi e impostazioni. Impostazioni di debug Open Source • Privacy First • Decentralizzato Chiudi @@ -146,7 +143,6 @@ Apri mappa Rimuovi segnalibro Teletrasporta - Selezionato Esci dal canale Raggiungibile via Nostr Preferito offline @@ -285,7 +281,6 @@ permessi Concedi permessi bitchat NON traccia la tua posizione - PERSONE nessuno nei dintorni… (tu) scorri e ingrandisci per selezionare un geohash diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 1a3df392..b272e51f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -72,7 +72,6 @@ 戻る - チャンネル: %1$s 退出 Nostr 経由で到達可能 未読のプライベートメッセージ @@ -102,7 +101,6 @@ Geohash ベースのチャンネルで近くの人々とつながります。公開インターネットのリレーを介してメッシュを拡張します。 エンドツーエンド暗号化 プライベートメッセージは暗号化されます。チャンネルメッセージは公開です。 - 表示 システム ライト ダーク @@ -126,7 +124,6 @@ Tor ステータス: %1$s、ブートストラップ %2$d%% 最終: %1$s 緊急データ消去 - ヒント: アプリタイトルを 3 回タップすると、メッセージ・鍵・設定を含む保存データをすべて消去します。 デバッグ設定 オープンソース • プライバシーファースト • 分散型 閉じる @@ -146,7 +143,6 @@ 地図を開く ブックマークを削除 テレポート - 選択済み チャンネルから退出 Nostr 経由で到達可能 お気に入り (オフライン) @@ -285,7 +281,6 @@ 権限 権限を付与 bitchat はあなたの位置情報を追跡しません - 人々 誰もいません… (あなた) パン/ズームして geohash を選択 diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index cf08947b..65f27848 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -72,7 +72,6 @@ უკან - არხი: %1$s გასვლა მისაწვდომია Nostr-ით წაუკითხავი პირადი შეტყობინებები @@ -102,7 +101,6 @@ დაუკავშირდით ახლომდებარე ხალხს geohash-ზე დაფუძნებული არხებით. გააფართოვეთ mesh საჯარო ინტერნეტ რილეებით. ბოლო-მდე დაშიფვრა პირადი შეტყობინებები დაშიფრულია. არხის შეტყობინებები საჯაროა. - გარეგნობა სისტემა ღია მუქი @@ -126,7 +124,6 @@ Tor სტატუსი: %1$s, ჩატვირთვა %2$d%% ბოლო: %1$s სასწრაფო მონაცემების წაშლა - რჩევა: დააჭირეთ აპის სათაურს სამჯერ ყველა შენახული მონაცემის – შეტყობინებების, გასაღებებისა და პარამეტრების – წასაშლელად. დebug პარამეტრები ღია კოდი • კონფიდენციალობა პირველია • დეცენტრალიზებული დახურვა @@ -146,7 +143,6 @@ რუკის გახსნა სანიშნეს წაშლა ტელეპორტი - მონიშნული არხიდან გამოსვლა მისაწვდომია Nostr-ით რჩეული (ოფლაინ) @@ -285,7 +281,6 @@ ნებართვები ნებართვების მინიჭება bitchat არ აკონტროლებს თქვენს მდებარეობას - ხალხი არავინ არის ახლოს… (თქვენ) პანორამირება/ზუმი geohash-ის ასარჩევად diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 5be26395..ee2838b6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -72,7 +72,6 @@ 뒤로 - 채널: %1$s 나가기 Nostr로 연결 가능 읽지 않은 개인 메시지 @@ -102,7 +101,6 @@ geohash 기반 채널을 통해 주변 사람들과 연결하세요. 공용 인터넷 릴레이로 메쉬를 확장합니다. 종단간 암호화 개인 메시지는 암호화됩니다. 채널 메시지는 공개입니다. - 모양 시스템 라이트 다크 @@ -126,7 +124,6 @@ Tor 상태: %1$s, 부트스트랩 %2$d%% 마지막: %1$s 긴급 데이터 삭제 - 팁: 저장된 모든 데이터(메시지, 키, 설정 포함)를 삭제하려면 앱 제목을 세 번 탭하세요. 디버그 설정 오픈 소스 • 프라이버시 우선 • 탈중앙 닫기 @@ -146,7 +143,6 @@ 지도 열기 북마크 제거 텔레포트 - 선택됨 채널 나가기 Nostr로 연결 가능 즐겨찾기 (오프라인) @@ -285,7 +281,6 @@ 권한 권한 허용 bitchat은 사용자의 위치를 추적하지 않습니다 - 사람들 주변에 아무도 없음 … (나) geohash를 선택하려면 이동하고 확대/축소하세요 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index 68b640f9..ffec5957 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -72,7 +72,6 @@ miverina - fantsona: %1$s hiala Azo tratrarina amin\'ny alalan\'ny Nostr Hafatra manokana tsy voavaky @@ -102,7 +101,6 @@ Mifandray amin\'ny olona eo amin\'ny faritra misy anao amin\'ny fampiasana fantsona mifototra amin\'ny geohash. Manitatra ny mesh amin\'ny fampiasana relay Internet ho an\'ny daholobe. Fandokoana Farany amin\'ny Farany Voafono ny hafatra manokana. Hafatra ho an\'ny daholobe ny hafatra fantsona. - endrika rafitra mazava maizina @@ -126,7 +124,6 @@ Toe-javatra Tor: %1$s, bootstrap %2$d%% Farany: %1$s Famafana Angona Maika - Toro-hevitra: Tsindrio in-telo ny anaran\'ny fampiharana mba hamafana maika ny angona rehetra voatahiry ao anatin\'izany ny hafatra, ny lakile ary ny fametrahana. Fametrahana Debug Loharanom-baovao misokatra • Fiainana manokana voalohany • Tsy misy foibe Hanakatona @@ -146,7 +143,6 @@ Hanokatra sarintany Hanesorana bookmark Hamindra toerana - Voafidy Hiala amin\'ny fantsona Azo tratrarina amin\'ny alalan\'ny Nostr Ankafizina tsy an-tserasera @@ -289,7 +285,6 @@ Ny bitchat dia TSY manaraka ny toerananao @ · ⧉ - OLONA tsy misy olona manodidina... (ianao) ahetsika sy ataovy lehibe mba hifidianana geohash diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index d460c037..7c2bbf26 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -72,7 +72,6 @@ पछाडि - च्यानल: %1$s छोड्नुहोस् Nostr मार्फत पुग्न सकिने नपढिएका निजी सन्देश @@ -102,7 +101,6 @@ Geohash आधारित च्यानलमार्फत नजिकका मानिसहरूसँग जोडिनुहोस्। सार्वजनिक इन्टरनेट रिलेलाई प्रयोग गरेर मेष विस्तार गर्नुहोस्। एन्ड‑टु‑एन्ड इन्क्रिप्सन निजी सन्देश इन्क्रिप्टेड हुन्छन्। च्यानल सन्देश सार्वजनिक हुन्छन्। - देखावट सिस्टम हल्का गाढा @@ -126,7 +124,6 @@ Tor स्थिति: %1$s, बुटस्ट्र्याप %2$d%% अन्तिम: %1$s आपतकालीन डेटा मेटाउने - सुझाव: एप शीर्षक तीन पटक ट्याप गरी सबै डेटा (सन्देश, कुञ्जी, सेटिङ) मेटाउनुहोस्। डिबग सेटिङ Open Source • Privacy First • विकेन्द्रित बन्द गर्नुहोस् @@ -146,7 +143,6 @@ नक्सा खोल्नुहोस् बुकमार्क हटाउनुहोस् टेलिपोर्ट - छानिएको च्यानल छोड्नुहोस् Nostr बाट पहुँचयोग्य मनपर्ने (अफलाइन) @@ -280,7 +276,6 @@ bitchat ले तपाईंको स्थान पछ्याउँदैन @ · ⧉ - मानिसहरू वरिपरि कोही छैन… (तपाईं) geohash छान्न प्यान र जुम गर्नुहोस् diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 4cf60a3d..d9de3a6d 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -72,7 +72,6 @@ terug - kanaal: %1$s verlaten Bereikbaar via Nostr Ongelezen privéberichten @@ -102,7 +101,6 @@ Maak contact met mensen in uw omgeving met geohash-gebaseerde kanalen. Breid het mesh uit met openbare internetrelays. End-to-end-versleuteling Privéberichten zijn versleuteld. Kanaalberichten zijn openbaar. - uiterlijk systeem licht donker @@ -126,7 +124,6 @@ Tor-status: %1$s, bootstrap %2$d%% Laatste: %1$s Noodgegevensverwijdering - Tip: Driedubbel klik op de app-titel om alle opgeslagen gegevens inclusief berichten, sleutels en instellingen noodmatig te verwijderen. Debug-instellingen Open Source • Privacy First • Gedecentraliseerd Sluiten @@ -146,7 +143,6 @@ Kaart openen Bladwijzer verwijderen Teleporteren - Geselecteerd Kanaal verlaten Bereikbaar via Nostr Offline favoriet @@ -285,7 +281,6 @@ toestemmingen Toestemmingen verlenen bitchat volgt NIET uw locatie - PERSONEN niemand in de buurt… (jij) pan en zoom om een geohash te selecteren diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 553e35ca..f7423ce5 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -72,7 +72,6 @@ واپس - چینل: %1$s باہر آؤ Nostr راہین پہنچ ممکن اے انہ پڑھیا ذاتی پیغام @@ -102,7 +101,6 @@ geohash بیسڈ چینلاں راہین اپنے علاقے دے لوکاں نال جڑو۔ پبلک انٹرنیٹ ریلے راہین میش نوں وسیع کرو۔ اینڈ ٹو اینڈ انکرپشن ذاتی پیغام انکرپٹڈ ہوندے نیں۔ چینل پیغام پبلک ہوندے نیں۔ - دِکھ سسٹم ہلکا گھُپ @@ -126,7 +124,6 @@ Tor حالت: %1$s، بوٹ اسٹرَیپ %2$d%% آخری: %1$s ایمرجنسی ڈیٹا صاف - ٹِپ: سارے محفوظ ڈیٹا (پیغام، کُنجیاں، سیٹنگا) مٹاؤن لئی ایپ دے عنوان تے تین وار ٹیپ کرو۔ ڈی بگ سیٹنگا اوپن سورس • پرائیویسی پہلے • غیر مرکزی بند کرو @@ -146,7 +143,6 @@ نقشہ کھولو بُک مارک ہٹاؤ ٹیلپورٹ - چُنے ہوۓ چینل چھوڑو Nostr راہین پہنچ ممکن اے پسندیدہ (آف لائن) @@ -285,7 +281,6 @@ اجازتاں اجازت دو bitchat توانڈی لوکیشن نئیں ٹریک کردا - لوگ کوئی وی نیڑے نئیں … (تُسی) geohash چُنن لئی پین تے زوم کرو diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 86e238cb..a350d3f7 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -72,7 +72,6 @@ voltar - Canal: %1$s sair Acessível via Nostr Mensagens privadas não lidas @@ -102,7 +101,6 @@ Conecte-se com pessoas próximas através de canais baseados em geohash. Expanda a mesh por meio de relays públicos na internet. Criptografia de ponta a ponta Mensagens privadas são criptografadas. Mensagens de canal são públicas. - Aparência Sistema Claro Escuro @@ -126,7 +124,6 @@ Status do Tor: %1$s, bootstrap %2$d%% Último: %1$s Limpeza de dados de emergência - Dica: Toque 3 vezes no título do app para apagar todos os dados salvos – incluindo mensagens, chaves e configurações. Configurações de depuração Código aberto • Privacidade em primeiro lugar • Descentralizado Fechar @@ -146,7 +143,6 @@ Abrir mapa Remover favorito Teletransportar - Selecionado Sair do canal Acessível via Nostr Favorito (offline) @@ -285,7 +281,6 @@ Permissões Conceder permissões O bitchat NÃO rastreia sua localização - PESSOAS ninguém por perto… (você) arraste/amplie para selecionar um geohash diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 2aeb5352..9751b733 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -72,7 +72,6 @@ voltar - Canal: %1$s sair Alcançável via Nostr Mensagens privadas não lidas @@ -102,7 +101,6 @@ Conecte-se com pessoas perto de si através de canais baseados em geohash. Estenda a mesh através de repetidores de internet pública. Encriptação ponto a ponto Mensagens privadas são encriptadas. Mensagens de canal são públicas. - Aparência Sistema Claro Escuro @@ -126,7 +124,6 @@ Estado do Tor: %1$s, bootstrap %2$d%% Último: %1$s Limpeza de dados de emergência - Dica: Toque três vezes no título da aplicação para apagar todos os dados guardados – incluindo mensagens, chaves e definições. Definições de depuração Código aberto • Privacidade primeiro • Descentralizado Fechar @@ -146,7 +143,6 @@ Abrir mapa Remover marcador Teletransportar - Selecionado Sair do canal Alcançável via Nostr Favorito (offline) @@ -285,7 +281,6 @@ Permissões Conceder permissões O bitchat NÃO rastreia a sua localização - PESSOAS ninguém por perto… (você) arraste e amplie para selecionar um geohash diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 76b332ff..5672cd00 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -64,7 +64,6 @@ Добавить закладку назад - канал: %1$s выйти Доступен через Nostr Непрочитанные личные @@ -92,7 +91,6 @@ Общайся с людьми поблизости через каналы на основе geohash. Расширяй mesh через публичные ретрансляторы. Сквозное шифрование Личные сообщения — зашифрованы. Канальные — публичны. - оформление система светлая тёмная @@ -116,7 +114,6 @@ статус Tor: %1$s, загрузка %2$d%% Последнее: %1$s Экстренное удаление данных - Совет: тройной тап по заголовку — удалить все данные (сообщения, ключи, настройки). Настройки отладки Open Source • Privacy First • Децентрализация Закрыть @@ -135,7 +132,6 @@ Открыть карту Удалить закладку Телепорт - Выбрано Покинуть канал Доступен через Nostr Избранное (оффлайн) @@ -264,7 +260,6 @@ bitchat НЕ отслеживает вашу геопозицию @ · ⧉ - ЛЮДИ никого рядом… (вы) прокрутите/масштабируйте для выбора geohash diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index e465e4da..a6df7a8e 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -64,7 +64,6 @@ Lägg till bokmärke tillbaka - kanal: %1$s lämna Nås via Nostr Olästa privata meddelanden @@ -92,7 +91,6 @@ Anslut med personer i närheten via geohash‑kanaler. Utöka mesh via publika internetreläer. Ända‑till‑ända‑kryptering Privata meddelanden är krypterade. Kanalmeddelanden är publika. - utseende system ljus mörk @@ -116,7 +114,6 @@ Tor‑status: %1$s, bootstrap %2$d%% Senast: %1$s Nödradering av data - Tips: tryck tre gånger på appens titel för att radera alla data (meddelanden, nycklar, inställningar). Felsökningsinställningar Open Source • Privacy First • Decentraliserad Stäng @@ -135,7 +132,6 @@ Öppna karta Ta bort bokmärke Teleportera - Vald Lämna kanal Nås via Nostr Favorit (offline) @@ -264,7 +260,6 @@ bitchat spårar INTE din plats @ · ⧉ - PERSONER ingen i närheten… (du) panorera och zooma för att välja geohash diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 33d45309..af9111fa 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -72,7 +72,6 @@ กลับ - ช่อง: %1$s ออก ติดต่อได้ผ่าน Nostr ข้อความส่วนตัวยังไม่ได้อ่าน @@ -102,7 +101,6 @@ เชื่อมต่อกับผู้คนใกล้คุณผ่านช่องที่อ้างอิง geohash ขยายเมชผ่านรีเลย์อินเทอร์เน็ตสาธารณะ การเข้ารหัสแบบปลายทางถึงปลายทาง ข้อความส่วนตัวถูกเข้ารหัส ข้อความในช่องเป็นสาธารณะ - ลักษณะ ระบบ สว่าง มืด @@ -126,7 +124,6 @@ สถานะ Tor: %1$s, การบูต %2$d%% ล่าสุด: %1$s ลบข้อมูลฉุกเฉิน - เคล็ดลับ: แตะชื่อแอปสามครั้งเพื่อลบข้อมูลที่บันทึกทั้งหมด – รวมทั้งข้อความ กุญแจ และการตั้งค่า การตั้งค่าดีบัก โอเพนซอร์ส • ความเป็นส่วนตัวมาก่อน • กระจายศูนย์ ปิด @@ -146,7 +143,6 @@ เปิดแผนที่ เอาที่คั่นหน้าออก เทเลพอร์ต - ที่เลือก ออกจากช่อง ติดต่อได้ผ่าน Nostr รายการโปรด (ออฟไลน์) @@ -285,7 +281,6 @@ สิทธิ์ ให้สิทธิ์ bitchat ไม่ติดตามตำแหน่งของคุณ - ผู้คน ไม่มีใครอยู่ใกล้… (คุณ) ลาก/ซูมเพื่อเลือก geohash diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 0eccd05f..721b50c1 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -64,7 +64,6 @@ Yer imi ekle geri - kanal: %1$s ayrıl Nostr üzerinden ulaşılabilir Okunmamış özel mesaj @@ -92,7 +91,6 @@ Geohash tabanlı kanallarla yakınındaki insanlarla bağlantı kur. Herkese açık internet röleleriyle mesh’i genişlet. Uçtan Uca Şifreleme Özel mesajlar şifrelidir. Kanal mesajları herkese açıktır. - görünüm sistem açık koyu @@ -116,7 +114,6 @@ Tor durumu: %1$s, önyükleme %2$d%% Son: %1$s Acil veri silme - İpucu: tüm verileri silmek için uygulama başlığına üç kez dokun (mesajlar, anahtarlar, ayarlar). Hata ayıklama ayarları Open Source • Privacy First • Merkezsiz Kapat @@ -135,7 +132,6 @@ Haritayı aç Yer imini kaldır Teleport - Seçildi Kanaldan ayrıl Nostr üzerinden erişilebilir Favori (çevrimdışı) @@ -264,7 +260,6 @@ bitchat konumunu takip etmez @ · ⧉ - KİŞİLER yakında kimse yok… (sen) geohash seçmek için kaydır/zoom yap diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index df6d1f4f..0f11e124 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -72,7 +72,6 @@ واپس - چینل: %1$s چھوڑیں Nostr کے ذریعے رسائی غیر پڑھے ہوئے نجی پیغامات @@ -102,7 +101,6 @@ geohash پر مبنی چینلز کے ذریعے اپنے قریب کے لوگوں سے جڑیں۔ عوامی انٹرنیٹ ریلے کے ذریعے میش کو پھیلائیں۔ اینڈ‑ٹو‑اینڈ انکرپشن نجی پیغامات انکرپٹڈ ہیں۔ چینل پیغامات عوامی ہیں۔ - ظاہری شکل سسٹم ہلکا گہرا @@ -126,7 +124,6 @@ Tor کی کیفیت: %1$s، بوٹ اسٹریپ %2$d%% آخری: %1$s ہنگامی ڈیٹا صافی - تجویز: تمام محفوظ شدہ ڈیٹا – پیغامات، کلیدیں اور سیٹنگز سمیت – صاف کرنے کے لیے ایپ کے ٹائٹل پر تین بار ٹیپ کریں۔ ڈیبگ سیٹنگز اوپن سورس • پرائیویسی پہلے • غیر مرکزی بند کریں @@ -146,7 +143,6 @@ نقشہ کھولیں بک مارک ہٹائیں ٹیلی پورٹ - منتخب چینل چھوڑیں Nostr کے ذریعے رسائی پسندیدہ (آف لائن) @@ -285,7 +281,6 @@ اجازات اجازات دیں bitchat آپ کا مقام ٹریک نہیں کرتا - لوگ یہاں کوئی نہیں… (آپ) geohash منتخب کرنے کے لیے پین/زوم کریں diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index b5cd2a6e..083a930a 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -72,7 +72,6 @@ quay lại - Kênh: %1$s rời khỏi Có thể tiếp cận qua Nostr Tin nhắn riêng chưa đọc @@ -102,7 +101,6 @@ Kết nối với những người trong khu vực của bạn qua các kênh dựa trên geohash. Mở rộng mesh qua các relay Internet công cộng. Mã hóa đầu cuối Tin nhắn riêng được mã hóa. Tin nhắn kênh là công khai. - Giao diện Hệ thống Sáng Tối @@ -126,7 +124,6 @@ Trạng thái Tor: %1$s, Bootstrap %2$d%% Cuối cùng: %1$s Xóa dữ liệu khẩn cấp - Mẹo: Nhấn ba lần vào tiêu đề ứng dụng để xóa tất cả dữ liệu đã lưu – bao gồm tin nhắn, khóa và cài đặt. Cài đặt debug Mã nguồn mở • Quyền riêng tư trước tiên • Phi tập trung Đóng @@ -146,7 +143,6 @@ Mở bản đồ Xóa bookmark Dịch chuyển - Đã chọn Rời khỏi kênh Có thể tiếp cận qua Nostr Yêu thích (ngoại tuyến) @@ -285,7 +281,6 @@ Quyền Cấp quyền bitchat KHÔNG theo dõi vị trí của bạn - MỌI NGƯỜI không có ai xung quanh … (bạn) di chuyển và zoom để chọn geohash diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 05143855..75d92b58 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -72,7 +72,6 @@ 返回 - 频道:%1$s 离开 可通过 Nostr 联系 未读私信 @@ -102,7 +101,6 @@ 使用基于 geohash 的频道与附近的人交流。通过公共互联网中继扩展 mesh。 端到端加密 私信加密;频道消息为公开。 - 外观 系统 浅色 深色 @@ -126,7 +124,6 @@ Tor 状态:%1$s,引导 %2$d%% 最近:%1$s 紧急数据删除 - 提示:三击应用标题以删除所有数据,包括消息、密钥和设置。 调试设置 Open Source • Privacy First • 去中心化 关闭 @@ -146,7 +143,6 @@ 打开地图 移除书签 传送 - 已选择 离开频道 可通过 Nostr 联系 收藏(离线) @@ -280,7 +276,6 @@ bitchat 不会跟踪你的位置 @ · ⧉ - 成员 附近无人… (你) 拖动/缩放以选择 geohash diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e20a4b49..594d6879 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -73,7 +73,6 @@ back - channel: %1$s leave Nostr reachable Unread private messages @@ -131,15 +130,10 @@ Settings - Offline Communication - Chat over a Bluetooth mesh with no internet and no servers. Messages relay through nearby devices to extend range. - Local Channels - Geohash channels connect you with people nearby, extending the mesh over public relays. No Tracking No servers, accounts, or data collection. Ephemeral Identity A new peer ID is generated regularly. - Panic Mode Triple-tap the logo to instantly clear all data. @@ -153,13 +147,12 @@ Connected (%1$d%%) Disconnected - Offline Mesh Chat - Communicate directly via Bluetooth LE without internet or servers. Messages relay through nearby devices to extend range. - Online Geohash Channels - Connect with people in your area using geohash-based channels. Extend the mesh using public internet relays. + Offline Communication + Chat over a Bluetooth mesh with no internet and no servers. Messages relay through nearby devices to extend range. + Local Channels + Geohash channels connect you with people nearby, extending the mesh over public relays. End-to-End Encryption Private messages are encrypted. Channel messages are public. - appearance system light dark @@ -182,8 +175,7 @@ route internet over tor for enhanced privacy. tor Status: %1$s, bootstrap %2$d%% Last: %1$s - Emergency Data Deletion - Tip: Triple-click the app title to emergency delete all stored data including messages, keys, and settings. + Panic Mode Debug Settings Open Source • Privacy First • Decentralized Close @@ -204,7 +196,6 @@ Open map Remove bookmark Teleport - Selected Quit bitchat run in background keep mesh active when app is closed (foreground service) @@ -260,19 +251,15 @@ chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. do not screenshot or share this screen to protect your privacy. - Bookmarked Nearby Tor Routing Hides your IP for location channels. Recommended - Remove location access People (%1$d) On location Teleported in - Channels - Mesh grant location permission location permission denied. enable in settings to use location channels. @@ -284,7 +271,7 @@ geohash invalid geohash teleport - disable location services + Remove location access enable location services mesh block @@ -433,7 +420,6 @@ @ Open About · ⧉ - PEOPLE nobody around... (you) pan and zoom to select a geohash diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index 44072412..e35e37f1 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -12,7 +12,14 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +/** + * Runs under Robolectric because URL detection in message bodies goes through + * `android.util.Patterns.WEB_URL`, which is null on a bare JVM. + */ +@RunWith(RobolectricTestRunner::class) class ChatUIUtilsTest { private val timeFormatter = SimpleDateFormat("HH:mm:ss", Locale.ROOT).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") @@ -226,7 +233,7 @@ class ChatUIUtilsTest { val sender = formatTextMessageSender( message = message("hi", sender = "carol#04af"), currentUserNickname = "bob", - meshService = FakeMeshServiceIds.stub(), + myPeerID = "peer-me", palette = palette, ) @@ -246,7 +253,7 @@ class ChatUIUtilsTest { val other = formatTextMessageSender( message = message("hi", sender = "carol#04af"), currentUserNickname = "bob", - meshService = FakeMeshServiceIds.stub(), + myPeerID = "peer-me", palette = palette, ) assertEquals(1, other.getStringAnnotations("nickname_click", 0, other.length).size) @@ -254,7 +261,7 @@ class ChatUIUtilsTest { val mine = formatTextMessageSender( message = message("hi", sender = "bob"), currentUserNickname = "bob", - meshService = FakeMeshServiceIds.stub(), + myPeerID = "peer-me", palette = palette, ) assertTrue(mine.getStringAnnotations("nickname_click", 0, mine.length).isEmpty()) diff --git a/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt new file mode 100644 index 00000000..4ba8ffa0 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt @@ -0,0 +1,152 @@ +package com.bitchat.android.ui + +import androidx.compose.ui.unit.dp +import com.bitchat.android.model.BitchatMessage +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Grouping decides whether a message repeats its author's name. Getting it wrong either spams + * the transcript with redundant labels or, worse, silently attributes one person's message to + * another — hence the emphasis on the negative cases here. + */ +class MessageGroupingTest { + + private val base = 1_700_000_000_000L + + private fun message( + sender: String = "alice", + senderPeerId: String? = "peer-alice", + offsetMs: Long = 0L, + isPrivate: Boolean = false, + channel: String? = null, + content: String = "hello", + ) = BitchatMessage( + sender = sender, + content = content, + timestamp = Date(base + offsetMs), + isPrivate = isPrivate, + senderPeerID = senderPeerId, + channel = channel, + ) + + @Test + fun `first message in a list never groups`() { + assertFalse(MessageGrouping.shouldGroup(previous = null, current = message())) + } + + @Test + fun `consecutive messages from the same peer group`() { + val first = message(offsetMs = 0) + val second = message(offsetMs = 30_000) + + assertTrue(MessageGrouping.shouldGroup(first, second)) + } + + @Test + fun `messages from different peers do not group`() { + val first = message(sender = "alice", senderPeerId = "peer-alice") + val second = message(sender = "bob", senderPeerId = "peer-bob", offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(first, second)) + } + + @Test + fun `same nickname but different peer id does not group`() { + // Two people can pick the same nickname; peer ID is the authority. + val first = message(sender = "alice", senderPeerId = "peer-one") + val second = message(sender = "alice", senderPeerId = "peer-two", offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(first, second)) + } + + @Test + fun `falls back to display name when peer ids are unavailable`() { + val first = message(sender = "alice#04af", senderPeerId = null) + val second = message(sender = "alice#04af", senderPeerId = null, offsetMs = 1_000) + val other = message(sender = "bob#1122", senderPeerId = null, offsetMs = 2_000) + + assertTrue(MessageGrouping.shouldGroup(first, second)) + assertFalse(MessageGrouping.shouldGroup(second, other)) + } + + @Test + fun `messages outside the grouping window start a new group`() { + val first = message(offsetMs = 0) + val justInside = message(offsetMs = MessageGrouping.GROUPING_WINDOW_MS) + val justOutside = message(offsetMs = MessageGrouping.GROUPING_WINDOW_MS + 1) + + assertTrue(MessageGrouping.shouldGroup(first, justInside)) + assertFalse(MessageGrouping.shouldGroup(first, justOutside)) + } + + @Test + fun `out of order timestamps do not group`() { + // Mesh delivery can surface messages out of order; a negative delta means we cannot + // reason about adjacency, so fall back to showing the sender. + val later = message(offsetMs = 60_000) + val earlier = message(offsetMs = 0) + + assertFalse(MessageGrouping.shouldGroup(later, earlier)) + } + + @Test + fun `system messages never group in either direction`() { + val system = message(sender = "system", senderPeerId = null, content = "tor started") + val user = message(offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(system, user)) + assertFalse(MessageGrouping.shouldGroup(user, system.copyWithOffset(2_000))) + assertFalse(MessageGrouping.shouldGroup(system, system.copyWithOffset(1_000))) + } + + @Test + fun `grouping does not cross the public private boundary`() { + val public = message(isPrivate = false) + val private = message(isPrivate = true, offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(public, private)) + } + + @Test + fun `grouping does not cross channels`() { + val inGeneral = message(channel = "#general") + val inRandom = message(channel = "#random", offsetMs = 1_000) + val inNoChannel = message(channel = null, offsetMs = 2_000) + + assertFalse(MessageGrouping.shouldGroup(inGeneral, inRandom)) + assertFalse(MessageGrouping.shouldGroup(inGeneral, inNoChannel)) + } + + @Test + fun `peer id comparison ignores case`() { + val first = message(senderPeerId = "PEER-Alice") + val second = message(senderPeerId = "peer-alice", offsetMs = 1_000) + + assertTrue(MessageGrouping.shouldGroup(first, second)) + } + + // MARK: - Spacing + + @Test + fun `spacing is zero for the very first row`() { + assertEquals(0.dp, MessageGrouping.topSpacingFor(isGrouped = false, isFirstInList = true)) + assertEquals(0.dp, MessageGrouping.topSpacingFor(isGrouped = true, isFirstInList = true)) + } + + @Test + fun `grouped rows sit tighter than the start of a new group`() { + val grouped = MessageGrouping.topSpacingFor(isGrouped = true, isFirstInList = false) + val newGroup = MessageGrouping.topSpacingFor(isGrouped = false, isFirstInList = false) + + assertEquals(MessageGrouping.GROUPED_SPACING, grouped) + assertEquals(MessageGrouping.NEW_GROUP_SPACING, newGroup) + assertTrue("a new speaker needs more air than a continuation", newGroup > grouped) + } + + private fun BitchatMessage.copyWithOffset(offsetMs: Long) = + copy(timestamp = Date(base + offsetMs)) +} From 15d56715b9474ab568d8022f097ca4160e572154 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:51:19 +0200 Subject: [PATCH 03/70] cleanup --- .../ui/component/button/BitChatBrandButton.kt | 6 +- .../core/ui/component/button/CloseButton.kt | 23 ++- .../BackgroundLocationPermissionScreen.kt | 6 +- .../onboarding/BatteryOptimizationScreen.kt | 6 +- .../onboarding/BluetoothCheckScreen.kt | 4 +- .../android/onboarding/LocationCheckScreen.kt | 4 +- .../onboarding/PermissionExplanationScreen.kt | 8 +- .../com/bitchat/android/ui/AboutSections.kt | 56 +++--- .../java/com/bitchat/android/ui/AboutSheet.kt | 39 ++-- .../com/bitchat/android/ui/AnimatedCount.kt | 112 +++++++++++ .../java/com/bitchat/android/ui/ChatHeader.kt | 182 +++++++++-------- .../java/com/bitchat/android/ui/ChatScreen.kt | 5 +- .../bitchat/android/ui/GeohashPeopleList.kt | 16 +- .../android/ui/GeohashPickerActivity.kt | 8 +- .../bitchat/android/ui/GeohashViewModel.kt | 21 +- .../com/bitchat/android/ui/InputComponents.kt | 14 +- .../com/bitchat/android/ui/LinkPreviewPill.kt | 4 +- .../android/ui/LocationChannelsSheet.kt | 32 +-- .../bitchat/android/ui/LocationNotesButton.kt | 8 +- .../bitchat/android/ui/LocationNotesSheet.kt | 4 +- .../android/ui/MatrixEncryptionAnimation.kt | 183 ------------------ .../bitchat/android/ui/MeshPeerListSheet.kt | 103 ++++++---- .../bitchat/android/ui/MessageComponents.kt | 23 +-- .../bitchat/android/ui/PoWStatusIndicator.kt | 133 ------------- .../android/ui/SecurityVerificationSheet.kt | 14 +- .../bitchat/android/ui/VerificationSheet.kt | 4 +- .../android/ui/VoiceInputComponents.kt | 6 +- .../android/ui/debug/DebugSettingsSheet.kt | 16 +- .../com/bitchat/android/ui/debug/MeshGraph.kt | 6 +- .../android/ui/media/AudioMessageItem.kt | 4 +- .../android/ui/media/FileMessageItem.kt | 4 +- .../android/ui/media/FilePickerButton.kt | 4 +- .../android/ui/media/FileSendingAnimation.kt | 4 +- .../android/ui/media/FullScreenImageViewer.kt | 6 +- .../android/ui/media/ImageMessageItem.kt | 4 +- .../android/ui/media/ImagePickerButton.kt | 14 +- .../android/ui/media/MediaPickerOptions.kt | 6 +- .../android/ui/media/VoiceNotePlayer.kt | 6 +- .../android/ui/theme/BitchatPalette.kt | 9 +- app/src/main/res/values-ar/strings.xml | 1 - app/src/main/res/values-bn/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-fa/strings.xml | 1 - app/src/main/res/values-fil/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-hi/strings.xml | 1 - app/src/main/res/values-id/strings.xml | 1 - app/src/main/res/values-it/strings.xml | 1 - app/src/main/res/values-ja/strings.xml | 1 - app/src/main/res/values-ka/strings.xml | 1 - app/src/main/res/values-ko/strings.xml | 1 - app/src/main/res/values-mg/strings.xml | 1 - app/src/main/res/values-ne/strings.xml | 1 - app/src/main/res/values-nl/strings.xml | 1 - app/src/main/res/values-pa-rPK/strings.xml | 1 - app/src/main/res/values-pt-rBR/strings.xml | 1 - app/src/main/res/values-pt/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-sv/strings.xml | 1 - app/src/main/res/values-th/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-ur/strings.xml | 1 - app/src/main/res/values-vi/strings.xml | 1 - app/src/main/res/values-zh/strings.xml | 1 - app/src/main/res/values/strings.xml | 5 +- 66 files changed, 476 insertions(+), 662 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt delete mode 100644 app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt delete mode 100644 app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt index 15b2b40e..c1361364 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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 kotlinx.coroutines.Job @@ -30,6 +31,7 @@ fun BitChatBrandButton( contentDescription: String, modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.primary, + iconSize: Dp = 22.dp, ) { var tapCount by remember { mutableIntStateOf(0) } var resetJob by remember { mutableStateOf(null) } @@ -63,9 +65,7 @@ fun BitChatBrandButton( imageVector = BitChatIcon, contentDescription = contentDescription, tint = tint, - // 20.dp rather than 16.dp: the pixel-art bubble has heavy internal negative space, - // so it reads noticeably smaller than a solid glyph at the same nominal size. - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), ) } } diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt index 96f408bc..36ad7526 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt @@ -1,34 +1,37 @@ 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.res.stringResource import androidx.compose.ui.unit.dp +import com.bitchat.android.R +import com.bitchat.android.ui.theme.LocalBitchatPalette @Composable fun CloseButton( onClick: () -> Unit, - modifier: Modifier = Modifier.Companion + modifier: Modifier = Modifier ) { + val palette = LocalBitchatPalette.current 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 = palette.textSecondary, + containerColor = palette.surfaceVariant ) ) { Icon( imageVector = Icons.Default.Close, - contentDescription = "Close", - modifier = Modifier.Companion.size(18.dp) + contentDescription = stringResource(R.string.close_plain), + modifier = Modifier.size(18.dp) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt index 1346cb23..93101abf 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt @@ -1,5 +1,8 @@ package com.bitchat.android.onboarding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Security import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,9 +16,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.LocationOn -import androidx.compose.material.icons.filled.Security import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ColorScheme diff --git a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt index 3fe392d6..0b3036aa 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt @@ -1,13 +1,13 @@ package com.bitchat.android.onboarding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* import androidx.compose.animation.core.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment diff --git a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt index 60c6e5e4..933656bd 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt @@ -1,9 +1,9 @@ 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 diff --git a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt index c5bb8c9b..32c86c0b 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt @@ -1,10 +1,10 @@ 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 diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt index 48138d66..a9b61476 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -1,9 +1,5 @@ package com.bitchat.android.onboarding -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.LocationOn @@ -13,6 +9,10 @@ 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 diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index ae1b79a0..3df4fe5d 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -1,5 +1,17 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AlternateEmail +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material.icons.outlined.BookmarkBorder +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.animateColorAsState @@ -19,18 +31,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.AlternateEmail -import androidx.compose.material.icons.filled.DeleteForever -import androidx.compose.material.icons.filled.Groups -import androidx.compose.material.icons.filled.Shuffle -import androidx.compose.material.icons.filled.Terminal -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material.icons.filled.WifiOff -import androidx.compose.material.icons.outlined.BookmarkBorder -import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.Public -import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -70,10 +70,12 @@ internal val AboutHorizontalPadding = 20.dp /** Card corner radius for grouped rows. */ internal val AboutCardShape = RoundedCornerShape(16.dp) -/** Two top-level views of the sheet. */ +/** + * Two top-level views of the sheet: what the app is and how to drive it, versus the knobs. + */ enum class AboutTab { - HowToUse, - BasicInfo, + Info, + Settings, } /** @@ -175,7 +177,7 @@ internal fun AboutTabBar( var rowWidth by remember { mutableStateOf(0.dp) } val tabWidth = rowWidth / 2 val indicatorOffset by animateDpAsState( - targetValue = if (selected == AboutTab.HowToUse) 0.dp else tabWidth, + targetValue = if (selected == AboutTab.Info) 0.dp else tabWidth, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "aboutTabIndicator" ) @@ -190,15 +192,15 @@ internal fun AboutTabBar( ) { Row(modifier = Modifier.fillMaxWidth()) { AboutTabLabel( - text = stringResource(R.string.about_tab_how_to_use), - isSelected = selected == AboutTab.HowToUse, - onClick = { onSelect(AboutTab.HowToUse) }, + 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_basic_info), - isSelected = selected == AboutTab.BasicInfo, - onClick = { onSelect(AboutTab.BasicInfo) }, + text = stringResource(R.string.about_tab_settings), + isSelected = selected == AboutTab.Settings, + onClick = { onSelect(AboutTab.Settings) }, modifier = Modifier.weight(1f) ) } @@ -272,8 +274,8 @@ private fun AboutInstructionRow( contentDescription = null, tint = colorScheme.primary, modifier = Modifier - .padding(top = 2.dp) - .size(18.dp) + .padding(top = 1.dp) + .size(22.dp) ) Text( text = text, @@ -315,7 +317,7 @@ internal fun AboutHowToUseSection(modifier: Modifier = Modifier) { text = stringResource(R.string.about_howto_channels) ) AboutInstructionRow( - icon = Icons.Filled.Groups, + icon = Icons.Filled.Person, text = stringResource(R.string.about_howto_people) ) AboutInstructionRow( @@ -428,7 +430,7 @@ private fun AboutFeatureRow( tint = colorScheme.primary, modifier = Modifier .padding(top = 1.dp) - .size(22.dp) + .size(24.dp) ) Spacer(modifier = Modifier.width(16.dp)) Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { @@ -580,7 +582,7 @@ internal fun SheetHeaderBadge( imageVector = icon, contentDescription = null, tint = colorScheme.primary, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(26.dp) ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 2049b518..9b3d6836 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -1,5 +1,12 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Speed import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState @@ -10,13 +17,6 @@ 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.Lock -import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material.icons.filled.Security -import androidx.compose.material.icons.filled.Speed import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -246,7 +246,7 @@ fun AboutSheet( val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current - var selectedTab by remember { mutableStateOf(AboutTab.HowToUse) } + var selectedTab by remember { mutableStateOf(AboutTab.Info) } if (isPresented) { BitchatBottomSheet( @@ -273,21 +273,22 @@ fun AboutSheet( ) } - if (selectedTab == AboutTab.HowToUse) { + 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.BasicInfo) { - // Features Section - Grouped Card - item(key = "features") { - Column { - AboutSectionLabel(text = stringResource(R.string.about_section_about)) - AboutFeatureCard() - } - } - + if (selectedTab == AboutTab.Settings) { // Appearance Section item(key = "appearance") { Column { @@ -569,7 +570,7 @@ fun AboutSheet( item(key = "warning") { AboutWarningCard(modifier = Modifier.padding(top = 24.dp)) } - } // end BasicInfo tab + } // end Settings tab // Footer item(key = "footer") { diff --git a/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt b/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt new file mode 100644 index 00000000..41650b25 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt @@ -0,0 +1,112 @@ +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.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 that 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" + ) { _ -> + Text( + text = text, + style = style, + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + fontFamily = fontFamily, + maxLines = 1 + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index adda2378..6576a712 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -1,10 +1,20 @@ 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 android.util.Log +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState 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.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* @@ -14,10 +24,6 @@ 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.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 @@ -45,14 +51,29 @@ import com.bitchat.android.ui.theme.LocalBitchatPalette * Extracted from ChatScreen.kt for better organization */ -/** Height of the chat top bar. Taller than the old 42.dp so 40.dp tap targets fit properly. */ -val ChatHeaderHeight = 48.dp +/** Height of the chat top bar. Taller than the old 42.dp so 44.dp tap targets fit properly. */ +val ChatHeaderHeight = 52.dp -/** Standard icon size in the header. */ -private val HeaderIconSize = 18.dp +/** + * The single icon size used by every glyph in the top bar. + * + * Previously the bar mixed 12/14/16/18.dp icons, which is why it read as a collection of + * unrelated glyphs rather than one control strip. One size for everything, and large enough to + * actually see. + */ +internal val HeaderIconSize = 22.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 = 40.dp +private val HeaderTapTarget = 44.dp /** Corner radius for the header's tappable label+icon clusters. */ private val HeaderClusterShape = RoundedCornerShape(8.dp) @@ -81,6 +102,14 @@ private fun HeaderIconButton( } } +/** + * Connection status indicator. + * + * Only visible while the connection is still coming up (or has failed). A steady green "all + * good" light is noise: the absence of the indicator already means everything is fine, and the + * eye stops registering an always-on dot anyway. It fades and scales in/out so appearing and + * disappearing reads as a state change rather than a glitch. + */ @Composable fun TorStatusDot( modifier: Modifier = Modifier @@ -88,27 +117,35 @@ fun TorStatusDot( val palette = LocalBitchatPalette.current 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 targetColor = when { - torStatus.running && torStatus.bootstrapPercent < 100 -> palette.accentOrange - torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen - else -> palette.accentRed - } - // Cross-fade rather than snap: Tor flips through bootstrapping states frequently and a - // hard colour cut in the corner of the screen reads as a glitch. + + val isEnabled = torStatus.mode != com.bitchat.android.net.TorMode.OFF + val isEstablished = torStatus.running && torStatus.bootstrapPercent >= 100 + val isVisible = isEnabled && !isEstablished + + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + scaleIn( + initialScale = 0.4f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + scaleOut( + targetScale = 0.4f, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ), + ) { + // Bootstrapping vs. failed. Cross-faded, because Tor flips between these frequently and + // a hard colour cut in the corner of the screen reads as a rendering fault. val dotColor by animateColorAsState( - targetValue = targetColor, + targetValue = if (torStatus.running) palette.accentOrange else palette.accentRed, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "torDotColor" ) - Canvas( - modifier = modifier - ) { - val radius = size.minDimension / 2 + Canvas(modifier = modifier) { drawCircle( color = dotColor, - radius = radius, + radius = size.minDimension / 2, center = Offset(size.width / 2, size.height / 2) ) } @@ -179,15 +216,17 @@ fun NicknameEditor( Text( text = stringResource(R.string.at_symbol), style = MaterialTheme.typography.bodyMedium, + 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 = FontFamily.Monospace, + fontSize = HeaderTextSize ), cursorBrush = SolidColor(colorScheme.primary), singleLine = true, @@ -198,7 +237,7 @@ fun NicknameEditor( } ), modifier = Modifier - .widthIn(max = 140.dp) + .widthIn(max = 150.dp) .horizontalScroll(scrollState) ) } @@ -250,9 +289,9 @@ fun PeerCounter( .padding(horizontal = 6.dp) ) { Icon( - // `Groups` reads as a crowd at 18.dp, where `Group`'s two-person silhouette turns - // into an indistinct blob. - imageVector = Icons.Filled.Groups, + // A single silhouette rather than a crowd: at 22.dp a multi-person glyph collapses + // into an indistinct blob, and the number beside it already conveys "how many". + imageVector = Icons.Filled.Person, contentDescription = when (selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants) else -> stringResource(R.string.cd_connected_peers) @@ -261,17 +300,20 @@ fun PeerCounter( tint = animatedCountColor ) - Text( - text = "$peopleCount", + AnimatedCount( + count = peopleCount, style = MaterialTheme.typography.bodyMedium, + 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, + fontSize = HeaderTextSize, color = if (isConnected) palette.accentGreen else palette.accentRed, fontWeight = FontWeight.Medium ) @@ -343,7 +385,7 @@ private fun ChannelHeader( Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back), - modifier = Modifier.size(20.dp), + modifier = Modifier.size(HeaderIconSize), tint = colorScheme.primary ) } @@ -352,6 +394,7 @@ private fun ChannelHeader( Text( text = "#$channel", style = MaterialTheme.typography.titleMedium, + fontSize = HeaderTextSize, color = colorScheme.primary, modifier = Modifier .align(Alignment.Center) @@ -368,6 +411,7 @@ private fun ChannelHeader( Text( text = stringResource(R.string.chat_leave), style = MaterialTheme.typography.labelMedium, + fontSize = 15.sp, color = palette.accentRed ) } @@ -395,11 +439,6 @@ 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(), verticalAlignment = Alignment.CenterVertically @@ -423,6 +462,7 @@ private fun MainHeader( 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), @@ -435,12 +475,26 @@ private fun MainHeader( ) } - // MARK: - Status cluster. Order matches the design: bookmark, channel, people. + // MARK: - Status cluster. + // + // Order, left to right: connection state, unread DMs, notes, channel, people. + // The connection indicator leads because it is the only item that can invalidate + // everything to its right, and because it appears and disappears on its own — a fixed + // leftmost slot means the rest of the cluster never shifts when it does. Row( verticalAlignment = Alignment.CenterVertically, - // Tight, because every child below is its own >=40.dp tap target. + // Tight, because every child below is its own >=44.dp tap target. horizontalArrangement = Arrangement.spacedBy(2.dp) ) { + // Connection status. The Box holds the slot open at a constant width so the icons to + // its right stay put whether or not the dot is currently showing. + Box( + modifier = Modifier.size(HeaderIconSize), + contentAlignment = Alignment.Center + ) { + TorStatusDot(modifier = Modifier.size(8.dp)) + } + // Unread private messages badge (click to open most recent DM) if (hasUnreadPrivateMessages.isNotEmpty()) { HeaderIconButton( @@ -462,37 +516,9 @@ private fun MainHeader( onClick = onLocationNotesClick ) - // Tor status dot. Wrapped in a fixed box so its optical position no longer depends - // on ad-hoc asymmetric padding. - Box(modifier = Modifier.size(16.dp), contentAlignment = Alignment.Center) { - TorStatusDot(modifier = Modifier.size(6.dp)) - } - - // PoW status indicator - PoWStatusIndicator( - modifier = Modifier, - style = PoWIndicatorStyle.COMPACT - ) - - // 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) - HeaderIconButton( - onClick = { bookmarksStore.toggle(currentGeohash) }, - contentDescription = stringResource(R.string.cd_toggle_bookmark) - ) { - Icon( - imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, - contentDescription = stringResource(R.string.cd_toggle_bookmark), - tint = if (isBookmarked) palette.accentGreen else palette.textSecondary, - modifier = Modifier.size(HeaderIconSize) - ) - } - } + // Bookmarking lives in the Location Channels sheet, one tap away via the channel + // button below. Duplicating it here bought a shortcut for a rare action at the cost + // of a slot in the app's most crowded row. LocationChannelsButton( viewModel = viewModel, @@ -532,8 +558,11 @@ private fun LocationChannelsButton( 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}" - else -> "#mesh" + // The local mesh is not a hashtag channel, and the Bluetooth glyph already says what it + // is, so it is plain "mesh". + else -> stringResource(R.string.mesh_label) } val badgeColor = if (isLocation) palette.accentGreen else palette.accentBlue val badgeIcon = if (isLocation) Icons.Outlined.Public else Icons.Filled.Bluetooth @@ -557,6 +586,7 @@ private fun LocationChannelsButton( Text( text = badgeText, style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, fontWeight = FontWeight.Medium, color = badgeColor, maxLines = 1 @@ -567,7 +597,7 @@ private fun LocationChannelsButton( Icon( imageVector = Icons.Default.PinDrop, contentDescription = stringResource(R.string.cd_teleported), - modifier = Modifier.size(12.dp), + modifier = Modifier.size(HeaderIconSize), tint = badgeColor ) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 340f180c..6f4ff8a3 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -4,6 +4,8 @@ package com.bitchat.android.ui // [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 @@ -14,8 +16,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.Alignment -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 @@ -303,6 +303,7 @@ fun ChatScreen(viewModel: ChatViewModel) { Icon( imageVector = Icons.Filled.ArrowDownward, contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom), + modifier = Modifier.size(22.dp), tint = palette.accentGreen ) } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 1eccc152..cb3cc476 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -1,13 +1,13 @@ package com.bitchat.android.ui -import android.util.Log -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape 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 android.util.Log +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -193,7 +193,7 @@ private fun GeohashPersonItem( Icon( imageVector = Icons.Filled.Email, contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(16.dp), + modifier = Modifier.size(18.dp), tint = palette.accentOrange ) } else { @@ -205,7 +205,7 @@ private fun GeohashPersonItem( else -> "face.smiling" to palette.textSecondary } - // Use appropriate Material icon (closest match to iOS SF Symbols) + // Teleported users get the pin (they chose this location); locals get the person. val icon = when (iconName) { "face.dashed" -> Icons.Outlined.Explore else -> Icons.Outlined.LocationOn @@ -214,8 +214,8 @@ private fun GeohashPersonItem( Icon( imageVector = icon, contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User", - modifier = Modifier.size(16.dp), - tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) // Make dashed faces slightly transparent + modifier = Modifier.size(18.dp), + tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt index e15f9930..0f2f53bb 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -1,5 +1,9 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Remove import android.annotation.SuppressLint import android.app.Activity import android.content.Intent @@ -15,10 +19,6 @@ import android.webkit.WebViewClient import androidx.activity.compose.setContent import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 0504aa11..4cae30dd 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -210,22 +210,11 @@ 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 = 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) } catch (e: Exception) { Log.e(TAG, "Failed to send geohash message: ${e.message}") } diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 3fac4761..938729ce 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -2,6 +2,8 @@ package com.bitchat.android.ui // [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween @@ -12,8 +14,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -187,10 +187,10 @@ private val ComposerMinHeight = 44.dp private val ComposerShape = RoundedCornerShape(22.dp) /** Diameter of the circular send affordance nested inside the pill. */ -private val SendButtonSize = 32.dp +private val SendButtonSize = 36.dp -/** Tap target for the media affordances sitting outside the pill. */ -private val MediaButtonSize = 40.dp +/** Icon size shared by the composer's glyphs, matching the top bar. */ +internal val ComposerIconSize = 22.dp @Composable fun MessageInput( @@ -325,7 +325,7 @@ fun MessageInput( hasText = hasText, isAccented = selectedPrivatePeer != null || currentChannel != null, onSend = onSend, - modifier = Modifier.padding(end = 6.dp, bottom = 6.dp) + modifier = Modifier.padding(end = 4.dp, bottom = 4.dp) ) } @@ -430,7 +430,7 @@ private fun SendButton( Icon( imageVector = Icons.Filled.ArrowUpward, contentDescription = stringResource(id = R.string.send_message), - modifier = Modifier.size(18.dp), + modifier = Modifier.size(ComposerIconSize), tint = tint ) } diff --git a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt index 4896e345..cc0d3026 100644 --- a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt +++ b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Link import android.content.Intent import android.net.Uri import androidx.compose.foundation.background @@ -7,8 +9,6 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Link import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 98e1fb40..3f3e59cf 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -1,5 +1,12 @@ package com.bitchat.android.ui +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.material.icons.outlined.Public import android.content.Intent import android.net.Uri import android.provider.Settings @@ -15,13 +22,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Map -import androidx.compose.material.icons.filled.PinDrop -import androidx.compose.material.icons.outlined.BookmarkBorder -import androidx.compose.material.icons.outlined.Public import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -218,6 +218,7 @@ fun LocationChannelsSheet( title = meshTitleWithCount(viewModel), subtitle = stringResource(R.string.location_bluetooth_subtitle, bluetoothRangeString()), isSelected = selectedChannel is ChannelID.Mesh, + participantCount = meshCount(viewModel), titleColor = standardBlue, titleBold = meshCount(viewModel) > 0, trailingContent = null, @@ -251,6 +252,7 @@ fun LocationChannelsSheet( title = geohashTitleWithCount(channel, participantCount), subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""), isSelected = isChannelSelected(channel, selectedChannel), + participantCount = participantCount, titleColor = standardGreen, titleBold = highlight, trailingContent = { @@ -259,7 +261,7 @@ fun LocationChannelsSheet( 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 = if (isBookmarked) palette.accentGreen else palette.textSecondary, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(22.dp) ) } }, @@ -314,6 +316,7 @@ fun LocationChannelsSheet( title = title, subtitle = subtitle, isSelected = isChannelSelected(channel, selectedChannel), + participantCount = participantCount, titleColor = null, titleBold = participantCount > 0, trailingContent = { @@ -322,7 +325,7 @@ fun LocationChannelsSheet( imageVector = Icons.Filled.Bookmark, contentDescription = stringResource(R.string.cd_remove_bookmark), tint = palette.accentGreen, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(22.dp) ) } }, @@ -432,7 +435,7 @@ fun LocationChannelsSheet( imageVector = Icons.Filled.Map, contentDescription = stringResource(R.string.cd_open_map), tint = palette.textSecondary, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(22.dp) ) } @@ -468,7 +471,7 @@ fun LocationChannelsSheet( Icon( imageVector = Icons.Filled.PinDrop, contentDescription = stringResource(R.string.cd_teleport), - modifier = Modifier.size(12.dp), + modifier = Modifier.size(14.dp), tint = teleportColor ) Text( @@ -641,6 +644,8 @@ private fun ChannelRow( title: String, subtitle: String, isSelected: Boolean, + /** Drives the count animation; the number itself is already baked into [title]. */ + participantCount: Int, titleColor: Color? = null, titleBold: Boolean = false, trailingContent: (@Composable (() -> Unit))? = null, @@ -701,7 +706,10 @@ private fun ChannelRow( ) countSuffix?.let { count -> - Text( + AnimatedCountLabel( + // Participant counts are polled from the relays, so they change + // while the sheet is open. + count = participantCount, text = count, fontSize = 11.sp, fontFamily = FontFamily.Monospace, diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index af1198bc..1ec6417e 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -1,8 +1,8 @@ package com.bitchat.android.ui -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Description +import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -55,12 +55,12 @@ fun LocationNotesButton( val hasNotes = notesCount > 0 IconButton( onClick = onClick, - modifier = modifier.size(40.dp) + modifier = modifier.size(44.dp) ) { Icon( - imageVector = Icons.Outlined.Description, // "long.text.page.and.pencil" equivalent + imageVector = Icons.Outlined.Description, contentDescription = stringResource(R.string.cd_location_notes), - modifier = Modifier.size(18.dp), + modifier = Modifier.size(HeaderIconSize), tint = if (hasNotes) colorScheme.primary else LocalBitchatPalette.current.textSecondary ) } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index 29ead6a6..4543d2af 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -1,5 +1,7 @@ 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 @@ -9,8 +11,6 @@ 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 diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt deleted file mode 100644 index 52632f93..00000000 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ /dev/null @@ -1,183 +0,0 @@ -package com.bitchat.android.ui - -import androidx.compose.material3.ColorScheme -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bitchat.android.mesh.MeshService -import com.bitchat.android.model.BitchatMessage -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import kotlin.random.Random - -/** - * Animation state for individual characters - */ -private enum class CharacterAnimationState { - ENCRYPTED, // Showing random encrypted characters - FINAL // Showing final decrypted character -} - -/** - * Check if a message should be animated based on its mining state - */ -@Composable -fun shouldAnimateMessage(messageId: String): Boolean { - val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle() - return miningMessages.contains(messageId) -} - -/** - * Tracks which messages are currently being mined for PoW - * Provides reactive state for UI animations - */ -object PoWMiningTracker { - private val _miningMessages = MutableStateFlow>(emptySet()) - val miningMessages: StateFlow> = _miningMessages.asStateFlow() - - /** - * Start tracking a message as mining - */ - fun startMiningMessage(messageId: String) { - _miningMessages.value = _miningMessages.value + messageId - } - - /** - * Stop tracking a message as mining - */ - fun stopMiningMessage(messageId: String) { - _miningMessages.value = _miningMessages.value - messageId - } - - /** - * Check if a message is currently mining - */ - fun isMiningMessage(messageId: String): Boolean { - return _miningMessages.value.contains(messageId) - } - - /** - * Clear all mining messages (for cleanup) - */ - fun clearAllMining() { - _miningMessages.value = emptySet() - } -} - -/** - * Shows the active PoW animation inside the same two-row layout used by static text messages. - */ -@Composable -fun MessageWithMatrixAnimation( - message: BitchatMessage, - currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme, - timeFormatter: SimpleDateFormat, - onNicknameClick: ((String) -> Unit)?, - onMessageLongPress: ((BitchatMessage) -> Unit)?, - modifier: Modifier = Modifier, - showSender: Boolean = true, -) { - AnimatedMessageDisplay( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress, - modifier = modifier, - showSender = showSender, - ) -} - -/** - * 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, - showSender: Boolean = true, -) { - 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, - showSender = showSender, - bodyContent = animatedContent, - ) -} diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index a6c0d63d..5aae35a3 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -1,19 +1,25 @@ 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.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.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Groups -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 @@ -37,6 +43,7 @@ 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.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry @@ -116,8 +123,9 @@ fun MeshPeerListSheet( .padding(horizontal = SheetHorizontalPadding + 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { - SheetHeaderBadge(icon = Icons.Filled.Groups) - Text( + SheetHeaderBadge(icon = Icons.Filled.Person) + AnimatedCountLabel( + count = peopleCount, text = stringResource(R.string.people_count_title, peopleCount), fontSize = 20.sp, fontFamily = FontFamily.Monospace, @@ -212,13 +220,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 = LocalBitchatPalette.current.textSecondary, + modifier = Modifier.size(22.dp) ) } } @@ -231,6 +239,9 @@ fun MeshPeerListSheet( } } +/** Icon size for rows inside the sheets: one step down from the top bar. */ +private val PeerRowIconSize = 18.dp + @Composable private fun ChannelRow( channel: String, @@ -580,28 +591,26 @@ private fun PeerItem( ) { // 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 + modifier = Modifier.size(PeerRowIconSize), + tint = palette.accentOrange ) } 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 + modifier = Modifier.size(PeerRowIconSize), + tint = palette.accentPurple ) } else if (!isDirect && isFavorite) { - // Offline favorited user: show outlined circle icon + // Offline favourite: routed glyph, dimmed to read as unavailable. Icon( imageVector = Icons.Outlined.Circle, contentDescription = stringResource(R.string.cd_offline_favorite), - modifier = Modifier.size(16.dp), - tint = Color.Gray + modifier = Modifier.size(PeerRowIconSize), + tint = palette.textTertiary ) } else { Icon( @@ -615,8 +624,8 @@ private fun PeerItem( isDirect -> "Direct Bluetooth" else -> "Routed" }, - modifier = Modifier.size(16.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) + modifier = Modifier.size(PeerRowIconSize), + tint = palette.textSecondary ) } @@ -649,8 +658,8 @@ private fun PeerItem( Icon( imageVector = Icons.Filled.Wifi, contentDescription = "Direct Wi-Fi Aware", - modifier = Modifier.size(13.dp), - tint = colorScheme.onSurface.copy(alpha = 0.8f) + modifier = Modifier.size(PeerRowIconSize), + tint = palette.textSecondary ) } } @@ -660,9 +669,9 @@ private fun PeerItem( Spacer(modifier = Modifier.width(4.dp)) Icon( imageVector = Icons.Filled.Verified, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = Color(0xFF32D74B) // iOS Green + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(PeerRowIconSize), + tint = palette.accentGreen ) } @@ -673,13 +682,13 @@ private fun PeerItem( // Favorite star with proper filled/outlined states IconButton( onClick = onToggleFavorite, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(44.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) + modifier = Modifier.size(PeerRowIconSize), + tint = if (isFavorite) palette.accentOrange else palette.textTertiary ) } } @@ -696,25 +705,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 = FontFamily.Monospace, + color = Color.Black ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 7457c681..83db678a 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -1,6 +1,8 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -17,8 +19,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState 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 @@ -393,24 +393,7 @@ 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, - showSender = showSender, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress, - modifier = modifier - ) - } else if (message.sender == "system") { + if (message.sender == "system") { // Background narration: `// Tor started. Routing all chats…` val annotatedText = remember(message, palette) { formatSystemMessage( diff --git a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt b/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt deleted file mode 100644 index 883b2b45..00000000 --- a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.bitchat.android.ui - -import androidx.compose.animation.core.* -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Security -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.bitchat.android.nostr.NostrProofOfWork -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bitchat.android.R -import com.bitchat.android.nostr.PoWPreferenceManager - -/** - * Shows the current Proof of Work status and settings - */ -@Composable -fun PoWStatusIndicator( - modifier: Modifier = Modifier, - style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT -) { - val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle() - val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle() - val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle() - val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - - if (!powEnabled) return - - when (style) { - PoWIndicatorStyle.COMPACT -> { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // PoW icon with animation if mining - if (isMining) { - val rotation by rememberInfiniteTransition(label = "pow-rotation").animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "pow-icon-rotation" - ) - - Icon( - imageVector = Icons.Filled.Security, - contentDescription = stringResource(R.string.cd_mining_pow), - tint = Color(0xFFFF9500), // Orange for mining - modifier = Modifier - .size(12.dp) - .graphicsLayer { rotationZ = rotation } - ) - } else { - Icon( - imageVector = Icons.Filled.Security, - contentDescription = stringResource(R.string.cd_pow_enabled), - tint = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), // Green when ready - modifier = Modifier.size(12.dp) - ) - } - } - } - - PoWIndicatorStyle.DETAILED -> { - Surface( - modifier = modifier, - color = colorScheme.surfaceVariant.copy(alpha = 0.3f), - shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // PoW icon - Icon( - imageVector = Icons.Filled.Security, - contentDescription = stringResource(R.string.cd_proof_of_work), - tint = if (isMining) Color(0xFFFF9500) else { - if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - }, - modifier = Modifier.size(14.dp) - ) - - // Status text - Text( - text = if (isMining) { - stringResource(R.string.pow_mining_ellipsis) - } else { - stringResource(R.string.pow_label_format, powDifficulty) - }, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = if (isMining) Color(0xFFFF9500) else { - colorScheme.onSurface.copy(alpha = 0.7f) - } - ) - - // Time estimate - if (!isMining && powDifficulty > 0) { - Text( - text = stringResource(R.string.pow_time_estimate, NostrProofOfWork.estimateMiningTime(powDifficulty)), - fontSize = 9.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f) - ) - } - } - } - } - } -} - -/** - * Style options for the PoW status indicator - */ -enum class PoWIndicatorStyle { - COMPACT, // Small icon + difficulty number - DETAILED // Icon + status text + time estimate -} diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index 2633f888..5549f43f 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -1,5 +1,12 @@ 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 @@ -12,13 +19,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 diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index 97392eae..a81938d5 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.QrCodeScanner import android.graphics.Bitmap import android.os.Handler import android.os.Looper @@ -31,8 +33,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 diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index 1c005b46..cdc40583 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -1,13 +1,13 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic 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 androidx.compose.material3.Icon import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -132,7 +132,7 @@ fun VoiceRecordButton( imageVector = Icons.Filled.Mic, contentDescription = stringResource(com.bitchat.android.R.string.cd_record_voice), tint = Color.Black, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(22.dp) ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index be950316..8d38c678 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -1,5 +1,13 @@ package com.bitchat.android.ui.debug +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.* @@ -9,14 +17,6 @@ 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 diff --git a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt index 44c7ef55..3254c565 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt @@ -1,14 +1,14 @@ package com.bitchat.android.ui.debug +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.Bluetooth import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material.icons.outlined.Bluetooth import androidx.compose.material3.Icon import androidx.compose.runtime.* import androidx.compose.ui.Modifier diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index dc52f845..8722ab29 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -1,11 +1,11 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt index ca57931b..7a289686 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -12,8 +14,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Description import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon diff --git a/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt index 16d92d7a..bd5f30e0 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt @@ -1,11 +1,11 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Attachment import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Attachment import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt b/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt index 2f40dec0..2ec7cd9a 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -11,8 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Description import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt index 2964f9c0..bebc1293 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt @@ -1,5 +1,8 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Download import android.content.ContentValues import android.os.Build import android.provider.MediaStore @@ -10,9 +13,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Download import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 01d119fe..a0961cfc 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -1,12 +1,12 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.* diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt index 172ad50d..df6f8ea7 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt @@ -1,5 +1,9 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.Photo +import androidx.compose.material.icons.filled.PhotoCamera import android.Manifest import android.content.pm.PackageManager import android.net.Uri @@ -10,10 +14,6 @@ import androidx.compose.foundation.combinedClickable 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.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 @@ -91,8 +91,8 @@ fun ImagePickerButton( Box( modifier = modifier - // 40.dp to match the header's tap targets and the composer's send button. - .size(40.dp) + // Matches the header's tap targets and the composer's send button. + .size(44.dp) .clip(CircleShape) .combinedClickable( onClick = { imagePicker.launch("image/*") }, @@ -110,7 +110,7 @@ fun ImagePickerButton( imageVector = Icons.Filled.PhotoCamera, contentDescription = stringResource(com.bitchat.android.R.string.pick_image), tint = LocalBitchatPalette.current.textSecondary, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(22.dp) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt b/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt index 47883499..6909a52d 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt @@ -1,5 +1,8 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Description import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -9,9 +12,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Description import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt index 67719d87..5341ec1f 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt @@ -1,13 +1,13 @@ package com.bitchat.android.ui.media +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 diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt index bd4280eb..bc5e7b03 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -22,7 +22,7 @@ data class BitchatPalette( val isDark: Boolean, // MARK: - Surfaces - /** Screen, header and composer background. Slightly green-tinted near-black. */ + /** Screen, header and composer background. True black in dark mode. */ val background: Color, /** Sheets, cards and list rows: one step above [background]. */ val surface: Color, @@ -56,7 +56,10 @@ data class BitchatPalette( val DarkBitchatPalette = BitchatPalette( isDark = true, - background = Color(0xFF070D07), + // True black. OLED panels switch these pixels off entirely, which is both the terminal + // aesthetic the app is going for and a real battery win on a screen that is mostly + // background. + background = Color(0xFF000000), surface = Color(0xFF0E150E), surfaceVariant = Color(0xFF182118), outline = Color(0xFF2A3A2A), @@ -73,7 +76,7 @@ val DarkBitchatPalette = BitchatPalette( val LightBitchatPalette = BitchatPalette( isDark = false, - background = Color(0xFFFBFDFB), + background = Color(0xFFFFFFFF), surface = Color(0xFFF2F6F2), surfaceVariant = Color(0xFFE7EDE7), outline = Color(0xFFCBD6CB), diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 5ad12ff2..00892344 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -75,7 +75,6 @@ مغادرة يمكن الوصول عبر Nostr رسائل خاصة غير مقروءة - تبديل الإشارة المرجعية تم الانتقال حالة Tor الأقران المتصلون diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 76c13dd2..45750704 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -75,7 +75,6 @@ ছেড়ে দিন Nostr এর মাধ্যমে পৌঁছানো যায় অপঠিত ব্যক্তিগত বার্তা - বুকমার্ক টগল করুন টেলিপোর্ট করা হয়েছে Tor অবস্থা সংযুক্ত পিয়ার diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 23168837..dd2675fe 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -75,7 +75,6 @@ verlassen Über Nostr erreichbar Ungelesene private Nachrichten - Lesezeichen umschalten Teleportiert Tor‑Status Verbundenen Peers diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 836f563a..76871eeb 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -75,7 +75,6 @@ salir Accesible vía Nostr Mensajes privados sin leer - Alternar marcador Teletransportado Estado de Tor Pares conectados diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 551ca8db..0bd3529f 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -75,7 +75,6 @@ خروج قابل دسترس از طریق Nostr پیام‌های خصوصی خوانده‌نشده - تغییر وضعیت نشانک انتقال مکانی وضعیت Tor همتایان متصل diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 526cc636..288068bc 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -75,7 +75,6 @@ umalis Maabot sa Nostr Hindi nabasang pribadong mensahe - I‑toggle ang bookmark Naiteleport Katayuan ng Tor Nakakonektang peers diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8da0188f..4d2a0d52 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -75,7 +75,6 @@ quitter Joignable via Nostr Messages privés non lus - Basculer le signet Téléporté Statut Tor Pairs connectés diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 6921ace7..5074fa44 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -75,7 +75,6 @@ छोड़ें Nostr के माध्यम से उपलब्ध अपठित निजी संदेश - बुकमार्क टॉगल करें टेलीपोर्ट हुआ Tor स्थिति कनेक्टेड पीयर्स diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index c5eb612c..cae8b84a 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -75,7 +75,6 @@ keluar Dapat dijangkau melalui Nostr Pesan pribadi yang belum dibaca - Alihkan bookmark Diteleportasi Status Tor Peer yang terhubung diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8c157300..78cc8b84 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -75,7 +75,6 @@ esci Raggiungibile via Nostr Messaggi privati non letti - Attiva/disattiva segnalibro Teletrasportato Stato Tor Peer connessi diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index b272e51f..c5b7a5c6 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -75,7 +75,6 @@ 退出 Nostr 経由で到達可能 未読のプライベートメッセージ - ブックマークを切り替え テレポート済み Tor ステータス 接続中のピア diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 65f27848..2c682575 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -75,7 +75,6 @@ გასვლა მისაწვდომია Nostr-ით წაუკითხავი პირადი შეტყობინებები - სანიშნეს გადართვა ტელეპორტირებული Tor-ის სტატუსი დაკავშირებული პირები diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ee2838b6..4cda1af6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -75,7 +75,6 @@ 나가기 Nostr로 연결 가능 읽지 않은 개인 메시지 - 북마크 전환 텔레포트됨 Tor 상태 연결된 피어 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index ffec5957..6cff0fe3 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -75,7 +75,6 @@ hiala Azo tratrarina amin\'ny alalan\'ny Nostr Hafatra manokana tsy voavaky - Hanova bookmark Nafindra toerana Toe-javatra Tor Mpiara-miasa mifandray diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 7c2bbf26..f80354db 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -75,7 +75,6 @@ छोड्नुहोस् Nostr मार्फत पुग्न सकिने नपढिएका निजी सन्देश - बुकमार्क टगल टेलिपोर्ट भयो Tor स्थिति जडान भएका साथीहरू diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index d9de3a6d..7ce6dbc9 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -75,7 +75,6 @@ verlaten Bereikbaar via Nostr Ongelezen privéberichten - Bladwijzer omschakelen Geteleporteerd Tor-status Verbonden peers diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index f7423ce5..46024ab5 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -75,7 +75,6 @@ باہر آؤ Nostr راہین پہنچ ممکن اے انہ پڑھیا ذاتی پیغام - بُک مارک ٹوگل کرو ٹیلپورٹ کیتا گیا Tor حالت جُڑے پیئر diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index a350d3f7..96043e4f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -75,7 +75,6 @@ sair Acessível via Nostr Mensagens privadas não lidas - Alternar favorito Teletransportado Status do Tor Peers conectados diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 9751b733..184e4b2a 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -75,7 +75,6 @@ sair Alcançável via Nostr Mensagens privadas não lidas - Alternar marcador Teletransportado Estado do Tor Pares ligados diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5672cd00..4a60c472 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -67,7 +67,6 @@ выйти Доступен через Nostr Непрочитанные личные - Переключить закладку Телепортировано Статус Tor Подключённые узлы diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index a6df7a8e..9eabab8a 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -67,7 +67,6 @@ lämna Nås via Nostr Olästa privata meddelanden - Växla bokmärke Teleporterad Tor‑status Anslutna noder diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index af9111fa..ddb21b5e 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -75,7 +75,6 @@ ออก ติดต่อได้ผ่าน Nostr ข้อความส่วนตัวยังไม่ได้อ่าน - สลับที่คั่นหน้า เทเลพอร์ตแล้ว สถานะ Tor เพียร์ที่เชื่อมต่อ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 721b50c1..7d64afdf 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -67,7 +67,6 @@ ayrıl Nostr üzerinden ulaşılabilir Okunmamış özel mesaj - Yer imini değiştir Teleport edildi Tor durumu Bağlı eşler diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 0f11e124..60fc378f 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -75,7 +75,6 @@ چھوڑیں Nostr کے ذریعے رسائی غیر پڑھے ہوئے نجی پیغامات - بک مارک تبدیل کریں ٹیلی پورٹ Tor کی کیفیت منسلک پیئرز diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 083a930a..7858dd49 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -75,7 +75,6 @@ rời khỏi Có thể tiếp cận qua Nostr Tin nhắn riêng chưa đọc - Bật/tắt bookmark Đã dịch chuyển Trạng thái Tor Peers đã kết nối diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 75d92b58..21ce5d51 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -75,7 +75,6 @@ 离开 可通过 Nostr 联系 未读私信 - 切换书签 已传送 Tor 状态 已连接节点 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 594d6879..0818a9f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,7 +76,6 @@ leave Nostr reachable Unread private messages - Toggle bookmark Location notes Teleported Tor status @@ -111,8 +110,8 @@ The sidegroup chat - How to use - Basic info + Info + Settings How To Use From fc1c23314bd3cd758e7204ee2dc728a3ecb2f67c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:57:22 +0200 Subject: [PATCH 04/70] capitalization --- .../bitchat/android/ui/MeshPeerListSheet.kt | 2 +- .../android/ui/debug/DebugSettingsSheet.kt | 12 +- app/src/main/res/values/strings.xml | 286 +++++++++--------- 3 files changed, 150 insertions(+), 150 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 5aae35a3..180b35c8 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -829,7 +829,7 @@ fun PrivateChatSheet( val name = if (fullPubkey.isNotEmpty()) { viewModel.geohashViewModel.displayNameForGeohashConversation(fullPubkey, gh) } else { - peerNicknames[peerID] ?: "unknown" + peerNicknames[peerID] ?: "Unknown" } "#$gh/@$name" } else { diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 8d38c678..4f4f53da 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -61,13 +61,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 = FontFamily.Monospace, 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 = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { ForceDirectedMeshGraph( nodes = nodes, @@ -88,7 +88,7 @@ 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, @@ -388,7 +388,7 @@ fun DebugSettingsSheet( } Row(verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.width(24.dp)) - Text("Wi‑Fi Aware verbose", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text("Wi‑Fi Aware verbose logging", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) Switch(checked = wifiAwareVerbose, onCheckedChange = { manager.setWifiAwareVerbose(it) }) } } @@ -719,7 +719,7 @@ 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 = FontFamily.Monospace, 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)) Slider(value = seenCapacity.toFloat(), onValueChange = { manager.setSeenPacketCapacity(it.toInt()) }, valueRange = 10f..1000f, steps = 99) @@ -745,7 +745,7 @@ fun DebugSettingsSheet( 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)) if (connectedDevices.isEmpty()) { - Text("none", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("None", fontFamily = FontFamily.Monospace, 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)) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0818a9f1..93579e5b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,8 +4,8 @@ Bluetooth permission is required for peer-to-peer messaging without internet. Location permission is required to discover nearby devices via Bluetooth. Notification permission is required to alert you of new messages. - nickname - type a message… + Nickname + Type a message… Password Join Channel Leave @@ -54,7 +54,7 @@ Messages in %1$s %1$s joined the conversation bitchat - %1$d mentions - bitchat - location chats + bitchat - Location chats %1$d messages from %2$d locations Mentioned in Mesh Chat %1$d mentions in Mesh Chat @@ -72,8 +72,8 @@ Add bookmark - back - leave + Back + Leave Nostr reachable Unread private messages Location notes @@ -152,34 +152,34 @@ Geohash channels connect you with people nearby, extending the mesh over public relays. End-to-End Encryption Private messages are encrypted. Channel messages are public. - system - light - dark - proof of work - pow off - pow on - add proof of work to geohash messages for spam deterrence. - difficulty: %1$d bits (~%2$s) - difficulty %1$d requires ~%2$s hash attempts - no proof of work required - very low - minimal spam protection - low - basic spam protection - medium - good spam protection - high - strong spam protection - very high - may cause delays - extreme - significant computation required - network - tor off - tor on - route internet over tor for enhanced privacy. - tor Status: %1$s, bootstrap %2$d%% + System + Light + Dark + Proof of Work + PoW Off + PoW On + Add proof of work to geohash messages for spam deterrence. + Difficulty: %1$d bits (~%2$s) + Difficulty %1$d requires ~%2$s hash attempts + No proof of work required + Very low – minimal spam protection + Low – basic spam protection + Medium – good spam protection + High – strong spam protection + Very high – may cause delays + Extreme – significant computation required + Network + Tor Off + Tor On + Route internet over Tor for enhanced privacy. + Tor Status: %1$s, bootstrap %2$d%% Last: %1$s Panic Mode Debug Settings Open Source • Privacy First • Decentralized Close Privacy Protected - cancel + Cancel Warning @@ -196,8 +196,8 @@ Remove bookmark Teleport Quit bitchat - run in background - keep mesh active when app is closed (foreground service) + Run in Background + Keep mesh active when app is closed (foreground service) Leave channel Reachable via Nostr Offline favorite @@ -214,8 +214,8 @@ Mining PoW PoW Enabled Proof of Work - mining... - pow: %1$dbit + Mining… + PoW: %1$dbit Offline Mesh Chat Online Geohash Channels End-to-End Encryption @@ -227,27 +227,27 @@ Failed to save image Pick file [file unavailable] - unknown + Unknown - choose an action for this message or user - choose an action for this user - copy message - copy this message to clipboard - slap %1$s - send a playful slap message - hug %1$s - send a friendly hug message - block %1$s - block all messages from this user - message %1$s - send a private message + Choose an action for this message or user + Choose an action for this user + Copy message + Copy this message to clipboard + Slap %1$s + Send a playful slap message + Hug %1$s + Send a friendly hug message + Block %1$s + Block all messages from this user + Message %1$s + Send a private message - #location channels + #Location Channels Location Channels - chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. do not screenshot or share this screen to protect your privacy. + Chat with people near you using geohash channels. Only a coarse geohash is shared, never exact GPS. Do not screenshot or share this screen to protect your privacy. Nearby @@ -260,113 +260,113 @@ On location Teleported in - grant location permission - location permission denied. enable in settings to use location channels. + Grant location permission + Location permission denied. Enable in settings to use location channels. - \u2713 location permission granted - checking permissions... - finding nearby channels… - bookmarked + \u2713 Location permission granted + Checking permissions… + Finding nearby channels… + Bookmarked geohash - invalid geohash - teleport + Invalid geohash + Teleport Remove location access - enable location services + Enable location services mesh - block - neighborhood - city - province - region + Block + Neighborhood + City + Province + Region #%1$s ± 1 • %2$d note #%1$s ± 1 • %2$d notes - add short permanent notes to this location for other visitors to find. - geo relays unavailable; notes paused - no geo relays nearby - notes rely on geo relays. check connection and try again. - loading notes… - no notes yet - be the first to add one for this spot. - dismiss - add a note for this place + Add short permanent notes to this location for other visitors to find. + Geo relays unavailable; notes paused + No geo relays nearby + Notes rely on geo relays. Check connection and try again. + Loading notes… + No notes yet + Be the first to add one for this spot. + Dismiss + Add a note for this place - debug tools - developer utilities for diagnostics and control - verbose logging - logs peer joins/leaves, connection direction, packet routing and relays - bluetooth roles - gatt server - connections: %1$d / %2$d - max server - gatt client - max client - connections: %1$d / %2$d - max overall - packet relay - since start: %1$d - turn roles on/off and close all connections when disabled - sync settings - max packets per sync: %1$d - max GCS filter size: %1$d bytes (128–1024) - target FPR: %1$.2f%% - connected devices - our device id: %1$s - none - disconnect - recent scan results + Debug Tools + Developer utilities for diagnostics and control + Verbose logging + Logs peer joins/leaves, connection direction, packet routing and relays + Bluetooth roles + GATT server + Connections: %1$d / %2$d + Max server + GATT client + Max client + Connections: %1$d / %2$d + Max overall + Packet relay + Since start: %1$d + Turn roles on/off and close all connections when disabled + Sync settings + Max packets per sync: %1$d + Max GCS filter size: %1$d bytes (128–1024) + Target FPR: %1$.2f%% + Connected devices + Our device ID: %1$s + None + Disconnect + Recent scan results - verify - scan to verify me - scan someone elses qr - scan someone elses qr - show my qr - remove verification - qr unavailable - camera permission is needed to scan qr codes - enable camera - paste verification url - validate - verification requested - security verification - their fingerprint - your fingerprint - handshake pending - open a private chat to view fingerprints - encrypted & verified - encrypted - handshaking - handshake failed - not encrypted - verified - you have verified this persons identity. - not verified - compare these fingerprints with %1$s using a secure channel. - mark as verified - start handshake - copy + Verify + Scan to verify me + Scan someone else\'s QR + Scan someone else\'s QR + Show my QR + Remove verification + QR unavailable + Camera permission is needed to scan QR codes + Enable camera + Paste verification URL + Validate + Verification requested + Security verification + Their fingerprint + Your fingerprint + Handshake pending + Open a private chat to view fingerprints + Encrypted & verified + Encrypted + Handshaking + Handshake failed + Not encrypted + Verified + You have verified this person\'s identity. + Not verified + Compare these fingerprints with %1$s using a secure channel. + Mark as verified + Start handshake + Copy Mutual verification You and %1$s verified each other - mutual verification with %1$s + Mutual verification with %1$s Verified You verified %1$s - verified %1$s + Verified %1$s - connect - debug console - clear - last 10s: %1$d • 1m: %2$d • 15m: %3$d - derived P: %1$s • est. max elements: %2$s + Connect + Debug console + Clear + Last 10s: %1$d • 1m: %2$d • 15m: %3$d + Derived P: %1$s • est. max elements: %2$s • direct RSSI: %1$s ? - as server (we host) - as client (we connect) + As server (we host) + As client (we connect) Location Services Required @@ -375,11 +375,11 @@ bitchat needs location services for: • Bluetooth device scanning\n• Discovering nearby users on mesh network\n• Geohash chat feature\n• No tracking or location collection Background Location Recommended - optional, improves mesh reliability + Optional, improves mesh reliability Android recommends background location so bitchat can scan for nearby devices while the app is not open. This keeps the mesh alive after reboot. When settings opens, choose "Allow all the time". bitchat uses background location for: - - scan for nearby devices while the app is closed\n- reconnect after reboot\n- keep the mesh running in the background + - Scan for nearby devices while the app is closed\n- Reconnect after reboot\n- Keep the mesh running in the background We NEVER collect or store your location. Your privacy is safe. Allow Background Location Continue without background location @@ -395,13 +395,13 @@ Bluetooth Not Supported This device doesn\'t support Bluetooth Low Energy (BLE), which is required for bitchat to function.\n\nbitchat needs BLE to create mesh networks and communicate with nearby devices without internet. Checking Bluetooth status... - battery optimization detected + Battery optimization detected Battery Optimization Enabled - bitchat needs to run in the background to maintain mesh connections. battery optimization can interrupt these connections. + bitchat needs to run in the background to maintain mesh connections. Battery optimization can interrupt these connections. Benefits of Disabling - • reliable message delivery\n• maintains mesh connectivity\n• prevents connection drops + • Reliable message delivery\n• Maintains mesh connectivity\n• Prevents connection drops Disable Battery Optimization - battery optimization disabled + Battery optimization disabled Continue Initializing mesh network . @@ -412,20 +412,20 @@ Try Again Open Settings Your Privacy is Protected - • no tracking or data collection\n• Bluetooth mesh chats are fully offline\n• Geohash chats use the internet - permissions + • No tracking or data collection\n• Bluetooth mesh chats are fully offline\n• Geohash chats use the internet + Permissions Grant Permissions bitchat does NOT track your location @ Open About · ⧉ - nobody around... + Nobody around… (you) - pan and zoom to select a geohash - select + Pan and zoom to select a geohash + Select Type a message… @%1$s - mention + Mention %1$d / %2$d (~%1$s) @%1$s From 53a891a71fee2e35dfd1b257e7dcbc83af95c5a6 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:58:06 +0200 Subject: [PATCH 05/70] strings --- .../main/java/com/bitchat/android/ui/LocationChannelsSheet.kt | 2 +- app/src/main/res/values/strings.xml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 3f3e59cf..cc060e47 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -751,7 +751,7 @@ 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) + val meshLabel = stringResource(com.bitchat.android.R.string.mesh_title) return "$meshLabel [$peopleText]" } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 93579e5b..62d3ad1d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -273,6 +273,7 @@ Remove location access Enable location services mesh + Mesh Block Neighborhood City From 893e7d3875ac2bae970d43df1e511bd24ba00768 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:11:11 +0200 Subject: [PATCH 06/70] input bar fixes --- .../com/bitchat/android/ui/AboutSections.kt | 54 --- .../java/com/bitchat/android/ui/AboutSheet.kt | 56 +-- .../java/com/bitchat/android/ui/ChatHeader.kt | 34 +- .../com/bitchat/android/ui/InputComponents.kt | 401 ++++++++++++------ .../bitchat/android/ui/LocationNotesButton.kt | 25 +- .../android/ui/VoiceInputComponents.kt | 48 +-- .../android/ui/media/ImagePickerButton.kt | 49 +-- .../android/ui/theme/BitchatPalette.kt | 15 + app/src/main/res/values/strings.xml | 4 - 9 files changed, 382 insertions(+), 304 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index 3df4fe5d..ee432a26 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -6,7 +6,6 @@ import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Terminal -import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.Lock @@ -452,59 +451,6 @@ private fun AboutFeatureRow( } } -/** - * Security-audit warning banner. - * - * Deliberately styled as a tinted, outlined card rather than plain red text: it needs to be - * impossible to skim past, but it is also permanent until the audit lands, so a full-bleed - * alarm would quickly become wallpaper. - */ -@Composable -internal fun AboutWarningCard(modifier: Modifier = Modifier) { - val palette = LocalBitchatPalette.current - - Surface( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - color = palette.accentRed.copy(alpha = 0.12f), - border = BorderStroke(1.dp, palette.accentRed.copy(alpha = 0.35f)), - shape = RoundedCornerShape(12.dp) - ) { - Column( - modifier = Modifier.padding(14.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = null, - tint = palette.accentRed, - modifier = Modifier.size(16.dp) - ) - Text( - text = stringResource(R.string.about_warning_title).uppercase(), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 0.8.sp, - color = palette.accentRed - ) - } - Text( - text = stringResource(R.string.about_warning_body), - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - lineHeight = 17.sp, - color = palette.accentRed.copy(alpha = 0.85f) - ) - } - } -} - /** * Small uppercase pill, e.g. the `RECOMMENDED` badge beside the Tor routing toggle. */ diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 9b3d6836..6be3cff1 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -2,9 +2,6 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Speed import androidx.compose.animation.animateColorAsState @@ -24,7 +21,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -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 @@ -41,52 +37,6 @@ import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette -/** - * Feature row for displaying app capabilities - */ -@Composable -private fun FeatureRow( - icon: ImageVector, - title: String, - subtitle: String -) { - val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 13.dp), - verticalAlignment = Alignment.Top - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier - .padding(top = 1.dp) - .size(22.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = title, - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = palette.textPrimary - ) - Text( - text = subtitle, - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = palette.textSecondary, - lineHeight = 17.sp - ) - } - } -} - /** * Theme selection chip with Apple-like styling */ @@ -566,10 +516,6 @@ fun AboutSheet( } } - // Security audit warning - item(key = "warning") { - AboutWarningCard(modifier = Modifier.padding(top = 24.dp)) - } } // end Settings tab // Footer @@ -585,7 +531,7 @@ fun AboutSheet( 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), diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 6576a712..052201b0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -510,20 +510,26 @@ private fun MainHeader( } } - // Location Notes button (extracted to separate component) - LocationNotesButton( - viewModel = viewModel, - onClick = onLocationNotesClick - ) + // 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 below. Duplicating it here bought a shortcut for a rare action at the cost - // of a slot in the app's most crowded row. + // 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. - LocationChannelsButton( - viewModel = viewModel, - onClick = onLocationChannelsClick - ) + LocationChannelsButton( + viewModel = viewModel, + onClick = onLocationChannelsClick + ) + } PeerCounter( connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, @@ -574,7 +580,9 @@ private fun LocationChannelsButton( .clip(HeaderClusterShape) .clickable(onClickLabel = stringResource(R.string.location_channels_title)) { onClick() } .height(HeaderTapTarget) - .padding(horizontal = 6.dp) + // 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) ) { Icon( imageVector = badgeIcon, diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 938729ce..e97a1c95 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -4,10 +4,26 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -18,7 +34,11 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource @@ -174,23 +194,96 @@ class CombinedVisualTransformation(private val transformations: List Unit +) { + val palette = LocalBitchatPalette.current + val accent = if (activeColor == Color.Unspecified) palette.accentGreen else activeColor + + val container by animateColorAsState( + // A tint rather than a fill. A solid accent disc next to the text you are typing was the + // loudest thing on the screen; at 20% it still reads as "armed" without competing. + targetValue = if (isActive) accent.copy(alpha = 0.20f) else palette.surfaceVariant, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerButtonContainer" + ) + val tint by animateColorAsState( + targetValue = if (isActive) accent else palette.textSecondary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerButtonTint" + ) + // A small dip on press. Spring rather than tween so the release overshoots very slightly and + // the button feels physical instead of merely animated. + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.88f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "composerButtonScale" + ) + + Box( + modifier = modifier.size(ComposerButtonSize), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(ComposerButtonDisc) + .scale(scale) + .background(container, CircleShape) + .semantics { contentDescription?.let { this.contentDescription = it } }, + contentAlignment = Alignment.Center + ) { + content(tint) + } + } +} @Composable fun MessageInput( @@ -206,60 +299,72 @@ fun MessageInput( showMediaButtons: Boolean, modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current val isFocused = remember { mutableStateOf(false) } - val hasText = value.text.isNotBlank() // Check if there's text for send button state - val keyboard = LocalSoftwareKeyboardController.current + val hasText = value.text.isNotBlank() val focusRequester = remember { FocusRequester() } var isRecording by remember { mutableStateOf(false) } var elapsedMs by remember { mutableStateOf(0L) } var amplitude by remember { mutableStateOf(0) } - // Recording turns the pill's outline red; a cross-fade keeps that from flashing. + // Recording is the one state worth shouting about, so it overrides focus. val borderColor by animateColorAsState( targetValue = when { - isRecording -> palette.accentRed.copy(alpha = 0.6f) - isFocused.value -> palette.outline - else -> palette.outlineVariant + isRecording -> palette.accentRed.copy(alpha = 0.7f) + isFocused.value -> palette.inputOutlineFocused + else -> palette.inputOutline }, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "composerBorder" ) + // A barely-there lift on focus. Enough to register, not enough to look like a different + // component. + val containerColor by animateColorAsState( + targetValue = if (isFocused.value) palette.surfaceVariant else palette.surface, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerContainer" + ) Row( - modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), - // Bottom-aligned so that when the field grows to several lines the send button and the - // media buttons stay pinned next to the last line, as in the design's long-text state. - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(4.dp) + modifier = modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.Bottom ) { - // MARK: - The pill. Holds the field and the send button as one visual object. + // MARK: - The pill. Field and action buttons are one visual object. Row( modifier = Modifier .weight(1f) .heightIn(min = ComposerMinHeight) - .background(palette.surfaceVariant, ComposerShape) + // Grow smoothly as the field wraps to more lines rather than jumping a line at + // a time. + .animateContentSize( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ) + .background(containerColor, ComposerShape) .border(1.dp, borderColor, ComposerShape), verticalAlignment = Alignment.Bottom ) { Box( modifier = Modifier .weight(1f) - .padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp) + .padding(start = 18.dp, end = 4.dp, top = 15.dp, bottom = 15.dp) ) { // Always keep the text field mounted to retain focus and avoid IME collapse BasicTextField( value = value, onValueChange = onValueChange, + // Near-white, not terminal green: this is the one place in the app where the + // user is composing rather than reading, and green-on-black is tiring to + // type into. textStyle = MaterialTheme.typography.bodyMedium.copy( - color = colorScheme.primary, + color = palette.textPrimary, fontFamily = FontFamily.Monospace ), - cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary), + cursorBrush = SolidColor( + if (isRecording) Color.Transparent else palette.textPrimary + ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), keyboardActions = KeyboardActions(onSend = { - if (hasText) onSend() // Only send if there's text + if (hasText) onSend() }), // Cap the growth so a pasted wall of text cannot swallow the message list. maxLines = 6, @@ -285,34 +390,52 @@ fun MessageInput( } ) - // Show placeholder when there's no text and not recording - if (value.text.isEmpty() && !isRecording) { + // Placeholder fades rather than blinking, which matters because it reappears + // every time a message is sent. + val placeholderAlpha by animateFloatAsState( + targetValue = if (value.text.isEmpty() && !isRecording) 1f else 0f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "placeholderAlpha" + ) + if (placeholderAlpha > 0f) { Text( text = stringResource(R.string.type_a_message_placeholder), style = MaterialTheme.typography.bodyMedium.copy( fontFamily = FontFamily.Monospace ), color = palette.textTertiary, - modifier = Modifier.fillMaxWidth() + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .alpha(placeholderAlpha) ) } - // Overlay the real-time scrolling waveform while recording + // Recording visualiser, layered over the (empty) field. + val waveformAlpha by animateFloatAsState( + targetValue = if (isRecording) 1f else 0f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "waveformAlpha" + ) if (isRecording) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(waveformAlpha), + verticalAlignment = Alignment.CenterVertically + ) { RealtimeScrollingWaveform( - modifier = Modifier.weight(1f).height(24.dp), + modifier = Modifier.weight(1f).height(22.dp), amplitudeNorm = normalizeAmplitudeSample(amplitude) ) Spacer(Modifier.width(12.dp)) val secs = (elapsedMs / 1000).toInt() - val mm = secs / 60 - val ss = secs % 60 val maxSecs = 10 // 10 second max recording time - val maxMm = maxSecs / 60 - val maxSs = maxSecs % 60 Text( - text = String.format("%02d:%02d / %02d:%02d", mm, ss, maxMm, maxSs), + text = String.format( + "%02d:%02d / %02d:%02d", + secs / 60, secs % 60, maxSecs / 60, maxSecs % 60 + ), fontFamily = FontFamily.Monospace, color = palette.accentRed, fontSize = (BASE_FONT_SIZE - 4).sp @@ -321,60 +444,116 @@ fun MessageInput( } } - SendButton( - hasText = hasText, - isAccented = selectedPrivatePeer != null || currentChannel != null, - onSend = onSend, - modifier = Modifier.padding(end = 4.dp, bottom = 4.dp) - ) - } - - // MARK: - Media affordances, outside the pill, only while the field is empty. - if (value.text.isEmpty() && showMediaButtons) { - // Ensure latest values are used when finishing recording + // MARK: - Action cluster, inside the pill. + // + // Swaps between the auxiliary buttons and send. AnimatedContent cross-fades and + // scales between the two, and SizeTransform animates the width change, so typing the + // first character morphs camera+mic into send instead of snapping. val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer) val latestChannel = rememberUpdatedState(currentChannel) val latestOnSendVoiceNote = rememberUpdatedState(onSendVoiceNote) - // Image button (image picker) - hide during recording - if (!isRecording) { - ImagePickerButton( - onImageReady = { outPath -> - onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath) + AnimatedContent( + targetState = hasText, + transitionSpec = { + ( + fadeIn(tween(BitchatMotion.STANDARD_MS)) + + scaleIn( + initialScale = 0.7f, + animationSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ) + ) + ).togetherWith( + fadeOut(tween(BitchatMotion.QUICK_MS)) + + scaleOut( + targetScale = 0.7f, + animationSpec = tween( + BitchatMotion.QUICK_MS, + easing = FastOutSlowInEasing + ) + ) + ) using SizeTransform(clip = false) { _, _ -> + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) } - ) - } + }, + modifier = Modifier.padding(end = 6.dp, bottom = 6.dp), + label = "composerActions" + ) { showSend -> + if (showSend) { + SendButton( + isAccented = latestSelectedPeer.value != null || latestChannel.value != null, + onSend = onSend + ) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + if (showMediaButtons) { + // The camera steps aside while recording so the microphone is the + // only thing that can be released. + AnimatedVisibility( + visible = !isRecording, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandHorizontally( + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkHorizontally( + tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ) + ) { + ImagePickerButton( + onImageReady = { outPath -> + onSendImageNote( + latestSelectedPeer.value, + latestChannel.value, + outPath + ) + } + ) + } - VoiceRecordButton( - backgroundColor = colorScheme.primary, - onStart = { - isRecording = true - elapsedMs = 0L - // Keep existing focus to avoid IME collapse, but do not force-show keyboard - if (isFocused.value) { - try { focusRequester.requestFocus() } catch (_: Exception) {} - } - }, - onAmplitude = { amp, ms -> - amplitude = amp - elapsedMs = ms - }, - onFinish = { path -> - isRecording = false - // Extract and cache waveform from the actual audio file to match receiver rendering - AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr -> - if (arr != null) { - try { com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr) } catch (_: Exception) {} + VoiceRecordButton( + isRecording = isRecording, + onStart = { + isRecording = true + elapsedMs = 0L + // Keep existing focus to avoid IME collapse, but do not + // force-show the keyboard. + if (isFocused.value) { + try { focusRequester.requestFocus() } catch (_: Exception) {} + } + }, + onAmplitude = { amp, ms -> + amplitude = amp + elapsedMs = ms + }, + onFinish = { path -> + isRecording = false + // Extract and cache the waveform from the actual audio file + // so it matches the receiver's rendering. + AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr -> + if (arr != null) { + try { + com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr) + } catch (_: Exception) {} + } + } + latestOnSendVoiceNote.value( + latestSelectedPeer.value, + latestChannel.value, + path + ) + } + ) + } else { + // No media in this context, so keep an inert send button rather than + // leaving a hole where the action cluster should be. + SendButton(isAccented = false, onSend = {}, enabled = false) } } - // BLE path (private or public) — use latest values to avoid stale captures - latestOnSendVoiceNote.value( - latestSelectedPeer.value, - latestChannel.value, - path - ) } - ) + } } } @@ -382,51 +561,31 @@ fun MessageInput( } /** - * Circular send affordance nested in the bottom-right of the composer pill. - * - * Goes from a flat grey disc to a solid accent the moment there is something to send, which is - * the clearest possible signal that the return key will do something. + * Send affordance. Only rendered when there is something to send, so its mere presence is the + * signal; it does not need to shout in the terminal's full-brightness green as well. */ @Composable private fun SendButton( - hasText: Boolean, isAccented: Boolean, onSend: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + enabled: Boolean = true ) { val palette = LocalBitchatPalette.current - val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() - val targetBackground = when { - !hasText -> palette.outline - isAccented -> palette.accentOrange - else -> colorScheme.primary - } - val targetTint = when { - !hasText -> palette.textTertiary - // Both accents are bright enough that black is the only legible arrow colour. - else -> Color.Black - } - - val background by animateColorAsState( - targetValue = targetBackground, - animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), - label = "sendButtonBackground" - ) - val tint by animateColorAsState( - targetValue = targetTint, - animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), - label = "sendButtonTint" - ) - - Box( - modifier = modifier - .size(SendButtonSize) - .background(background, CircleShape) - .clip(CircleShape) - .clickable(enabled = hasText) { onSend() }, - contentAlignment = Alignment.Center - ) { + ComposerActionSurface( + isActive = enabled, + isPressed = isPressed, + // Private chats and channels keep their orange identity, disc and glyph together. + activeColor = if (isAccented) palette.accentOrange else palette.accentGreen, + modifier = modifier.clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled + ) { onSend() } + ) { tint -> Icon( imageVector = Icons.Filled.ArrowUpward, contentDescription = stringResource(id = R.string.send_message), diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 1ec6417e..185a053e 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -2,16 +2,19 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Description +import androidx.compose.foundation.clickable +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.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -34,7 +37,7 @@ 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) } @@ -44,7 +47,7 @@ fun LocationNotesButton( // 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() @@ -53,13 +56,19 @@ 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(44.dp) + val contentDescription = stringResource(R.string.cd_location_notes) + // Match other header icon buttons: 44.dp target, no Material IconButton min-size padding + // that pushed the notes glyph farther from the mesh badge than sibling gaps. + Box( + modifier = modifier + .size(44.dp) + .clip(CircleShape) + .clickable(onClickLabel = contentDescription) { onClick() }, + contentAlignment = Alignment.Center ) { Icon( imageVector = Icons.Outlined.Description, - contentDescription = stringResource(R.string.cd_location_notes), + contentDescription = contentDescription, modifier = Modifier.size(HeaderIconSize), tint = if (hasNotes) colorScheme.primary else LocalBitchatPalette.current.textSecondary ) diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index cdc40583..bda3aee4 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -3,22 +3,16 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic 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.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 @@ -32,7 +26,11 @@ import kotlinx.coroutines.launch @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 @@ -41,7 +39,7 @@ fun VoiceRecordButton( val haptic = LocalHapticFeedback.current val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO) - var isRecording by remember { mutableStateOf(false) } + var isCapturing by remember { mutableStateOf(false) } var recorder by remember { mutableStateOf(null) } var recordedFilePath by remember { mutableStateOf(null) } var recordingStart by remember { mutableStateOf(0L) } @@ -54,14 +52,15 @@ fun VoiceRecordButton( val latestOnAmplitude = rememberUpdatedState(onAmplitude) val latestOnFinish = rememberUpdatedState(onFinish) - Box( + // Same disc, same sizing and the same press feedback as the camera and send buttons. + ComposerActionSurface( + isActive = isRecording || isCapturing, + isPressed = isCapturing, modifier = modifier - .size(36.dp) - .background(backgroundColor, CircleShape) .pointerInput(Unit) { detectTapGestures( onPress = { - if (!isRecording) { + if (!isCapturing) { if (micPermission.status !is PermissionStatus.Granted) { micPermission.launchPermissionRequest() return@detectTapGestures @@ -69,24 +68,24 @@ fun VoiceRecordButton( val rec = VoiceRecorder(context) val f = rec.start() recorder = rec - isRecording = f != null + isCapturing = f != null recordedFilePath = f?.absolutePath recordingStart = System.currentTimeMillis() - if (isRecording) { + if (isCapturing) { 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) { + while (isActive && isCapturing) { 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) { + if (elapsedMs >= 10_000 && isCapturing) { val file = recorder?.stop() - isRecording = false + isCapturing = false recorder = null val path = file?.absolutePath if (!path.isNullOrBlank()) { @@ -104,13 +103,13 @@ fun VoiceRecordButton( try { awaitRelease() } finally { - if (isRecording) { + if (isCapturing) { // Extend recording for 500ms after release to avoid clipping delay(500) } - if (isRecording) { + if (isCapturing) { val file = recorder?.stop() - isRecording = false + isCapturing = false recorder = null val path = (file?.absolutePath ?: recordedFilePath) recordedFilePath = null @@ -125,14 +124,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(22.dp) + tint = tint, + modifier = Modifier.size(ComposerIconSize) ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt index df6f8ea7..97ee01a4 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt @@ -11,22 +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.foundation.shape.CircleShape import androidx.compose.material3.Icon 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.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.theme.LocalBitchatPalette +import com.bitchat.android.ui.ComposerActionSurface +import com.bitchat.android.ui.ComposerIconSize import java.io.File @OptIn(ExperimentalFoundationApi::class) @@ -89,28 +87,31 @@ fun ImagePickerButton( } } - Box( - modifier = modifier - // Matches the header's tap targets and the composer's send button. - .size(44.dp) - .clip(CircleShape) - .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 = LocalBitchatPalette.current.textSecondary, - modifier = Modifier.size(22.dp) + tint = tint, + modifier = Modifier.size(ComposerIconSize) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt index bc5e7b03..e7f1a9af 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -33,6 +33,17 @@ data class BitchatPalette( /** Hairline dividers. */ val outlineVariant: Color, + // MARK: - Form controls + /** + * Resting border for text inputs. Deliberately a neutral grey rather than the green-tinted + * [outline]: the composer is the one surface the user stares at while typing, and a green + * cast there made the whole bottom of the screen read as "terminal chrome" instead of a + * place to write. + */ + val inputOutline: Color, + /** Border for a focused text input. A step brighter, still neutral. */ + val inputOutlineFocused: Color, + // MARK: - Text /** Message bodies and row titles. Neutral, not green. */ val textPrimary: Color, @@ -64,6 +75,8 @@ val DarkBitchatPalette = BitchatPalette( surfaceVariant = Color(0xFF182118), outline = Color(0xFF2A3A2A), outlineVariant = Color(0xFF1C271C), + inputOutline = Color(0xFF333635), + inputOutlineFocused = Color(0xFF5A605D), textPrimary = Color(0xFFE8EDE8), textSecondary = Color(0xFF9AA69A), textTertiary = Color(0xFF6B776B), @@ -81,6 +94,8 @@ val LightBitchatPalette = BitchatPalette( surfaceVariant = Color(0xFFE7EDE7), outline = Color(0xFFCBD6CB), outlineVariant = Color(0xFFDEE6DE), + inputOutline = Color(0xFFCFD3D1), + inputOutlineFocused = Color(0xFF8E9490), textPrimary = Color(0xFF131A13), textSecondary = Color(0xFF4C574C), textTertiary = Color(0xFF757F75), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 62d3ad1d..3b7a7022 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -135,10 +135,6 @@ A new peer ID is generated regularly. Triple-tap the logo to instantly clear all data. - - Warning - Private message security has not yet been fully audited. Do not use for critical situations until this warning disappears. - Tor Network Difficulty From 02a737fad6c4a56c7df1678f4b9831e73e91a22f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:26:15 +0200 Subject: [PATCH 07/70] fixes --- .../com/bitchat/android/ui/AboutSections.kt | 97 ++++------ .../java/com/bitchat/android/ui/ChatHeader.kt | 11 -- .../java/com/bitchat/android/ui/ChatScreen.kt | 161 +++++++++------- .../com/bitchat/android/ui/InputComponents.kt | 24 ++- .../bitchat/android/ui/MeshPeerListSheet.kt | 8 +- .../bitchat/android/ui/MessageComponents.kt | 18 +- .../android/ui/VoiceInputComponents.kt | 175 +++++++++++++----- .../android/ui/theme/BitchatPalette.kt | 16 ++ 8 files changed, 322 insertions(+), 188 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index ee432a26..af17a509 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -4,7 +4,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AlternateEmail import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material.icons.outlined.BookmarkBorder @@ -339,71 +338,40 @@ internal fun AboutHowToUseSection(modifier: Modifier = Modifier) { } /** - * The six-item capability list from the design. - * - * Icons are Material approximations of the designer's custom line art; swap in the exported - * SVGs when they are available. + * Capability list, laid out like [AboutHowToUseSection]: flat rows, no card surface or dividers. */ @Composable internal fun AboutFeatureCard(modifier: Modifier = Modifier) { - val palette = LocalBitchatPalette.current + val features = listOf( + Triple( + Icons.Filled.WifiOff, + R.string.about_offline_mesh_title, + R.string.about_offline_mesh_desc + ), + Triple( + Icons.Outlined.Lock, + R.string.about_e2e_title, + R.string.about_e2e_desc + ), + Triple( + Icons.Outlined.Public, + R.string.about_online_geohash_title, + R.string.about_online_geohash_desc + ), + Triple( + Icons.Outlined.VisibilityOff, + R.string.about_no_tracking_title, + R.string.about_no_tracking_desc + ), + ) - Surface( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, - shape = AboutCardShape - ) { - Column { - val features = listOf( - Triple( - Icons.Filled.WifiOff, - R.string.about_offline_mesh_title, - R.string.about_offline_mesh_desc - ), - Triple( - Icons.Outlined.Lock, - R.string.about_e2e_title, - R.string.about_e2e_desc - ), - Triple( - Icons.Outlined.Public, - R.string.about_online_geohash_title, - R.string.about_online_geohash_desc - ), - Triple( - Icons.Outlined.VisibilityOff, - R.string.about_no_tracking_title, - R.string.about_no_tracking_desc - ), - Triple( - Icons.Filled.Shuffle, - R.string.about_ephemeral_title, - R.string.about_ephemeral_desc - ), - Triple( - Icons.Filled.DeleteForever, - R.string.about_emergency_title, - R.string.about_panic_desc - ), + Column(modifier = modifier.fillMaxWidth()) { + features.forEach { (icon, titleRes, descRes) -> + AboutFeatureRow( + icon = icon, + title = stringResource(titleRes), + subtitle = stringResource(descRes) ) - - features.forEachIndexed { index, (icon, titleRes, descRes) -> - if (index > 0) { - HorizontalDivider( - // Inset to align with the text column, not the icon. - modifier = Modifier.padding(start = 54.dp), - thickness = 1.dp, - color = palette.outlineVariant - ) - } - AboutFeatureRow( - icon = icon, - title = stringResource(titleRes), - subtitle = stringResource(descRes) - ) - } } } } @@ -420,7 +388,8 @@ private fun AboutFeatureRow( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 13.dp), + .padding(horizontal = AboutHorizontalPadding, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top ) { Icon( @@ -429,15 +398,15 @@ private fun AboutFeatureRow( tint = colorScheme.primary, modifier = Modifier .padding(top = 1.dp) - .size(24.dp) + .size(22.dp) ) - Spacer(modifier = Modifier.width(16.dp)) Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( text = title, fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium, + lineHeight = 20.sp, color = palette.textPrimary ) Text( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 052201b0..c6cb546f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -560,7 +560,6 @@ private fun LocationChannelsButton( // Get current channel selection from location manager val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() - val teleported by viewModel.isTeleported.collectAsStateWithLifecycle() val isLocation = selectedChannel is com.bitchat.android.geohash.ChannelID.Location val badgeText = when (val channel = selectedChannel) { @@ -599,15 +598,5 @@ private fun LocationChannelsButton( color = badgeColor, maxLines = 1 ) - - // Teleportation indicator (like iOS) - if (teleported) { - Icon( - imageVector = Icons.Default.PinDrop, - contentDescription = stringResource(R.string.cd_teleported), - modifier = Modifier.size(HeaderIconSize), - tint = badgeColor - ) - } } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 6f4ff8a3..2d721bba 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -14,11 +14,14 @@ 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.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.TextRange import androidx.compose.ui.text.input.TextFieldValue @@ -120,7 +123,14 @@ fun ChatScreen(viewModel: ChatViewModel) { .background(colorScheme.background) // Extend background to fill entire screen including status bar ) { 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 @@ -128,19 +138,17 @@ 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 MessagesList( messages = displayMessages, currentUserNickname = nickname, meshService = viewModel.meshServiceFacade, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + top = statusBarHeight + headerHeight, + bottom = composerHeight + ), forceScrollToBottom = forceScrollToBottom, onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, onNicknameClick = { fullSenderName -> @@ -188,7 +196,7 @@ fun ChatScreen(viewModel: ChatViewModel) { showFullScreenImageViewer = true } ) - // 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 -> @@ -197,6 +205,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 @@ -244,6 +257,7 @@ fun ChatScreen(viewModel: ChatViewModel) { colorScheme = colorScheme, showMediaButtons = showMediaButtons ) + } } // Floating header - positioned absolutely at top, ignores keyboard @@ -261,17 +275,6 @@ fun ChatScreen(viewModel: ChatViewModel) { onLocationNotesClick = { showLocationNotesSheet = true } ) - // Divider under header - positioned after status bar + header height - HorizontalDivider( - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.statusBars) - .offset(y = headerHeight) - .zIndex(1f), - thickness = 1.dp, - color = palette.outlineVariant - ) - // Scroll-to-bottom floating button AnimatedVisibility( visible = isScrolledUp, @@ -287,7 +290,7 @@ fun ChatScreen(viewModel: ChatViewModel) { ) + 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) @@ -407,50 +410,73 @@ fun ChatInputSection( currentChannel: String?, nickname: String, colorScheme: ColorScheme, - showMediaButtons: Boolean + showMediaButtons: Boolean, + modifier: Modifier = Modifier ) { val palette = LocalBitchatPalette.current - 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)) ) { - // No divider above the composer: the pill's own border provides the separation, and a - // full-width rule on top of it read as a double line. - Column { - // Command suggestions box - if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { - CommandSuggestionsBox( - suggestions = commandSuggestions, - onSuggestionClick = onCommandSuggestionClick, - modifier = Modifier.fillMaxWidth() - ) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) - } - // Mention suggestions box - if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) { - MentionSuggestionsBox( - suggestions = mentionSuggestions, - onSuggestionClick = onMentionSuggestionClick, - modifier = Modifier.fillMaxWidth() - ) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) - } - MessageInput( - value = messageText, - onValueChange = onMessageTextChange, - onSend = onSend, - onSendVoiceNote = onSendVoiceNote, - onSendImageNote = onSendImageNote, - onSendFileNote = onSendFileNote, - selectedPrivatePeer = selectedPrivatePeer, - currentChannel = currentChannel, - nickname = nickname, - showMediaButtons = showMediaButtons, + // Hairline marking where chrome begins. Faint on purpose — it is a hint, not a border. + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + + // Command suggestions box + if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { + CommandSuggestionsBox( + suggestions = commandSuggestions, + onSuggestionClick = onCommandSuggestionClick, modifier = Modifier.fillMaxWidth() ) + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) } + // Mention suggestions box + if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) { + MentionSuggestionsBox( + suggestions = mentionSuggestions, + onSuggestionClick = onMentionSuggestionClick, + modifier = Modifier.fillMaxWidth() + ) + HorizontalDivider(thickness = 1.dp, color = palette.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() + ) } } + +/** + * 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, @@ -467,13 +493,24 @@ private fun ChatFloatingHeader( ) { val context = androidx.compose.ui.platform.LocalContext.current val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) } - - Surface( + val palette = LocalBitchatPalette.current + + Box( modifier = Modifier .fillMaxWidth() .zIndex(1f) - .windowInsetsPadding(WindowInsets.statusBars), // Extend into status bar area - color = colorScheme.background // Solid background color extending into status bar + // 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 ) { // A plain Row rather than M3's TopAppBar. TopAppBar silently injects a 4.dp horizontal // pad plus a 12.dp title inset and applies its own minimum heights, which made the diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index e97a1c95..d673bb79 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -220,6 +220,15 @@ private val ComposerButtonDisc = 36.dp /** Icon size shared by the composer's glyphs. */ internal val ComposerIconSize = 20.dp +/** + * Opacity of the composer pill. + * + * Not fully opaque: the message list scrolls underneath the composer, and letting a hint of it + * through is what makes the bar read as sitting *over* the conversation rather than boxing it in. + * Kept high enough that text in the field never loses contrast. + */ +private const val ComposerFillAlpha = 0.88f + /** * The shared visual treatment for every button in the composer: camera, microphone, send. * @@ -248,7 +257,7 @@ internal fun ComposerActionSurface( val container by animateColorAsState( // A tint rather than a fill. A solid accent disc next to the text you are typing was the // loudest thing on the screen; at 20% it still reads as "armed" without competing. - targetValue = if (isActive) accent.copy(alpha = 0.20f) else palette.surfaceVariant, + targetValue = if (isActive) accent.copy(alpha = 0.20f) else palette.inputButton, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "composerButtonContainer" ) @@ -318,9 +327,10 @@ fun MessageInput( label = "composerBorder" ) // A barely-there lift on focus. Enough to register, not enough to look like a different - // component. + // component. Slightly translucent so the messages scrolling underneath stay faintly visible. val containerColor by animateColorAsState( - targetValue = if (isFocused.value) palette.surfaceVariant else palette.surface, + targetValue = (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface) + .copy(alpha = ComposerFillAlpha), animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "composerContainer" ) @@ -544,6 +554,14 @@ fun MessageInput( latestChannel.value, path ) + }, + // Any capture that ends without a file must clear the recording + // state here too, otherwise the pill stays red with a live + // waveform over an empty field. + onCancel = { + isRecording = false + amplitude = 0 + elapsedMs = 0L } ) } else { diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 180b35c8..880c0cc6 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -860,6 +860,7 @@ fun PrivateChatSheet( val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true ) + val palette = LocalBitchatPalette.current if (isPresented) { BitchatBottomSheet( @@ -872,7 +873,7 @@ fun PrivateChatSheet( ) { Spacer(modifier = Modifier.height(64.dp)) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) // Messages list var forceScrollToBottom by remember { mutableStateOf(false) } @@ -891,9 +892,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( diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 83db678a..ad763aa7 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -11,6 +11,8 @@ 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 @@ -37,6 +39,7 @@ 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 @@ -72,6 +75,13 @@ fun MessagesList( currentUserNickname: String, meshService: MeshService, modifier: Modifier = Modifier, + /** + * 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), forceScrollToBottom: Boolean = false, onScrolledUpChanged: ((Boolean) -> Unit)? = null, onNicknameClick: ((String) -> Unit)? = null, @@ -119,11 +129,17 @@ fun MessagesList( } } + val layoutDirection = LocalLayoutDirection.current LazyColumn( state = listState, // 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, end = 16.dp, top = 8.dp, bottom = 12.dp), + 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 so that a continuation of the same author can sit // tighter than the start of a new speaker's run. verticalArrangement = Arrangement.spacedBy(0.dp), diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index bda3aee4..c9922bf1 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -21,6 +21,34 @@ 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 @@ -33,7 +61,13 @@ fun VoiceRecordButton( 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 @@ -51,6 +85,35 @@ fun VoiceRecordButton( val latestOnStart = rememberUpdatedState(onStart) val latestOnAmplitude = rememberUpdatedState(onAmplitude) val latestOnFinish = rememberUpdatedState(onFinish) + val latestOnCancel = rememberUpdatedState(onCancel) + + // 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( @@ -60,63 +123,89 @@ fun VoiceRecordButton( .pointerInput(Unit) { detectTapGestures( onPress = { - if (!isCapturing) { - if (micPermission.status !is PermissionStatus.Granted) { - micPermission.launchPermissionRequest() - return@detectTapGestures - } - val rec = VoiceRecorder(context) - val f = rec.start() - recorder = rec - isCapturing = f != null - recordedFilePath = f?.absolutePath - recordingStart = System.currentTimeMillis() - if (isCapturing) { - 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 && isCapturing) { - 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 && isCapturing) { - val file = recorder?.stop() - isCapturing = 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 (isCapturing) { - // Extend recording for 500ms after release to avoid clipping - delay(500) + // Keep going briefly past the release so the tail is not clipped. + delay(ReleaseTailMs) } if (isCapturing) { val file = recorder?.stop() 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() diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt index e7f1a9af..697567b3 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -43,6 +43,16 @@ data class BitchatPalette( 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: - Text /** Message bodies and row titles. Neutral, not green. */ @@ -77,6 +87,9 @@ val DarkBitchatPalette = BitchatPalette( outlineVariant = Color(0xFF1C271C), inputOutline = Color(0xFF333635), inputOutlineFocused = Color(0xFF5A605D), + inputSurface = Color(0xFF0B0B0B), + inputSurfaceFocused = Color(0xFF151515), + inputButton = Color(0xFF1E1E1E), textPrimary = Color(0xFFE8EDE8), textSecondary = Color(0xFF9AA69A), textTertiary = Color(0xFF6B776B), @@ -96,6 +109,9 @@ val LightBitchatPalette = BitchatPalette( outlineVariant = Color(0xFFDEE6DE), inputOutline = Color(0xFFCFD3D1), inputOutlineFocused = Color(0xFF8E9490), + inputSurface = Color(0xFFFAFAFA), + inputSurfaceFocused = Color(0xFFF2F2F2), + inputButton = Color(0xFFE8E8E8), textPrimary = Color(0xFF131A13), textSecondary = Color(0xFF4C574C), textTertiary = Color(0xFF757F75), From 974de992f0ce226233a6110238632c92e74e77b0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:29:09 +0200 Subject: [PATCH 08/70] notes --- .../java/com/bitchat/android/ui/ChatHeader.kt | 96 ++++++------------- .../bitchat/android/ui/LocationNotesButton.kt | 10 +- 2 files changed, 36 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index c6cb546f..9d831592 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -5,16 +5,9 @@ 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 android.util.Log -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState 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.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* @@ -39,10 +32,10 @@ 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.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 @@ -103,53 +96,30 @@ private fun HeaderIconButton( } /** - * Connection status indicator. + * Smooth tint for header glyphs that should reflect Tor connection state. * - * Only visible while the connection is still coming up (or has failed). A steady green "all - * good" light is noise: the absence of the indicator already means everything is fine, and the - * eye stops registering an always-on dot anyway. It fades and scales in/out so appearing and - * disappearing reads as a state change rather than a glitch. + * When Tor is off or fully bootstrapped, [normal] is used. While connecting the tint goes + * orange; if Tor is enabled but not running it goes red. Cross-fades so Bootstrap percent + * flips do not flash the whole header. */ @Composable -fun TorStatusDot( - modifier: Modifier = Modifier -) { +internal fun torConnectionTint(normal: Color): Color { val palette = LocalBitchatPalette.current - val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() } - val torStatus by torProvider.statusFlow.collectAsState() + val torStatus by remember { ArtiTorManager.getInstance() }.statusFlow.collectAsState() - val isEnabled = torStatus.mode != com.bitchat.android.net.TorMode.OFF - val isEstablished = torStatus.running && torStatus.bootstrapPercent >= 100 - val isVisible = isEnabled && !isEstablished - - AnimatedVisibility( - visible = isVisible, - enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + - scaleIn( - initialScale = 0.4f, - animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) - ), - exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + - scaleOut( - targetScale = 0.4f, - animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) - ), - ) { - // Bootstrapping vs. failed. Cross-faded, because Tor flips between these frequently and - // a hard colour cut in the corner of the screen reads as a rendering fault. - val dotColor by animateColorAsState( - targetValue = if (torStatus.running) palette.accentOrange else palette.accentRed, - animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), - label = "torDotColor" - ) - Canvas(modifier = modifier) { - drawCircle( - color = dotColor, - radius = size.minDimension / 2, - center = Offset(size.width / 2, size.height / 2) - ) - } + val target = when { + torStatus.mode == TorMode.OFF -> normal + torStatus.running && torStatus.bootstrapPercent >= 100 -> normal + torStatus.running -> palette.accentOrange + else -> palette.accentRed } + + val animated by animateColorAsState( + targetValue = target, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "torConnectionTint" + ) + return animated } @Composable @@ -477,24 +447,14 @@ private fun MainHeader( // MARK: - Status cluster. // - // Order, left to right: connection state, unread DMs, notes, channel, people. - // The connection indicator leads because it is the only item that can invalidate - // everything to its right, and because it appears and disappears on its own — a fixed - // leftmost slot means the rest of the cluster never shifts when it does. + // 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) ) { - // Connection status. The Box holds the slot open at a constant width so the icons to - // its right stay put whether or not the dot is currently showing. - Box( - modifier = Modifier.size(HeaderIconSize), - contentAlignment = Alignment.Center - ) { - TorStatusDot(modifier = Modifier.size(8.dp)) - } - // Unread private messages badge (click to open most recent DM) if (hasUnreadPrivateMessages.isNotEmpty()) { HeaderIconButton( @@ -569,7 +529,9 @@ private fun LocationChannelsButton( // is, so it is plain "mesh". else -> stringResource(R.string.mesh_label) } - val badgeColor = if (isLocation) palette.accentGreen else palette.accentBlue + val channelColor = if (isLocation) palette.accentGreen else palette.accentBlue + // Icon alone carries Tor health so the geohash / "mesh" label stays channel-coloured. + val iconColor = torConnectionTint(normal = channelColor) val badgeIcon = if (isLocation) Icons.Outlined.Public else Icons.Filled.Bluetooth Row( @@ -585,9 +547,9 @@ private fun LocationChannelsButton( ) { Icon( imageVector = badgeIcon, - contentDescription = null, + contentDescription = stringResource(R.string.cd_tor_status), modifier = Modifier.size(HeaderIconSize), - tint = badgeColor + tint = iconColor ) Text( @@ -595,7 +557,7 @@ private fun LocationChannelsButton( style = MaterialTheme.typography.bodyMedium, fontSize = HeaderTextSize, fontWeight = FontWeight.Medium, - color = badgeColor, + color = channelColor, maxLines = 1 ) } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 185a053e..4819deef 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -26,8 +26,9 @@ import com.bitchat.android.ui.theme.LocalBitchatPalette /** * 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 + * Shows in mesh mode when location permission granted AND services enabled. + * Base tint is primary when notes exist, secondary otherwise; Tor health may + * override that color (orange connecting, red failed) via [torConnectionTint]. */ @Composable fun LocationNotesButton( @@ -36,6 +37,7 @@ fun LocationNotesButton( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current val context = LocalContext.current // Get channel and permission state @@ -57,6 +59,8 @@ fun LocationNotesButton( if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) { val hasNotes = notesCount > 0 val contentDescription = stringResource(R.string.cd_location_notes) + val normalTint = if (hasNotes) colorScheme.primary else palette.textSecondary + val tint = torConnectionTint(normal = normalTint) // Match other header icon buttons: 44.dp target, no Material IconButton min-size padding // that pushed the notes glyph farther from the mesh badge than sibling gaps. Box( @@ -70,7 +74,7 @@ fun LocationNotesButton( imageVector = Icons.Outlined.Description, contentDescription = contentDescription, modifier = Modifier.size(HeaderIconSize), - tint = if (hasNotes) colorScheme.primary else LocalBitchatPalette.current.textSecondary + tint = tint ) } } From 33b060b5517304d0c6cf9ec7cb1c804a4e1a919e Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:37:55 +0200 Subject: [PATCH 09/70] nice --- .../android/ui/LocationChannelsSheet.kt | 1083 +++++++++-------- .../bitchat/android/ui/MessageComponents.kt | 216 +++- .../android/ui/MessageArrivalTrackerTest.kt | 141 +++ 3 files changed, 877 insertions(+), 563 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index cc060e47..76b3c020 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -2,22 +2,21 @@ package com.bitchat.android.ui 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.filled.Security import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.Public import android.content.Intent import android.net.Uri import android.provider.Settings -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -28,35 +27,48 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.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.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 +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashBookmarksStore +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette +import kotlinx.coroutines.launch /** - * Location Channels Sheet for selecting geohash-based location channels - * Direct port from iOS LocationChannelsSheet for 100% compatibility + * Leading column width matching settings rows: 22.dp glyph + 16.dp gutter before title text. + * Selection dots and row icons sit in this column so every option lines up with About settings. + */ +private val ChannelLeadingSlot = 22.dp +private val ChannelLeadingGutter = 16.dp +private val ChannelRowHorizontal = 16.dp +private val ChannelRowVertical = 13.dp +private val ChannelDividerInset = ChannelRowHorizontal + ChannelLeadingSlot + ChannelLeadingGutter +/** 2× the previous 6.dp selected indicator; sits centered in [ChannelLeadingSlot]. */ +private val ChannelSelectedDot = 12.dp + +/** + * Location Channels sheet: grouped card rows matching About → Settings. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -70,34 +82,22 @@ fun LocationChannelsSheet( val locationManager = LocationChannelManager.getInstance(context) val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) } - // Observe location manager state val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() val 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(null) } - var isInputFocused by remember { mutableStateOf(false) } - // Bottom sheet state - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val coroutineScope = rememberCoroutineScope() - // Scroll state for LazyColumn with animated top bar val listState = rememberLazyListState() val isScrolled by remember { derivedStateOf { @@ -105,7 +105,8 @@ fun LocationChannelsSheet( } } val topBarAlpha by animateFloatAsState( - targetValue = if (isScrolled) 0.95f else 0f, + targetValue = if (isScrolled) 0.98f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), label = "topBarAlpha" ) @@ -126,6 +127,13 @@ fun LocationChannelsSheet( val standardGreen = palette.accentGreen val standardBlue = palette.accentBlue + val nearbyChannels = remember(availableChannels) { + availableChannels.filter { it.level != GeohashChannelLevel.BUILDING } + } + val showNearbyLoading = nearbyChannels.isEmpty() && + permissionState == LocationChannelManager.PermissionState.AUTHORIZED && + locationServicesEnabled + if (isPresented) { BitchatBottomSheet( modifier = modifier, @@ -136,14 +144,14 @@ fun LocationChannelsSheet( LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 64.dp, bottom = 16.dp) + contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) ) { - // Header Section item(key = "header") { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding + 14.dp), + .padding(horizontal = AboutHorizontalPadding), verticalArrangement = Arrangement.spacedBy(10.dp) ) { SheetHeaderBadge(icon = Icons.Outlined.Public) @@ -164,413 +172,268 @@ fun LocationChannelsSheet( } } - // Permission controls if services enabled - if (locationServicesEnabled) { + if (locationServicesEnabled && + permissionState == LocationChannelManager.PermissionState.DENIED + ) { item(key = "permissions") { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 8.dp), + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 16.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 = palette.accentRed - ) - 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, - participantCount = meshCount(viewModel), - 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 } - if (nearbyChannels.isNotEmpty()) { - item(key = "nearby_header") { - SheetSectionLabel(text = stringResource(R.string.section_nearby)) - } - } - 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), - participantCount = participantCount, - 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 = if (isBookmarked) palette.accentGreen else palette.textSecondary, - modifier = Modifier.size(22.dp) - ) - } - }, - 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 = palette.textSecondary - ) Text( - text = stringResource(R.string.finding_nearby_channels), + text = stringResource(R.string.location_permission_denied), fontSize = 12.sp, fontFamily = FontFamily.Monospace, - color = palette.textSecondary + color = palette.accentRed ) - } - } - } - - // Bookmarked geohashes - if (bookmarks.isNotEmpty()) { - item(key = "bookmarked_header") { - SheetSectionLabel(text = stringResource(R.string.bookmarked)) - } - 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), - participantCount = participantCount, - titleColor = null, - titleBold = participantCount > 0, - trailingContent = { - IconButton(onClick = { bookmarksStore.toggle(gh) }) { - Icon( - imageVector = Icons.Filled.Bookmark, - contentDescription = stringResource(R.string.cd_remove_bookmark), - tint = palette.accentGreen, - modifier = Modifier.size(22.dp) - ) - } - }, - 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 = palette.surface, - shape = RoundedCornerShape(10.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 44.dp) - .padding(start = 14.dp, end = 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 = palette.textTertiary - ) - - 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 = colorScheme.primary - ), - cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary), - 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 = palette.textTertiary - ) + TextButton( + onClick = { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) } - 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 = palette.textSecondary, - modifier = Modifier.size(22.dp) + context.startActivity(intent) + }, + contentPadding = PaddingValues(0.dp) + ) { + Text( + text = stringResource(R.string.open_settings), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace ) } + } + } + } - val isValid = validateGeohash(normalized) - - // Compact teleport pill. Dims rather than disappearing when the - // geohash is incomplete so its position stays predictable. - val teleportColor = if (isValid) colorScheme.primary else palette.textTertiary - Surface( - 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)) + // Channels card: mesh + nearby + teleport input + item(key = "channels_card") { + Column { + AboutSectionLabel(text = stringResource(R.string.channels)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + ChannelOptionRow( + title = meshTitleWithCount(viewModel), + subtitle = stringResource( + R.string.location_bluetooth_subtitle, + bluetoothRangeString() + ), + isSelected = selectedChannel is ChannelID.Mesh, + participantCount = meshCount(viewModel), + titleColor = standardBlue, + titleBold = meshCount(viewModel) > 0, + onClick = { + locationManager.select(ChannelID.Mesh) onDismiss() - } else { - customError = context.getString(R.string.invalid_geohash) } - }, - enabled = isValid, - shape = RoundedCornerShape(8.dp), - color = palette.surfaceVariant + ) + + if (locationServicesEnabled) { + if (nearbyChannels.isNotEmpty()) { + nearbyChannels.forEach { channel -> + ChannelCardDivider() + val coverage = coverageString(channel.geohash.length) + val nameBase = locationNames[channel.level] + val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } + val subtitlePrefix = "#${channel.geohash} • $coverage" + val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 + val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) + + ChannelOptionRow( + title = geohashTitleWithCount(channel, participantCount), + subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""), + isSelected = isChannelSelected(channel, selectedChannel), + participantCount = participantCount, + titleColor = standardGreen, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = isBookmarked, + onClick = { bookmarksStore.toggle(channel.geohash) } + ) + }, + onClick = { + locationManager.setTeleported(false) + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } + ) + } + } else if (showNearbyLoading) { + ChannelCardDivider() + ChannelLoadingRow() + } + } + + ChannelCardDivider() + CustomGeohashRow( + customGeohash = customGeohash, + onGeohashChange = { value -> + val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() + customGeohash = value + .lowercase() + .replace("#", "") + .filter { it in allowed } + .take(12) + customError = null + }, + onFocusGained = { + coroutineScope.launch { + sheetState.expand() + listState.animateScrollToItem( + index = listState.layoutInfo.totalItemsCount - 1 + ) + } + }, + onOpenMap = { + val normalized = customGeohash.trim().lowercase().replace("#", "") + val initial = when { + normalized.isNotBlank() -> normalized + selectedChannel is ChannelID.Location -> + (selectedChannel as ChannelID.Location).channel.geohash + else -> "" + } + val intent = Intent(context, GeohashPickerActivity::class.java).apply { + putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial) + } + mapPickerLauncher.launch(intent) + }, + onTeleport = { + val normalized = customGeohash.trim().lowercase().replace("#", "") + if (validateGeohash(normalized)) { + val level = levelForLength(normalized.length) + val channel = GeohashChannel(level = level, geohash = normalized) + locationManager.setTeleported(true) + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } else { + customError = context.getString(R.string.invalid_geohash) + } + } + ) + } + } + + if (customError != null) { + Text( + text = customError!!, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = palette.accentRed, + modifier = Modifier.padding( + start = AboutHorizontalPadding + ChannelRowHorizontal, + top = 8.dp + ) + ) + } + } + } + + if (bookmarks.isNotEmpty()) { + item(key = "bookmarks_card") { + Column { + AboutSectionLabel(text = stringResource(R.string.bookmarked)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape ) { - Row( - modifier = Modifier - .height(32.dp) - .padding(horizontal = 10.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Filled.PinDrop, - contentDescription = stringResource(R.string.cd_teleport), - modifier = Modifier.size(14.dp), - tint = teleportColor - ) - Text( - text = stringResource(R.string.teleport).uppercase(), - fontSize = 11.sp, - letterSpacing = 0.8.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily.Monospace, - color = teleportColor - ) + Column { + bookmarks.forEachIndexed { index, gh -> + if (index > 0) ChannelCardDivider() + val level = levelForLength(gh.length) + val channel = GeohashChannel(level = level, geohash = gh) + val coverage = coverageString(gh.length) + val name = bookmarkNames[gh] + val subtitle = "#$gh • $coverage" + + (name?.let { " • ${formattedNamePrefix(level)}$it" } ?: "") + val participantCount = geohashParticipantCounts[gh] ?: 0 + + ChannelOptionRow( + title = geohashHashTitleWithCount(gh, participantCount), + subtitle = subtitle, + isSelected = isChannelSelected(channel, selectedChannel), + participantCount = participantCount, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = true, + onClick = { bookmarksStore.toggle(gh) } + ) + }, + onClick = { + val inRegional = availableChannels.any { it.geohash == gh } + locationManager.setTeleported( + !inRegional && availableChannels.isNotEmpty() + ) + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } + ) + LaunchedEffect(gh) { bookmarksStore.resolveNameIfNeeded(gh) } + } } } } } } - // Error message for custom geohash - if (customError != null) { - item(key = "geohash_error") { - Text( - text = customError!!, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = palette.accentRed, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding + 14.dp, vertical = 4.dp) - ) - } - } - - // Tor routing. Mirrors the toggle in About: this is where users actually - // think about IP exposure, because location channels are the feature that - // leaks it. item(key = "tor_routing") { val torProvider = remember { ArtiTorManager.getInstance() } val torAvailable = remember { torProvider.isTorAvailable() } var torMode by remember { mutableStateOf(TorPreferenceManager.get(context)) } - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) - .padding(top = 16.dp), - color = palette.surface, - shape = RoundedCornerShape(10.dp) - ) { - Row( + Column { + AboutSectionLabel(text = stringResource(R.string.about_network)) + Surface( modifier = Modifier .fillMaxWidth() - .padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text( - text = stringResource(R.string.location_tor_routing_title), - fontSize = 14.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = if (torAvailable) palette.textPrimary else palette.textTertiary - ) - BitchatBadge(text = stringResource(R.string.badge_recommended)) - } - Text( - text = stringResource(R.string.location_tor_routing_desc), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = palette.textSecondary - ) - } - - Switch( + ChannelSettingsToggleRow( + icon = Icons.Filled.Security, + title = stringResource(R.string.location_tor_routing_title), + subtitle = stringResource(R.string.location_tor_routing_desc), checked = torMode == TorMode.ON, + enabled = torAvailable, + statusIndicator = { + BitchatBadge(text = stringResource(R.string.badge_recommended)) + }, onCheckedChange = { enabled -> if (torAvailable) { torMode = if (enabled) TorMode.ON else TorMode.OFF TorPreferenceManager.set(context, torMode) } - }, - enabled = torAvailable, - colors = SwitchDefaults.colors( - checkedThumbColor = Color.White, - checkedTrackColor = palette.accentGreen, - uncheckedThumbColor = Color.White, - uncheckedTrackColor = palette.surfaceVariant + } + ) + } + if (!torAvailable) { + Text( + text = stringResource(R.string.tor_not_available_in_this_build), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = palette.textTertiary, + modifier = Modifier.padding( + start = AboutHorizontalPadding + ChannelRowHorizontal, + top = 8.dp ) ) } } } - // Location services toggle button item(key = "location_toggle") { SheetDestructiveButton( text = if (locationServicesEnabled) { @@ -587,15 +450,14 @@ fun LocationChannelsSheet( } }, modifier = Modifier.padding( - start = SheetHorizontalPadding, - end = SheetHorizontalPadding, - top = 16.dp + start = AboutHorizontalPadding, + end = AboutHorizontalPadding, + top = 24.dp ) ) } } - // TopBar (animated) BitchatSheetTopBar( onClose = onDismiss, modifier = modifier.align(Alignment.TopCenter), @@ -609,19 +471,17 @@ fun LocationChannelsSheet( } } - // Lifecycle management: when presented, manage location updates DisposableEffect(isPresented, permissionState, locationServicesEnabled) { - if (isPresented && permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { + if (isPresented && + permissionState == LocationChannelManager.PermissionState.AUTHORIZED && + locationServicesEnabled + ) { locationManager.refreshChannels() locationManager.beginLiveRefresh() } - - onDispose { - locationManager.endLiveRefresh() - } + 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() @@ -631,20 +491,30 @@ fun LocationChannelsSheet( } } - // Ensure cleanup when the composable is destroyed (e.g. removed from parent composition) DisposableEffect(Unit) { - onDispose { - viewModel.endGeohashSampling() - } + onDispose { viewModel.endGeohashSampling() } } } @Composable -private fun ChannelRow( +private fun ChannelCardDivider() { + val palette = LocalBitchatPalette.current + HorizontalDivider( + modifier = Modifier.padding(start = ChannelDividerInset), + thickness = 1.dp, + color = palette.outlineVariant + ) +} + +/** + * Single channel option — settings-row geometry: 22.dp leading slot, title + subtitle, trailing. + * Selected state is a 12.dp green dot centered in the leading slot (icon-sized footprint). + */ +@Composable +private fun ChannelOptionRow( title: String, subtitle: String, isSelected: Boolean, - /** Drives the count animation; the number itself is already baked into [title]. */ participantCount: Int, titleColor: Color? = null, titleBold: Boolean = false, @@ -652,95 +522,301 @@ private fun ChannelRow( onClick: () -> Unit ) { val palette = LocalBitchatPalette.current + val (baseTitle, countSuffix) = splitTitleAndCount(title) - // Selected rows step up to surfaceVariant rather than showing a trailing checkmark: with a - // leading status dot the fill alone communicates state and keeps the row's right edge free - // for the bookmark control. - val containerColor by animateColorAsState( - targetValue = if (isSelected) palette.surfaceVariant else palette.surface, - animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), - label = "channelRowContainer" - ) - - Surface( - onClick = onClick, - color = containerColor, - shape = RoundedCornerShape(10.dp), + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) + .clickable(onClick = onClick) + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center ) { if (isSelected) { Box( modifier = Modifier - .padding(end = 10.dp) - .size(6.dp) + .size(ChannelSelectedDot) .background(palette.accentGreen, CircleShape) ) } + } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) + Spacer(modifier = Modifier.width(ChannelLeadingGutter)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically ) { - // Split title to handle count part with smaller font (iOS style) - val (baseTitle, countSuffix) = splitTitleAndCount(title) - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = baseTitle, - fontSize = 14.sp, - fontFamily = FontFamily.Monospace, - fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium, - color = titleColor ?: palette.textPrimary - ) - - countSuffix?.let { count -> - AnimatedCountLabel( - // Participant counts are polled from the relays, so they change - // while the sheet is open. - count = participantCount, - text = count, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = palette.textSecondary - ) - } - } - Text( - text = subtitle, - fontSize = 11.sp, + text = baseTitle, + fontSize = 14.sp, fontFamily = FontFamily.Monospace, - color = palette.textSecondary + fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium, + color = titleColor ?: palette.textPrimary ) + countSuffix?.let { count -> + AnimatedCountLabel( + count = participantCount, + text = count, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = palette.textSecondary + ) + } } + Text( + text = subtitle, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + lineHeight = 17.sp, + color = palette.textSecondary + ) + } - if (trailingContent != null) { - trailingContent() - } + if (trailingContent != null) { + Spacer(modifier = Modifier.width(8.dp)) + trailingContent() } } } -// MARK: - Helper Functions (matching iOS implementation) +@Composable +private fun ChannelBookmarkButton( + bookmarked: Boolean, + onClick: () -> Unit +) { + val palette = LocalBitchatPalette.current + Box( + modifier = Modifier + .size(36.dp) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (bookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, + contentDescription = stringResource( + if (bookmarked) R.string.cd_remove_bookmark else R.string.cd_add_bookmark + ), + tint = if (bookmarked) palette.accentGreen else palette.textSecondary, + modifier = Modifier.size(22.dp) + ) + } +} + +@Composable +private fun ChannelLoadingRow() { + val palette = LocalBitchatPalette.current + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + horizontalArrangement = Arrangement.spacedBy(ChannelLeadingGutter), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = palette.textSecondary + ) + } + Text( + text = stringResource(R.string.finding_nearby_channels), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = palette.textSecondary + ) + } +} + +/** + * Teleport / custom geohash control unified into the same card as channel options. + */ +@Composable +private fun CustomGeohashRow( + customGeohash: String, + onGeohashChange: (String) -> Unit, + onFocusGained: () -> Unit, + onOpenMap: () -> Unit, + onTeleport: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + val normalized = customGeohash.trim().lowercase().replace("#", "") + val isValid = validateGeohash(normalized) + val teleportColor = if (isValid) colorScheme.primary else palette.textTertiary + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.PinDrop, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(ChannelLeadingGutter)) + + Text( + text = stringResource(R.string.hash_symbol), + fontSize = 14.sp, + fontFamily = FontFamily.Monospace, + color = palette.textTertiary + ) + + Spacer(modifier = Modifier.width(4.dp)) + + BasicTextField( + value = customGeohash, + onValueChange = onGeohashChange, + textStyle = TextStyle( + fontSize = 14.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.primary + ), + cursorBrush = SolidColor(colorScheme.primary), + singleLine = true, + modifier = Modifier + .weight(1f) + .onFocusChanged { if (it.isFocused) onFocusGained() }, + decorationBox = { inner -> + if (customGeohash.isEmpty()) { + Text( + text = stringResource(R.string.geohash_placeholder), + fontSize = 14.sp, + fontFamily = FontFamily.Monospace, + color = palette.textTertiary + ) + } + inner() + } + ) + + Box( + modifier = Modifier + .size(36.dp) + .clickable(onClick = onOpenMap), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Map, + contentDescription = stringResource(R.string.cd_open_map), + tint = palette.textSecondary, + modifier = Modifier.size(22.dp) + ) + } + + Surface( + onClick = onTeleport, + enabled = isValid, + shape = RoundedCornerShape(8.dp), + color = palette.surfaceVariant + ) { + Text( + text = stringResource(R.string.teleport).uppercase(), + fontSize = 11.sp, + letterSpacing = 0.8.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily.Monospace, + color = teleportColor, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp) + ) + } + } +} + +/** Settings-toggle row geometry (icon + title/subtitle + switch), local copy for this sheet. */ +@Composable +private fun ChannelSettingsToggleRow( + icon: ImageVector, + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, + statusIndicator: (@Composable () -> Unit)? = null +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (enabled) colorScheme.primary else palette.textTertiary, + modifier = Modifier.size(ChannelLeadingSlot) + ) + Spacer(modifier = Modifier.width(ChannelLeadingGutter)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (enabled) palette.textPrimary else palette.textTertiary + ) + statusIndicator?.invoke() + } + Text( + text = subtitle, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 17.sp, + color = if (enabled) palette.textSecondary else palette.textTertiary + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = checked, + onCheckedChange = { if (enabled) onCheckedChange(it) }, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = palette.accentGreen, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = palette.surfaceVariant + ) + ) + } +} + +// MARK: - Helper Functions private fun splitTitleAndCount(title: String): Pair { val lastBracketIndex = title.lastIndexOf('[') return if (lastBracketIndex != -1) { - val prefix = title.substring(0, lastBracketIndex).trim() - val suffix = title.substring(lastBracketIndex) - Pair(prefix, suffix) + Pair(title.substring(0, lastBracketIndex).trim(), title.substring(lastBracketIndex)) } else { Pair(title, null) } @@ -749,55 +825,47 @@ private fun splitTitleAndCount(title: String): Pair { @Composable private fun meshTitleWithCount(viewModel: ChatViewModel): String { val meshCount = meshCount(viewModel) - val ctx = androidx.compose.ui.platform.LocalContext.current - val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, meshCount, meshCount) - val meshLabel = stringResource(com.bitchat.android.R.string.mesh_title) + val ctx = LocalContext.current + val peopleText = ctx.resources.getQuantityString(R.plurals.people_count, meshCount, meshCount) + val meshLabel = stringResource(R.string.mesh_title) return "$meshLabel [$peopleText]" } private fun meshCount(viewModel: ChatViewModel): Int { val myID = viewModel.myPeerID - return viewModel.connectedPeers.value?.count { peerID -> - peerID != myID - } ?: 0 + return viewModel.connectedPeers.value?.count { it != myID } ?: 0 } @Composable private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String { - val ctx = androidx.compose.ui.platform.LocalContext.current - - // For high precision channels (Neighborhood, Block) where we don't broadcast presence, - // show "? people" instead of "0 people" to avoid misleading "nobody is here" indication. + val ctx = LocalContext.current val isHighPrecision = channel.level.precision > 5 val peopleText = if (isHighPrecision && participantCount == 0) { - ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?") + ctx.resources.getQuantityString(R.plurals.people_count, 0, 0).replace("0", "?") } else { - ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) + ctx.resources.getQuantityString(R.plurals.people_count, participantCount, participantCount) } - val levelName = when (channel.level) { - com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes - com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block) - com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD -> stringResource(com.bitchat.android.R.string.location_level_neighborhood) - com.bitchat.android.geohash.GeohashChannelLevel.CITY -> stringResource(com.bitchat.android.R.string.location_level_city) - com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE -> stringResource(com.bitchat.android.R.string.location_level_province) - com.bitchat.android.geohash.GeohashChannelLevel.REGION -> stringResource(com.bitchat.android.R.string.location_level_region) + GeohashChannelLevel.BUILDING -> "Building" + GeohashChannelLevel.BLOCK -> stringResource(R.string.location_level_block) + GeohashChannelLevel.NEIGHBORHOOD -> stringResource(R.string.location_level_neighborhood) + GeohashChannelLevel.CITY -> stringResource(R.string.location_level_city) + GeohashChannelLevel.PROVINCE -> stringResource(R.string.location_level_province) + GeohashChannelLevel.REGION -> stringResource(R.string.location_level_region) } return "$levelName [$peopleText]" } @Composable private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String { - val ctx = androidx.compose.ui.platform.LocalContext.current + val ctx = LocalContext.current val level = levelForLength(geohash.length) val isHighPrecision = level.precision > 5 - val peopleText = if (isHighPrecision && participantCount == 0) { - ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?") + ctx.resources.getQuantityString(R.plurals.people_count, 0, 0).replace("0", "?") } else { - ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) + ctx.resources.getQuantityString(R.plurals.people_count, participantCount, participantCount) } - return "#$geohash [$peopleText]" } @@ -821,13 +889,12 @@ private fun levelForLength(length: Int): GeohashChannelLevel { 5 -> GeohashChannelLevel.CITY 6 -> GeohashChannelLevel.NEIGHBORHOOD 7 -> GeohashChannelLevel.BLOCK - 8 -> GeohashChannelLevel.BUILDING // iOS: precision 8 for building-level + 8 -> GeohashChannelLevel.BUILDING else -> if (length > 8) GeohashChannelLevel.BUILDING else GeohashChannelLevel.BLOCK } } private fun coverageString(precision: Int): String { - // Approximate max cell dimension at equator for a given geohash length val maxMeters = when (precision) { 2 -> 1_250_000.0 3 -> 156_000.0 @@ -840,10 +907,7 @@ private fun coverageString(precision: Int): String { 10 -> 1.19 else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble()) } - - // Use metric system for simplicity (could be made locale-aware) - val km = maxMeters / 1000.0 - return "~${formatDistance(km)} km" + return "~${formatDistance(maxMeters / 1000.0)} km" } private fun formatDistance(value: Double): String { @@ -854,11 +918,6 @@ private fun formatDistance(value: Double): String { } } -private fun bluetoothRangeString(): String { - // Approximate Bluetooth LE range for typical mobile devices - return "~10–50 m" -} +private fun bluetoothRangeString(): String = "~10–50 m" -private fun formattedNamePrefix(level: GeohashChannelLevel): String { - return "~" -} +private fun formattedNamePrefix(level: GeohashChannelLevel): String = "~" diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index ad763aa7..aac25e91 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -3,6 +3,17 @@ 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 @@ -35,6 +46,7 @@ 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 @@ -45,6 +57,7 @@ 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.R @@ -55,6 +68,7 @@ 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.LocalBitchatPalette import com.bitchat.android.ui.theme.MessageBodyTextStyle import com.bitchat.android.ui.theme.MessageSenderTextStyle @@ -69,6 +83,82 @@ import java.util.Locale * Extracted from ChatScreen.kt for better organization */ +/** How far a newly arrived message travels up into place. */ +private val MessageEntrySlide = 14.dp + +/** + * Entry motion for a new message: quick, with just enough damping to settle rather than snap. + * Runs entirely on a graphics layer, so it costs a transform and nothing else. + */ +private val MessageEntrySpec: AnimationSpec = + spring(dampingRatio = 0.85f, stiffness = 1200f) + +/** + * Motion for messages being pushed out of the way by an arrival. Softer than the entry so the + * conversation glides up while the new message itself lands crisply. + */ +private val MessagePlacementSpec: FiniteAnimationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = IntOffset.VisibilityThreshold +) + +/** Removals are not worth dwelling on. */ +private val MessageFadeOutSpec: FiniteAnimationSpec = tween(BitchatMotion.QUICK_MS) + +/** + * Above this many simultaneous arrivals, entry animations are skipped. + * + * A history sync or a channel switch can append hundreds of messages in one frame. Animating each + * would spend the entire frame budget on motion nobody asked to see, so a burst is adopted + * silently and only conversational-pace arrivals animate. + */ +internal const val MaxAnimatedArrivals = 6 + +/** + * Remembers which message ids have already been seen, so genuine arrivals can be told apart from + * items merely scrolling back into view. + * + * This distinction is the whole reason the entry animation is usable: `LazyColumn` composes items + * on demand, so animating on first composition would replay the animation for every old message + * the user scrolled back to. + */ +internal class MessageArrivalTracker { + val known = HashSet() + var seeded = false +} + +/** + * Ids that should animate in on this composition pass. + * + * Deliberately computed during composition rather than in a `LaunchedEffect`: effects run *after* + * the frame's composition, by which point a new message's item has already composed and would + * have missed its cue. + */ +internal fun MessageArrivalTracker.arrivals(messages: List): Set { + if (!seeded) { + // First load adopts everything silently. A whole screenful animating on open reads as a + // glitch, not a flourish. + messages.forEach { known.add(it.id) } + seeded = true + return emptySet() + } + + // `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 { + added.isEmpty() || added.size > MaxAnimatedArrivals -> emptySet() + else -> added.mapTo(HashSet(added.size)) { it.id } + } +} + @Composable fun MessagesList( messages: List, @@ -129,6 +219,13 @@ 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. + val arrivalTracker = remember { MessageArrivalTracker() } + val enteringIds = remember(messages.size, messages.lastOrNull()?.id) { + arrivalTracker.arrivals(messages) + } + val layoutDirection = LocalLayoutDirection.current LazyColumn( state = listState, @@ -158,6 +255,24 @@ fun MessagesList( 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, @@ -171,7 +286,18 @@ fun MessagesList( onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, - onImageClick = onImageClick + 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 = MessagePlacementSpec, + fadeOutSpec = MessageFadeOutSpec + ) + .then(entryModifier) ) } } @@ -189,13 +315,14 @@ fun MessageItem( onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, onCancelTransfer: ((BitchatMessage) -> Unit)? = null, - onImageClick: ((String, List, Int) -> Unit)? = null + onImageClick: ((String, List, Int) -> Unit)? = null, + modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) } - + Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(top = topSpacing), verticalArrangement = Arrangement.spacedBy(0.dp) @@ -557,53 +684,40 @@ internal fun TextMessageLayout( fun DeliveryStatusIcon(status: DeliveryStatus) { val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current - - 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 = palette.accentBlue, - fontWeight = FontWeight.Bold - ) - } - is DeliveryStatus.Failed -> { - Text( - text = stringResource(R.string.status_failed), - fontSize = 10.sp, - color = palette.accentRed - ) - } - 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, palette.accentBlue, FontWeight.Bold) + DeliveryStatus.Failed::class -> + Triple(R.string.status_failed, palette.accentRed, FontWeight.Normal) + // A single subdued check, without the numeric label. + else -> + Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) } + + Text( + text = stringResource(text), + fontSize = 10.sp, + color = color, + fontWeight = weight + ) } } diff --git a/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt new file mode 100644 index 00000000..0c848281 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt @@ -0,0 +1,141 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The arrival tracker decides which messages animate in. + * + * Getting it wrong is not subtle: animate too eagerly and every old message replays its entrance + * whenever `LazyColumn` recycles it back into view, which looks broken during a scroll. + */ +class MessageArrivalTrackerTest { + + private var seq = 0 + + private fun msg(id: String = "m${seq++}") = BitchatMessage( + id = id, + sender = "alice", + content = "hello", + timestamp = Date(0), + ) + + @Test + fun `first load adopts everything silently`() { + val tracker = MessageArrivalTracker() + val initial = listOf(msg("a"), msg("b"), msg("c")) + + assertTrue( + "a whole screenful animating on open reads as a glitch", + tracker.arrivals(initial).isEmpty() + ) + } + + @Test + fun `a message appended after the first load animates`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a"), msg("b")) + tracker.arrivals(messages) + + messages += msg("c") + assertEquals(setOf("c"), tracker.arrivals(messages)) + } + + @Test + fun `an empty first load still lets the first real message animate`() { + // A brand new conversation seeds with nothing, so its opening message is a genuine arrival. + val tracker = MessageArrivalTracker() + tracker.arrivals(emptyList()) + + assertEquals(setOf("a"), tracker.arrivals(listOf(msg("a")))) + } + + @Test + fun `re-presenting the same list animates nothing`() { + val tracker = MessageArrivalTracker() + val messages = listOf(msg("a"), msg("b")) + tracker.arrivals(messages) + + assertTrue(tracker.arrivals(messages).isEmpty()) + assertTrue(tracker.arrivals(messages).isEmpty()) + } + + @Test + fun `an already-seen message never animates again`() { + // Stands in for LazyColumn recycling an old row back into view mid-scroll. + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a")) + tracker.arrivals(messages) + messages += msg("b") + tracker.arrivals(messages) + + assertTrue(tracker.arrivals(messages).isEmpty()) + } + + @Test + fun `only the new messages in a batch animate`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a")) + tracker.arrivals(messages) + + messages += listOf(msg("b"), msg("c")) + assertEquals(setOf("b", "c"), tracker.arrivals(messages)) + } + + @Test + fun `a burst larger than the cap is adopted silently`() { + // History sync: animating hundreds of rows would spend the whole frame budget on motion + // nobody asked to see. + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("seed")) + tracker.arrivals(messages) + + repeat(MaxAnimatedArrivals + 1) { messages += msg("burst$it") } + assertTrue(tracker.arrivals(messages).isEmpty()) + } + + @Test + fun `a burst exactly at the cap still animates`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("seed")) + tracker.arrivals(messages) + + repeat(MaxAnimatedArrivals) { messages += msg("b$it") } + assertEquals(MaxAnimatedArrivals, tracker.arrivals(messages).size) + } + + @Test + fun `clearing the conversation prunes the known set`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a"), msg("b"), msg("c")) + tracker.arrivals(messages) + + tracker.arrivals(emptyList()) + assertTrue("stale ids would leak and grow without bound", tracker.known.isEmpty()) + } + + @Test + fun `a message re-added after a clear animates again`() { + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"))) + tracker.arrivals(emptyList()) + + assertEquals(setOf("a"), tracker.arrivals(listOf(msg("a")))) + } + + @Test + fun `the known set never outgrows the conversation`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a"), msg("b")) + tracker.arrivals(messages) + + // Simulate a channel switch: entirely different messages, same list size. + val switched = listOf(msg("x"), msg("y")) + tracker.arrivals(switched) + + assertEquals(setOf("x", "y"), tracker.known) + } +} From 521784b3594470948424470c389f41e02a01b930 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:44:13 +0200 Subject: [PATCH 10/70] nicer --- .../android/ui/LocationChannelsSheet.kt | 173 ++++++++++++------ app/src/main/res/values/strings.xml | 5 +- 2 files changed, 120 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 76b3c020..43038419 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -2,6 +2,7 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.Hub import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.filled.PinDrop import androidx.compose.material.icons.filled.Security @@ -147,31 +148,6 @@ fun LocationChannelsSheet( contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(0.dp) ) { - item(key = "header") { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - SheetHeaderBadge(icon = Icons.Outlined.Public) - Text( - text = stringResource(R.string.location_channels_heading), - fontSize = 20.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = colorScheme.primary - ) - Text( - text = stringResource(R.string.location_channels_desc), - fontSize = 12.sp, - lineHeight = 17.sp, - fontFamily = FontFamily.Monospace, - color = palette.textSecondary - ) - } - } - if (locationServicesEnabled && permissionState == LocationChannelManager.PermissionState.DENIED ) { @@ -180,7 +156,7 @@ fun LocationChannelsSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) - .padding(top = 16.dp), + .padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( @@ -208,38 +184,64 @@ fun LocationChannelsSheet( } } - // Channels card: mesh + nearby + teleport input - item(key = "channels_card") { + // Mesh section: icon + title header, offline subtitle, then selection card + item(key = "mesh_card") { Column { - AboutSectionLabel(text = stringResource(R.string.channels)) + ChannelSectionHeader( + icon = Icons.Filled.Hub, + title = stringResource(R.string.mesh_title), + subtitle = stringResource(R.string.mesh_section_subtitle), + modifier = Modifier.padding(top = 8.dp) + ) Surface( modifier = Modifier .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + ChannelOptionRow( + title = meshTitleWithCount(viewModel), + subtitle = stringResource( + R.string.location_bluetooth_subtitle, + bluetoothRangeString() + ), + isSelected = selectedChannel is ChannelID.Mesh, + participantCount = meshCount(viewModel), + titleColor = standardBlue, + titleBold = meshCount(viewModel) > 0, + onClick = { + locationManager.select(ChannelID.Mesh) + onDismiss() + } + ) + } + } + } + + // Location channels: globe + title, geohash subtitle, nearby levels + teleport + item(key = "channels_card") { + Column { + ChannelSectionHeader( + icon = Icons.Outlined.Public, + title = stringResource(R.string.location_channels_heading), + subtitle = stringResource(R.string.location_channels_desc), + modifier = Modifier.padding(top = 20.dp) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), color = palette.surface, shape = AboutCardShape ) { Column { - ChannelOptionRow( - title = meshTitleWithCount(viewModel), - subtitle = stringResource( - R.string.location_bluetooth_subtitle, - bluetoothRangeString() - ), - isSelected = selectedChannel is ChannelID.Mesh, - participantCount = meshCount(viewModel), - titleColor = standardBlue, - titleBold = meshCount(viewModel) > 0, - onClick = { - locationManager.select(ChannelID.Mesh) - onDismiss() - } - ) - if (locationServicesEnabled) { if (nearbyChannels.isNotEmpty()) { - nearbyChannels.forEach { channel -> - ChannelCardDivider() + nearbyChannels.forEachIndexed { index, channel -> + if (index > 0) ChannelCardDivider() val coverage = coverageString(channel.geohash.length) val nameBase = locationNames[channel.level] val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } @@ -267,13 +269,13 @@ fun LocationChannelsSheet( } ) } - } else if (showNearbyLoading) { ChannelCardDivider() + } else if (showNearbyLoading) { ChannelLoadingRow() + ChannelCardDivider() } } - ChannelCardDivider() CustomGeohashRow( customGeohash = customGeohash, onGeohashChange = { value -> @@ -506,6 +508,53 @@ private fun ChannelCardDivider() { ) } +/** + * Section title row: icon + name on one line, optional short subtitle beneath. + */ +@Composable +private fun ChannelSectionHeader( + icon: ImageVector, + title: String, + subtitle: String, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + Text( + text = title, + fontSize = 17.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + color = colorScheme.primary + ) + } + Text( + text = subtitle, + fontSize = 12.sp, + lineHeight = 17.sp, + fontFamily = FontFamily.Monospace, + color = palette.textSecondary + ) + } +} + /** * Single channel option — settings-row geometry: 22.dp leading slot, title + subtitle, trailing. * Selected state is a 12.dp green dot centered in the leading slot (icon-sized footprint). @@ -518,9 +567,11 @@ private fun ChannelOptionRow( participantCount: Int, titleColor: Color? = null, titleBold: Boolean = false, + leadingIcon: ImageVector? = null, trailingContent: (@Composable (() -> Unit))? = null, onClick: () -> Unit ) { + val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current val (baseTitle, countSuffix) = splitTitleAndCount(title) @@ -535,12 +586,22 @@ private fun ChannelOptionRow( modifier = Modifier.size(ChannelLeadingSlot), contentAlignment = Alignment.Center ) { - if (isSelected) { - Box( - modifier = Modifier - .size(ChannelSelectedDot) - .background(palette.accentGreen, CircleShape) - ) + when { + isSelected -> { + Box( + modifier = Modifier + .size(ChannelSelectedDot) + .background(palette.accentGreen, CircleShape) + ) + } + leadingIcon != null -> { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3b7a7022..d0987249 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -241,9 +241,10 @@ - #Location Channels + Channels Location Channels - Chat with people near you using geohash channels. Only a coarse geohash is shared, never exact GPS. Do not screenshot or share this screen to protect your privacy. + Chat nearby over geohash channels. Only a coarse location is shared, never exact GPS. + Offline mesh via Bluetooth. No internet required. Nearby From 3dbe9c1c52412ddf3e5163b7adea7c65c89bdc2f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:50:46 +0200 Subject: [PATCH 11/70] lists --- .../com/bitchat/android/ui/AboutSections.kt | 69 +++ .../bitchat/android/ui/GeohashPeopleList.kt | 217 +++++---- .../android/ui/LocationChannelsSheet.kt | 81 +--- .../bitchat/android/ui/MeshPeerListSheet.kt | 453 +++++++++--------- 4 files changed, 423 insertions(+), 397 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index af17a509..8dfb63fb 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -68,6 +68,15 @@ 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 +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. */ @@ -99,6 +108,66 @@ internal fun AboutSectionLabel( ) } +/** + * Icon + title on one line, optional short subtitle beneath. Used by location / network sheets. + */ +@Composable +internal fun SheetIconSectionHeader( + icon: ImageVector, + title: String, + subtitle: String? = null, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + Text( + text = title, + fontSize = 17.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + color = colorScheme.primary + ) + } + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + fontSize = 12.sp, + lineHeight = 17.sp, + fontFamily = FontFamily.Monospace, + color = palette.textSecondary + ) + } + } +} + +/** Inset divider used inside grouped sheet cards (aligns with text column after the leading slot). */ +@Composable +internal fun SheetCardDivider() { + val palette = LocalBitchatPalette.current + HorizontalDivider( + modifier = Modifier.padding(start = SheetRowDividerInset), + thickness = 1.dp, + color = palette.outlineVariant + ) +} + /** * Centered app identity block: logo, wordmark, tagline, version. */ diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index cb3cc476..b29c12bc 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -1,13 +1,16 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material.icons.outlined.Public import android.util.Log -import androidx.compose.foundation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -17,25 +20,20 @@ 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 com.bitchat.android.ui.theme.LocalBitchatPalette -import java.util.* import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 ) @@ -46,32 +44,41 @@ fun GeohashPeopleList( 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 nickname by viewModel.nickname.collectAsStateWithLifecycle() val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() - + val palette = LocalBitchatPalette.current Column(modifier = modifier) { if (geohashPeople.isEmpty()) { - SheetSectionLabel(text = stringResource(R.string.section_on_location)) - // Empty state - matches iOS "nobody around..." - Text( - text = stringResource(R.string.nobody_around), - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = palette.textTertiary, - modifier = Modifier.padding( - horizontal = SheetHorizontalPadding + 14.dp, - vertical = 12.dp - ) + SheetIconSectionHeader( + icon = Icons.Outlined.LocationOn, + title = stringResource(R.string.section_on_location) ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + Text( + text = stringResource(R.string.nobody_around), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = palette.textTertiary, + modifier = Modifier.padding( + horizontal = SheetRowHorizontal, + vertical = SheetRowVertical + ) + ) + } } else { - // Get current geohash identity for "me" detection val myHex = remember(selectedLocationChannel) { when (val channel = selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> { @@ -89,30 +96,26 @@ fun GeohashPeopleList( 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 + else -> b.lastSeen.compareTo(a.lastSeen) } } } - // Compute base name collisions to decide whether to show hash suffix val baseNameCounts = remember(geohashPeople) { val counts = mutableMapOf() geohashPeople.forEach { person -> - val (b, _) = com.bitchat.android.ui.splitSuffix(person.displayName) + val (b, _) = splitSuffix(person.displayName) counts[b] = (counts[b] ?: 0) + 1 } counts } - - // Split by presence: people physically in the geohash versus people who teleported - // in. Mixing them hid the fact that a "nearby" channel can contain remote users. + fun personTeleported(person: GeoPerson): Boolean = if (person.id == myHex) { isTeleported } else { @@ -132,11 +135,9 @@ fun GeohashPeopleList( nickname = nickname, colorScheme = colorScheme, viewModel = viewModel, - showHashSuffix = (baseNameCounts[com.bitchat.android.ui.splitSuffix(person.displayName).first] ?: 0) > 1, + showHashSuffix = (baseNameCounts[splitSuffix(person.displayName).first] ?: 0) > 1, onTap = { if (person.id != myHex) { - // TODO: Re-enable when NIP-17 geohash DM issues are fixed - // Start geohash DM (iOS-compatible) viewModel.startGeohashDM(person.id) onTapPerson() } @@ -145,13 +146,48 @@ fun GeohashPeopleList( } if (localPeople.isNotEmpty()) { - SheetSectionLabel(text = stringResource(R.string.section_on_location)) - localPeople.forEach { personRow(it) } + SheetIconSectionHeader( + icon = Icons.Outlined.LocationOn, + title = stringResource(R.string.section_on_location) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + localPeople.forEachIndexed { index, person -> + if (index > 0) SheetCardDivider() + personRow(person) + } + } + } } if (teleportedPeople.isNotEmpty()) { - SheetSectionLabel(text = stringResource(R.string.section_teleported_in)) - teleportedPeople.forEach { personRow(it) } + SheetIconSectionHeader( + icon = Icons.Outlined.Explore, + title = stringResource(R.string.section_teleported_in), + modifier = Modifier.padding(top = if (localPeople.isNotEmpty()) 20.dp else 0.dp) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + teleportedPeople.forEachIndexed { index, person -> + if (index > 0) SheetCardDivider() + personRow(person) + } + } + } } } } @@ -172,69 +208,57 @@ private fun GeohashPersonItem( ) { val palette = LocalBitchatPalette.current - Surface( - onClick = onTap, - color = palette.surface, - shape = RoundedCornerShape(10.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) - ) { + val (iconName, iconColor) = when { + isMe && isMyTeleported -> "face.dashed" to palette.accentOrange + isTeleported -> "face.dashed" to palette.textSecondary + isMe -> "face.smiling" to palette.accentOrange + else -> "face.smiling" to palette.textSecondary + } + val statusIcon = when (iconName) { + "face.dashed" -> Icons.Outlined.Explore + else -> Icons.Outlined.LocationOn + } + + val (baseNameRaw, suffixRaw) = splitSuffix(person.displayName) + val baseName = truncateNickname(baseNameRaw) + val suffix = if (showHashSuffix) suffixRaw else "" + val assignedColor = viewModel.colorForNostrPubkey(person.id, palette.isDark) + val baseColor = if (isMe) palette.accentOrange else assignedColor + Row( modifier = Modifier .fillMaxWidth() - .heightIn(min = 44.dp) - .padding(horizontal = 14.dp), + .clickable(onClick = onTap) + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), 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(18.dp), - tint = palette.accentOrange - ) - } else { - // Face icon with teleportation state - val (iconName, iconColor) = when { - isMe && isMyTeleported -> "face.dashed" to palette.accentOrange - isTeleported -> "face.dashed" to palette.textSecondary - isMe -> "face.smiling" to palette.accentOrange - else -> "face.smiling" to palette.textSecondary + 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( + imageVector = statusIcon, + contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User", + modifier = Modifier.size(22.dp), + tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) + ) } - - // Teleported users get the pin (they chose this location); locals get the person. - 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(18.dp), - tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) - ) } - - 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 assignedColor = viewModel.colorForNostrPubkey(person.id, palette.isDark) - val baseColor = if (isMe) palette.accentOrange else assignedColor - + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { - // Base name with peer-specific color Text( text = baseName, fontFamily = FontFamily.Monospace, @@ -245,7 +269,6 @@ private fun GeohashPersonItem( overflow = TextOverflow.Ellipsis ) - // Suffix (collision-resistant #abcd) in lighter shade if (suffix.isNotEmpty()) { Text( text = suffix, @@ -256,7 +279,6 @@ private fun GeohashPersonItem( ) } - // "You" indicator for current user if (isMe) { Text( text = stringResource(R.string.you_suffix), @@ -267,7 +289,4 @@ private fun GeohashPersonItem( } } } - } } - - diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 43038419..28ecd8a0 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -60,13 +60,13 @@ import kotlinx.coroutines.launch * Leading column width matching settings rows: 22.dp glyph + 16.dp gutter before title text. * Selection dots and row icons sit in this column so every option lines up with About settings. */ -private val ChannelLeadingSlot = 22.dp -private val ChannelLeadingGutter = 16.dp -private val ChannelRowHorizontal = 16.dp -private val ChannelRowVertical = 13.dp -private val ChannelDividerInset = ChannelRowHorizontal + ChannelLeadingSlot + ChannelLeadingGutter +private val ChannelLeadingSlot = SheetRowLeadingSlot +private val ChannelLeadingGutter = SheetRowLeadingGutter +private val ChannelRowHorizontal = SheetRowHorizontal +private val ChannelRowVertical = SheetRowVertical +private val ChannelDividerInset = SheetRowDividerInset /** 2× the previous 6.dp selected indicator; sits centered in [ChannelLeadingSlot]. */ -private val ChannelSelectedDot = 12.dp +private val ChannelSelectedDot = SheetRowSelectedDot /** * Location Channels sheet: grouped card rows matching About → Settings. @@ -187,7 +187,7 @@ fun LocationChannelsSheet( // Mesh section: icon + title header, offline subtitle, then selection card item(key = "mesh_card") { Column { - ChannelSectionHeader( + SheetIconSectionHeader( icon = Icons.Filled.Hub, title = stringResource(R.string.mesh_title), subtitle = stringResource(R.string.mesh_section_subtitle), @@ -223,7 +223,7 @@ fun LocationChannelsSheet( // Location channels: globe + title, geohash subtitle, nearby levels + teleport item(key = "channels_card") { Column { - ChannelSectionHeader( + SheetIconSectionHeader( icon = Icons.Outlined.Public, title = stringResource(R.string.location_channels_heading), subtitle = stringResource(R.string.location_channels_desc), @@ -241,7 +241,7 @@ fun LocationChannelsSheet( if (locationServicesEnabled) { if (nearbyChannels.isNotEmpty()) { nearbyChannels.forEachIndexed { index, channel -> - if (index > 0) ChannelCardDivider() + if (index > 0) SheetCardDivider() val coverage = coverageString(channel.geohash.length) val nameBase = locationNames[channel.level] val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } @@ -269,10 +269,10 @@ fun LocationChannelsSheet( } ) } - ChannelCardDivider() + SheetCardDivider() } else if (showNearbyLoading) { ChannelLoadingRow() - ChannelCardDivider() + SheetCardDivider() } } @@ -352,7 +352,7 @@ fun LocationChannelsSheet( ) { Column { bookmarks.forEachIndexed { index, gh -> - if (index > 0) ChannelCardDivider() + if (index > 0) SheetCardDivider() val level = levelForLength(gh.length) val channel = GeohashChannel(level = level, geohash = gh) val coverage = coverageString(gh.length) @@ -498,63 +498,6 @@ fun LocationChannelsSheet( } } -@Composable -private fun ChannelCardDivider() { - val palette = LocalBitchatPalette.current - HorizontalDivider( - modifier = Modifier.padding(start = ChannelDividerInset), - thickness = 1.dp, - color = palette.outlineVariant - ) -} - -/** - * Section title row: icon + name on one line, optional short subtitle beneath. - */ -@Composable -private fun ChannelSectionHeader( - icon: ImageVector, - title: String, - subtitle: String, - modifier: Modifier = Modifier -) { - val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current - - Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(22.dp) - ) - Text( - text = title, - fontSize = 17.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - color = colorScheme.primary - ) - } - Text( - text = subtitle, - fontSize = 12.sp, - lineHeight = 17.sp, - fontFamily = FontFamily.Monospace, - color = palette.textSecondary - ) - } -} - /** * Single channel option — settings-row geometry: 22.dp leading slot, title + subtitle, trailing. * Selected state is a 12.dp green dot centered in the leading slot (icon-sized footprint). diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 880c0cc6..026292fc 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -19,6 +19,7 @@ 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.material3.* import androidx.compose.runtime.* @@ -109,89 +110,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) ) { - // Header: badge + live participant count - item(key = "people_header") { - val peopleCount = when (selectedLocationChannel) { - is ChannelID.Location -> geohashPeopleCount - else -> connectedPeers.count { it != viewModel.myPeerID } - } - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding + 14.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - SheetHeaderBadge(icon = Icons.Filled.Person) - AnimatedCountLabel( - count = peopleCount, - text = stringResource(R.string.people_count_title, peopleCount), - fontSize = 20.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = colorScheme.primary - ) - } + val peopleCount = when (selectedLocationChannel) { + is ChannelID.Location -> geohashPeopleCount + else -> connectedPeers.count { it != viewModel.myPeerID } } // Channels section if (joinedChannels.isNotEmpty()) { - item(key = "channels_header") { - SheetSectionLabel(text = stringResource(R.string.channels)) - } - - 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( + icon = Icons.Filled.Tag, + title = stringResource(R.string.channels), + modifier = Modifier.padding(top = 8.dp) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = LocalBitchatPalette.current.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, @@ -199,6 +194,7 @@ fun MeshPeerListSheet( colorScheme = colorScheme, selectedPrivatePeer = selectedPrivatePeer, wifiAwarePeerIDs = wifiAwarePeerIDs, + peopleCount = peopleCount, viewModel = viewModel, onPrivateChatStart = { peerID -> viewModel.showPrivateChatSheet(peerID) @@ -239,8 +235,8 @@ fun MeshPeerListSheet( } } -/** Icon size for rows inside the sheets: one step down from the top bar. */ -private val PeerRowIconSize = 18.dp +/** Icon size for trailing actions on peer rows (matches settings glyph scale). */ +private val PeerRowIconSize = 22.dp @Composable private fun ChannelRow( @@ -253,54 +249,50 @@ private fun ChannelRow( ) { val palette = LocalBitchatPalette.current - Surface( - onClick = onChannelClick, - color = if (isSelected) palette.surfaceVariant else palette.surface, - shape = RoundedCornerShape(10.dp), + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) + .clickable(onClick = onChannelClick) + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 44.dp) - .padding(start = 14.dp, end = 6.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 - ) - } - + if (isSelected) { + Box( + modifier = Modifier + .size(SheetRowSelectedDot) + .background(palette.accentGreen, CircleShape) + ) + } else if (unreadCount > 0) { + UnreadBadge(count = unreadCount, colorScheme = colorScheme) + } else { Text( - text = channel, + text = "#", fontFamily = FontFamily.Monospace, fontSize = 14.sp, - color = if (isSelected) colorScheme.primary else palette.textPrimary, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium - ) - } - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Leave channel button - CloseButton( - onClick = onLeaveChannel, + fontWeight = FontWeight.Medium, + color = palette.textTertiary ) } } + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + + Text( + text = channel, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + color = if (isSelected) colorScheme.primary else palette.textPrimary, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + CloseButton(onClick = onLeaveChannel) } } @@ -316,6 +308,7 @@ fun PeopleSection( colorScheme: ColorScheme, selectedPrivatePeer: String?, wifiAwarePeerIDs: Set = emptySet(), + peopleCount: Int = 0, viewModel: ChatViewModel, onPrivateChatStart: (String) -> Unit ) { @@ -327,19 +320,31 @@ fun PeopleSection( val palette = LocalBitchatPalette.current Column(modifier = modifier) { - SheetSectionLabel(text = stringResource(R.string.people)) + SheetIconSectionHeader( + icon = Icons.Filled.Person, + title = stringResource(R.string.people_count_title, peopleCount) + ) - if (connectedPeers.isEmpty()) { - Text( - text = stringResource(id = R.string.no_one_connected), - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = palette.textTertiary, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding + 14.dp, vertical = 12.dp) - ) - } + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + if (connectedPeers.isEmpty()) { + Text( + text = stringResource(id = R.string.no_one_connected), + fontFamily = FontFamily.Monospace, + 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() @@ -443,7 +448,11 @@ fun PeopleSection( if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 } + var peerIndex = 0 + sortedPeers.forEach { peerID -> + if (peerIndex > 0) SheetCardDivider() + peerIndex++ val conversationID = ContactDirectory.canonicalConversationId(peerID) val isFavorite = peerFavoriteStates[peerID] ?: false val isVerified = peerVerifiedStates[peerID] ?: false @@ -492,6 +501,9 @@ fun PeopleSection( val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey) if (isFavoriteMappedToConnected(fav)) return@forEach + if (peerIndex > 0) SheetCardDivider() + peerIndex++ + val nostrConvKey: String? = try { FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey) ?.let { ContactIdentityResolver.nostrAliasForPubkey(it) } @@ -535,6 +547,8 @@ fun PeopleSection( showHashSuffix = showHash ) } + } + } } } @@ -568,131 +582,112 @@ private fun PeerItem( val assignedColor = viewModel.colorForMeshPeer(peerID, palette.isDark) val baseColor = if (isMe) palette.accentOrange else assignedColor - Surface( - onClick = onItemClick, - color = if (isSelected) palette.surfaceVariant else palette.surface, - shape = RoundedCornerShape(10.dp), + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) + .clickable(onClick = onItemClick) + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 44.dp) - .padding(start = 14.dp, end = 6.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) { - Icon( - imageVector = Icons.Filled.Email, - contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(PeerRowIconSize), - tint = palette.accentOrange - ) - } else if (showNostrGlobe) { - Icon( - imageVector = Icons.Filled.Public, - contentDescription = stringResource(R.string.cd_reachable_via_nostr), - modifier = Modifier.size(PeerRowIconSize), - tint = palette.accentPurple - ) - } else if (!isDirect && isFavorite) { - // Offline favourite: routed glyph, dimmed to read as unavailable. - Icon( - imageVector = Icons.Outlined.Circle, - contentDescription = stringResource(R.string.cd_offline_favorite), - modifier = Modifier.size(PeerRowIconSize), - tint = palette.textTertiary - ) - } 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(PeerRowIconSize), - tint = palette.textSecondary - ) - } - - // Display name with iOS-style color and hashtag suffix support - Row(verticalAlignment = Alignment.CenterVertically) { - // Base name with peer-specific color - Text( - text = baseName, - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, - color = baseColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - // Hashtag suffix in lighter shade (iOS-style) - if (suffix.isNotEmpty()) { - Text( - text = suffix, - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, - color = baseColor.copy(alpha = SUFFIX_ALPHA) - ) - } - - if (isWifiAware && hasUnreadDM) { - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.Filled.Wifi, - contentDescription = "Direct Wi-Fi Aware", - modifier = Modifier.size(PeerRowIconSize), - tint = palette.textSecondary - ) - } - } - } - - if (isVerified) { - Spacer(modifier = Modifier.width(4.dp)) + if (isSelected) { + Box( + modifier = Modifier + .size(SheetRowSelectedDot) + .background(palette.accentGreen, CircleShape) + ) + } else if (hasUnreadDM) { Icon( - imageVector = Icons.Filled.Verified, - contentDescription = stringResource(R.string.verify_title), + imageVector = Icons.Filled.Email, + contentDescription = stringResource(R.string.cd_unread_message), modifier = Modifier.size(PeerRowIconSize), - tint = palette.accentGreen + tint = palette.accentOrange + ) + } else if (showNostrGlobe) { + Icon( + imageVector = Icons.Filled.Public, + 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( + 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(PeerRowIconSize), + tint = palette.textSecondary + ) + } + } + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = baseName, + fontFamily = FontFamily.Monospace, + 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 = FontFamily.Monospace, + 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(44.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(PeerRowIconSize), - tint = if (isFavorite) palette.accentOrange else palette.textTertiary - ) - } + if (isVerified) { + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(16.dp), + tint = palette.accentGreen + ) } } + + Box( + modifier = Modifier + .size(36.dp) + .clickable(onClick = onToggleFavorite), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, + contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", + modifier = Modifier.size(PeerRowIconSize), + tint = if (isFavorite) palette.accentOrange else palette.textTertiary + ) + } } } From 32d5e6f1dc133d7889af979a8227162aec253575 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:51:25 +0200 Subject: [PATCH 12/70] cleanup --- .../main/java/com/bitchat/android/ui/GeohashPeopleList.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index b29c12bc..58f2f44a 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -2,20 +2,15 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Email -import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.LocationOn -import androidx.compose.material.icons.outlined.Public import android.util.Log -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape 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 From c0b1af3d0f79e806e45cb6c458afaafaf2c16779 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:59:51 +0200 Subject: [PATCH 13/70] button --- .../java/com/bitchat/android/ui/ChatHeader.kt | 109 +++++++++++++++--- .../bitchat/android/ui/LocationNotesButton.kt | 24 ++-- 2 files changed, 100 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 9d831592..d2e6a9b6 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -7,7 +7,12 @@ 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.* @@ -24,6 +29,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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.platform.LocalFocusManager import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.res.stringResource @@ -96,30 +104,91 @@ private fun HeaderIconButton( } /** - * Smooth tint for header glyphs that should reflect Tor connection state. + * Tor health for location-channel header glyphs. * - * When Tor is off or fully bootstrapped, [normal] is used. While connecting the tint goes - * orange; if Tor is enabled but not running it goes red. Cross-fades so Bootstrap percent - * flips do not flash the whole header. + * 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 -internal fun torConnectionTint(normal: Color): Color { +internal fun rememberTorConnectionVisual(normal: Color): TorConnectionVisual { val palette = LocalBitchatPalette.current 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, palette.accentRed, 0.30f) + val target = when { - torStatus.mode == TorMode.OFF -> normal - torStatus.running && torStatus.bootstrapPercent >= 100 -> normal - torStatus.running -> palette.accentOrange - else -> palette.accentRed + 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 animated by animateColorAsState( - targetValue = target, + val animatedTint by animateColorAsState( + targetValue = target.tint, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "torConnectionTint" ) - return animated + 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 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 + } + + Box(contentAlignment = Alignment.Center, modifier = modifier) { + if (isProgress) { + // Soft halo behind the glyph — opacity tracks the pulse, never solid. + Box( + modifier = Modifier + .size(HeaderIconSize + 10.dp) + .graphicsLayer { alpha = pulse * 0.28f } + .background(tint.copy(alpha = 1f), CircleShape) + ) + } + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = Modifier + .size(HeaderIconSize) + .graphicsLayer { + // Pulse icon opacity gently when in progress; settle to full when idle. + alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f + }, + tint = tint + ) + } } @Composable @@ -530,8 +599,12 @@ private fun LocationChannelsButton( else -> stringResource(R.string.mesh_label) } val channelColor = if (isLocation) palette.accentGreen else palette.accentBlue - // Icon alone carries Tor health so the geohash / "mesh" label stays channel-coloured. - val iconColor = torConnectionTint(normal = channelColor) + // Tor status only tints the globe (location channels). Mesh Bluetooth stays blue. + val torVisual = if (isLocation) { + rememberTorConnectionVisual(normal = channelColor) + } else { + TorConnectionVisual(tint = channelColor, isProgress = false) + } val badgeIcon = if (isLocation) Icons.Outlined.Public else Icons.Filled.Bluetooth Row( @@ -545,11 +618,11 @@ private fun LocationChannelsButton( // padding so the gap to PeerCounter matches other cluster separations. .padding(start = 0.dp, end = 6.dp) ) { - Icon( + TorAwareHeaderIcon( imageVector = badgeIcon, - contentDescription = stringResource(R.string.cd_tor_status), - modifier = Modifier.size(HeaderIconSize), - tint = iconColor + tint = torVisual.tint, + isProgress = torVisual.isProgress, + contentDescription = stringResource(R.string.cd_tor_status) ) Text( diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 4819deef..7c29a93a 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.clickable 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.MaterialTheme import androidx.compose.runtime.Composable import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -25,10 +24,9 @@ import com.bitchat.android.nostr.LocationNotesManager import com.bitchat.android.ui.theme.LocalBitchatPalette /** - * Location Notes button component for MainHeader - * Shows in mesh mode when location permission granted AND services enabled. - * Base tint is primary when notes exist, secondary otherwise; Tor health may - * override that color (orange connecting, red failed) via [torConnectionTint]. + * 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( @@ -40,17 +38,14 @@ fun LocationNotesButton( val palette = LocalBitchatPalette.current 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 @@ -60,9 +55,8 @@ fun LocationNotesButton( val hasNotes = notesCount > 0 val contentDescription = stringResource(R.string.cd_location_notes) val normalTint = if (hasNotes) colorScheme.primary else palette.textSecondary - val tint = torConnectionTint(normal = normalTint) - // Match other header icon buttons: 44.dp target, no Material IconButton min-size padding - // that pushed the notes glyph farther from the mesh badge than sibling gaps. + val torVisual = rememberTorConnectionVisual(normal = normalTint) + Box( modifier = modifier .size(44.dp) @@ -70,11 +64,11 @@ fun LocationNotesButton( .clickable(onClickLabel = contentDescription) { onClick() }, contentAlignment = Alignment.Center ) { - Icon( + TorAwareHeaderIcon( imageVector = Icons.Outlined.Description, - contentDescription = contentDescription, - modifier = Modifier.size(HeaderIconSize), - tint = tint + tint = torVisual.tint, + isProgress = torVisual.isProgress, + contentDescription = contentDescription ) } } From 8e849a7bbea1dd0381c7735c6ae0bd735e591bd1 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:14:54 +0200 Subject: [PATCH 14/70] fixes --- .../ui/component/button/BitChatBrandButton.kt | 9 +- .../ui/component/sheet/BitchatBottomSheet.kt | 42 ++++- .../ui/component/sheet/BitchatSheetTopBar.kt | 7 +- .../java/com/bitchat/android/ui/AboutSheet.kt | 45 +++++- .../bitchat/android/ui/AnimatedRowColumn.kt | 85 +++++++++++ .../java/com/bitchat/android/ui/ChatHeader.kt | 68 ++++++--- .../bitchat/android/ui/GeohashPeopleList.kt | 143 +++++++++++++----- .../android/ui/LocationChannelsSheet.kt | 74 ++++++++- .../bitchat/android/ui/LocationNotesButton.kt | 2 + .../bitchat/android/ui/MeshPeerListSheet.kt | 36 +++-- .../com/bitchat/android/ui/PressFeedback.kt | 64 ++++++++ .../android/ui/SecurityVerificationSheet.kt | 4 +- .../bitchat/android/ui/VerificationSheet.kt | 4 +- app/src/main/res/values/strings.xml | 1 + .../android/ui/GeohashAnonOrderingTest.kt | 113 ++++++++++++++ 15 files changed, 606 insertions(+), 91 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/PressFeedback.kt create mode 100644 app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt index c1361364..f4afd5b6 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt @@ -12,11 +12,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.Modifier +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 @@ -39,6 +42,9 @@ fun BitChatBrandButton( val currentOnClick by rememberUpdatedState(onClick) val currentOnTripleClick by rememberUpdatedState(onTripleClick) + val interactionSource = remember { MutableInteractionSource() } + val pressScale = rememberPressScale(interactionSource) + IconButton( onClick = { tapCount += 1 @@ -59,7 +65,8 @@ fun BitChatBrandButton( } } }, - modifier = modifier, + modifier = modifier.scale(pressScale), + interactionSource = interactionSource, ) { Icon( imageVector = BitChatIcon, diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt index 948fd6d9..2b14c09e 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt @@ -9,8 +9,26 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +/** + * Dismisses the enclosing [BitchatBottomSheet], playing the slide-down first. + * + * `ModalBottomSheet` only animates itself out when *it* initiates the dismissal — a swipe or a tap + * on the scrim. Anything that closes a sheet programmatically (a close button, picking an item from + * a list) previously flipped the caller's `isPresented` flag straight to false, which yanks the + * composable out of the tree and makes the sheet vanish instantly. + * + * Anything inside a sheet that wants to close it should prefer this over calling its own + * `onDismiss` directly. + */ +val LocalSheetDismiss = staticCompositionLocalOf<(() -> Unit)?> { null } @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -20,6 +38,21 @@ 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() + } + Unit + } + } + ModalBottomSheet( modifier = modifier.statusBarsPadding(), onDismissRequest = onDismissRequest, @@ -27,6 +60,9 @@ fun BitchatBottomSheet( dragHandle = null, shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), containerColor = MaterialTheme.colorScheme.background, - content = content, - ) -} \ No newline at end of file + ) { + CompositionLocalProvider(LocalSheetDismiss provides animatedDismiss) { + content() + } + } +} diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt index 036cf01b..333e3ae6 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt @@ -13,6 +13,7 @@ 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.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) ) }, diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 6be3cff1..821ccabd 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -5,6 +5,8 @@ import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Speed import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -30,6 +32,7 @@ import com.bitchat.android.nostr.PoWPreferenceManager import androidx.compose.ui.res.stringResource 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.net.TorMode import com.bitchat.android.net.TorPreferenceManager @@ -101,17 +104,43 @@ private fun SettingsToggleRow( ) { val colorScheme = MaterialTheme.colorScheme 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) palette.textPrimary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowTitle" + ) + val subtitleColor by animateColorAsState( + targetValue = if (enabled) palette.textSecondary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowSubtitle" + ) Row( modifier = Modifier .fillMaxWidth() + // 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 palette.textTertiary, + tint = iconTint, modifier = Modifier.size(22.dp) ) @@ -130,7 +159,7 @@ private fun SettingsToggleRow( fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = if (enabled) palette.textPrimary else palette.textTertiary + color = titleColor ) statusIndicator?.invoke() } @@ -138,7 +167,7 @@ private fun SettingsToggleRow( text = subtitle, fontFamily = FontFamily.Monospace, fontSize = 12.sp, - color = if (enabled) palette.textSecondary else palette.textTertiary, + color = subtitleColor, lineHeight = 17.sp ) } @@ -149,6 +178,7 @@ private fun SettingsToggleRow( checked = checked, onCheckedChange = { if (enabled) onCheckedChange(it) }, enabled = enabled, + interactionSource = interactionSource, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, checkedTrackColor = palette.accentGreen, @@ -416,7 +446,8 @@ fun AboutSheet( fontWeight = FontWeight.Medium, color = palette.textPrimary ) - Text( + AnimatedCountLabel( + count = powDifficulty, text = stringResource( R.string.about_difficulty_value, powDifficulty, @@ -440,7 +471,8 @@ fun AboutSheet( ) ) - 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) @@ -560,8 +592,9 @@ fun AboutSheet( .height(64.dp) .background(palette.background.copy(alpha = topBarAlpha)) ) { + val dismiss = LocalSheetDismiss.current CloseButton( - onClick = onDismiss, + onClick = { dismiss?.invoke() ?: onDismiss() }, modifier = modifier .align(Alignment.CenterEnd) .padding(horizontal = 16.dp), diff --git a/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt b/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt new file mode 100644 index 00000000..26c8259a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt @@ -0,0 +1,85 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LookaheadScope + +/** + * Motion for a row moving to a new position, or resizing in place. + * + * People lists reorder themselves constantly and without user input — someone sends a DM and jumps + * to the top, a peer drops off the mesh, a favourite comes online. Rows teleporting between + * positions makes the list feel unreliable and costs the reader their place in it. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +private val RowBoundsTransform = BoundsTransform { _, _ -> + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Rect.VisibilityThreshold + ) +} + +/** Entry fade for a row that was not previously in the list. */ +private val RowEnterSpec: FiniteAnimationSpec = + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 900f) + +/** + * A vertical list whose rows animate when they are added, removed, or reordered. + * + * Deliberately not a `LazyColumn`: both people lists live *inside* an outer `LazyColumn` item, where + * nesting another lazy list is not possible. [LookaheadScope] plus [Modifier.animateBounds] gives + * the same reorder-and-resize animation that `LazyItemScope.animateItem` provides, without the list + * needing to be lazy. These lists are bounded (and the geohash one is explicitly capped), so + * nothing is lost by composing every row. + * + * Rows are keyed so identity survives reordering. Without stable keys a row that moved would look + * like a different row appearing, and would fade instead of sliding. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun AnimatedRowColumn( + items: List, + key: (T) -> Any, + modifier: Modifier = Modifier, + row: @Composable (index: Int, item: T) -> Unit +) { + LookaheadScope { + Column(modifier = modifier) { + items.forEachIndexed { index, item -> + key(key(item)) { + // Fades the row in on the composition it first appears, then never again — + // reordering an existing row must slide, not blink. + val enter = remember { Animatable(0f) } + LaunchedEffect(Unit) { enter.animateTo(1f, RowEnterSpec) } + + Box( + modifier = Modifier + .animateBounds( + lookaheadScope = this@LookaheadScope, + boundsTransform = RowBoundsTransform + ) + .graphicsLayer { alpha = enter.value } + ) { + row(index, item) + } + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index d2e6a9b6..fad7b82e 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -27,20 +27,21 @@ 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.platform.LocalFocusManager -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.res.stringResource -import com.bitchat.android.R +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +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 @@ -96,7 +97,7 @@ private fun HeaderIconButton( modifier = modifier .size(HeaderTapTarget) .clip(CircleShape) - .clickable(onClickLabel = contentDescription) { onClick() }, + .pressScaleClickable(onClick = onClick, onClickLabel = contentDescription), contentAlignment = Alignment.Center ) { content() @@ -167,14 +168,27 @@ internal fun TorAwareHeaderIcon( 1f } - Box(contentAlignment = Alignment.Center, modifier = modifier) { + // 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) { - // Soft halo behind the glyph — opacity tracks the pulse, never solid. + 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 - .size(HeaderIconSize + 10.dp) - .graphicsLayer { alpha = pulse * 0.28f } - .background(tint.copy(alpha = 1f), CircleShape) + .requiredSize(HeaderIconSize + 14.dp) + .graphicsLayer { alpha = pulse * 0.85f } + .background(glowBrush) ) } Icon( @@ -183,7 +197,6 @@ internal fun TorAwareHeaderIcon( modifier = Modifier .size(HeaderIconSize) .graphicsLayer { - // Pulse icon opacity gently when in progress; settle to full when idle. alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f }, tint = tint @@ -323,7 +336,7 @@ fun PeerCounter( horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier .clip(HeaderClusterShape) - .clickable { onClick() } + .pressScaleClickable(onClick = onClick) .height(HeaderTapTarget) .padding(horizontal = 6.dp) ) { @@ -438,7 +451,7 @@ private fun ChannelHeader( modifier = Modifier .align(Alignment.Center) .clip(HeaderClusterShape) - .clickable { onSidebarClick() } + .pressScaleClickable(onClick = onSidebarClick) .padding(horizontal = 8.dp, vertical = 4.dp) ) @@ -498,20 +511,27 @@ private fun MainHeader( modifier = Modifier.size(HeaderTapTarget), ) - 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(horizontal = 2.dp) - ) + // 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) + ) - NicknameEditor( - value = nickname, - onValueChange = onNicknameChange - ) + NicknameEditor( + value = nickname, + onValueChange = onNicknameChange + ) + } } // MARK: - Status cluster. diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 58f2f44a..75fe577c 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -5,12 +5,18 @@ import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.LocationOn import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background 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.Brush import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -19,6 +25,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R +import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette import java.util.* @@ -92,14 +99,15 @@ fun GeohashPeopleList( } } + // Self first, then anyone who chose a nickname, then the anons — all by recency + // within their group. A busy geohash is mostly anonymous drive-by participants, and + // letting them sort by recency alone buried the handful of people worth recognising. 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) - } - } + geohashPeople.sortedWith( + compareByDescending { myHex != null && it.id == myHex } + .thenBy { it.isAnonymous() } + .thenByDescending { it.lastSeen } + ) } val baseNameCounts = remember(geohashPeople) { @@ -145,21 +153,7 @@ fun GeohashPeopleList( icon = Icons.Outlined.LocationOn, title = stringResource(R.string.section_on_location) ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding) - .padding(top = 10.dp), - color = palette.surface, - shape = AboutCardShape - ) { - Column { - localPeople.forEachIndexed { index, person -> - if (index > 0) SheetCardDivider() - personRow(person) - } - } - } + PeopleCard(people = localPeople, row = { personRow(it) }) } if (teleportedPeople.isNotEmpty()) { @@ -168,22 +162,103 @@ fun GeohashPeopleList( title = stringResource(R.string.section_teleported_in), modifier = Modifier.padding(top = if (localPeople.isNotEmpty()) 20.dp else 0.dp) ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding) - .padding(top = 10.dp), - color = palette.surface, - shape = AboutCardShape - ) { - Column { - teleportedPeople.forEachIndexed { index, person -> - if (index > 0) SheetCardDivider() - personRow(person) + PeopleCard(people = teleportedPeople, row = { personRow(it) }) + } + } + } +} + +/** Anonymous participants beyond this many are hidden behind the "n more" affordance. */ +internal const val MaxVisibleAnons = 5 + +/** + * Whether this participant never set a nickname. + * + * The app labels them `anon` or `anon1234` before the `#abcd` disambiguator, so the base name is + * what identifies them. + */ +internal fun GeoPerson.isAnonymous(): Boolean { + val base = splitSuffix(displayName).first + return base == "anon" || (base.startsWith("anon") && base.drop(4).all { it.isDigit() }) +} + +/** + * One grouped card of people, with a cap on how many anonymous participants are shown. + * + * A popular geohash can hold dozens of anons, which pushed everyone worth recognising off screen and + * turned the sheet into a wall of near-identical rows. Named participants are always listed in full; + * anons are trimmed to [MaxVisibleAnons], and the overflow is collapsed behind a count. The last + * visible anon fades out under a gradient so the truncation is legible as truncation rather than + * looking like the list simply ended. + */ +@Composable +private fun PeopleCard( + people: List, + row: @Composable (GeoPerson) -> Unit +) { + val palette = LocalBitchatPalette.current + + val named = people.filterNot { it.isAnonymous() } + val anons = people.filter { it.isAnonymous() } + val visibleAnons = anons.take(MaxVisibleAnons) + val hiddenAnonCount = anons.size - visibleAnons.size + val visible = named + visibleAnons + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + AnimatedRowColumn(items = visible, key = { it.id }) { index, person -> + Column { + if (index > 0) SheetCardDivider() + if (hiddenAnonCount > 0 && index == visible.lastIndex) { + // Fade only the final row, so the gradient reads as "the list continues" + // rather than dimming content that is still meant to be read. + Box { + row(person) + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + listOf( + palette.surface.copy(alpha = 0f), + palette.surface.copy(alpha = 0.85f) + ) + ) + ) + ) } + } else { + row(person) } } } + + AnimatedVisibility( + visible = hiddenAnonCount > 0, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + ) { + Text( + text = stringResource(R.string.people_n_more, hiddenAnonCount), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = palette.textTertiary, + modifier = Modifier + .fillMaxWidth() + .padding( + start = SheetRowHorizontal, + end = SheetRowHorizontal, + bottom = SheetRowVertical + ) + ) + } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 28ecd8a0..84ba3709 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -1,5 +1,14 @@ package com.bitchat.android.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Hub @@ -43,6 +52,7 @@ 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.BitchatSheetTitle +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.geohash.ChannelID import com.bitchat.android.geohash.GeohashBookmarksStore @@ -54,6 +64,7 @@ import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette +import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** @@ -68,6 +79,14 @@ private val ChannelDividerInset = SheetRowDividerInset /** 2× the previous 6.dp selected indicator; sits centered in [ChannelLeadingSlot]. */ private val ChannelSelectedDot = SheetRowSelectedDot +/** + * Pause between applying a channel selection and dismissing the sheet. + * + * Just enough for the active dot to land on the chosen row, so the tap is acknowledged rather than + * answered by the sheet simply disappearing. + */ +private const val SelectionConfirmDelayMs = 180L + /** * Location Channels sheet: grouped card rows matching About → Settings. */ @@ -141,6 +160,18 @@ fun LocationChannelsSheet( onDismissRequest = onDismiss, sheetState = sheetState, ) { + // Selection is applied immediately so the active dot snaps to the new row, then the + // sheet slides away after a beat. Long enough to register the change, short enough + // that it never feels like waiting. + val animatedDismiss = LocalSheetDismiss.current + val confirmSelectionThenDismiss: () -> Unit = { + coroutineScope.launch { + delay(SelectionConfirmDelayMs) + animatedDismiss?.invoke() ?: onDismiss() + } + Unit + } + Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( state = listState, @@ -324,9 +355,22 @@ fun LocationChannelsSheet( } } - if (customError != null) { + AnimatedVisibility( + visible = customError != null, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandVertically( + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkVertically( + tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ) + ) { + // Held across the exit animation: by the time it plays, the error + // itself has already been cleared. + val shownError = remember(customError) { customError ?: "" } Text( - text = customError!!, + text = shownError, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = palette.accentRed, @@ -658,7 +702,24 @@ private fun CustomGeohashRow( val palette = LocalBitchatPalette.current val normalized = customGeohash.trim().lowercase().replace("#", "") val isValid = validateGeohash(normalized) - val teleportColor = if (isValid) colorScheme.primary else palette.textTertiary + // Typing the last character of a valid geohash arms the button; cross-fading both the label + // and its container makes that the moment the row confirms the input is usable. + val teleportColor by animateColorAsState( + targetValue = if (isValid) colorScheme.primary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "teleportLabel" + ) + val teleportContainer by animateColorAsState( + targetValue = if (isValid) { + colorScheme.primary.copy(alpha = 0.16f) + } else { + palette.surfaceVariant + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "teleportContainer" + ) + val teleportInteraction = remember { MutableInteractionSource() } + val teleportScale = rememberPressScale(teleportInteraction, pressedScale = 0.92f) Row( modifier = Modifier @@ -718,7 +779,8 @@ private fun CustomGeohashRow( Box( modifier = Modifier .size(36.dp) - .clickable(onClick = onOpenMap), + .clip(CircleShape) + .pressScaleClickable(onClick = onOpenMap), contentAlignment = Alignment.Center ) { Icon( @@ -733,7 +795,9 @@ private fun CustomGeohashRow( onClick = onTeleport, enabled = isValid, shape = RoundedCornerShape(8.dp), - color = palette.surfaceVariant + color = teleportContainer, + interactionSource = teleportInteraction, + modifier = Modifier.scale(teleportScale) ) { Text( text = stringResource(R.string.teleport).uppercase(), diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 7c29a93a..ab14a999 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -12,7 +12,9 @@ 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.draw.scale import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 026292fc..cfda3952 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -448,11 +448,21 @@ fun PeopleSection( if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 } - var peerIndex = 0 + // 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() - sortedPeers.forEach { peerID -> - if (peerIndex > 0) SheetCardDivider() - peerIndex++ + val offlineFavoriteRows = offlineFavorites.filterNot { isFavoriteMappedToConnected(it) } + val rowKeys: List = sortedPeers + + offlineFavoriteRows.map { ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) } + + AnimatedRowColumn(items = rowKeys, key = { it }) { rowIndex, rowKey -> + Column { + if (rowIndex > 0) SheetCardDivider() + val connectedPeerForRow = sortedPeers.firstOrNull { it == rowKey } + if (connectedPeerForRow != null) { + val peerID = connectedPeerForRow val conversationID = ContactDirectory.canonicalConversationId(peerID) val isFavorite = peerFavoriteStates[peerID] ?: false val isVerified = peerVerifiedStates[peerID] ?: false @@ -472,7 +482,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, @@ -494,15 +503,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 - - if (peerIndex > 0) SheetCardDivider() - peerIndex++ + } 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) @@ -546,6 +552,8 @@ fun PeopleSection( showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null), showHashSuffix = showHash ) + } + } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt b/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt new file mode 100644 index 00000000..01c9c6ca --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale + +/** + * Press feedback for the app's icon buttons. + * + * Terminal-style chrome has no elevation and no fills to lean on, so a Material ripple has almost + * nothing to show. A brief scale dip is legible on any background and reads as physical. Springs + * rather than tweens, so releasing overshoots very slightly instead of stopping dead. + */ +@Composable +fun rememberPressScale( + interactionSource: InteractionSource, + pressedScale: Float = 0.86f +): Float { + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) pressedScale else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "pressScale" + ) + return scale +} + +/** + * Convenience wrapper for the common case: a clickable that scales while held. + * + * Returns the modifier chain to apply, having disabled the default indication — the scale *is* the + * indication, and a ripple underneath it just muddies the edges of these small glyphs. + */ +@Composable +fun Modifier.pressScaleClickable( + onClick: () -> Unit, + enabled: Boolean = true, + onClickLabel: String? = null, + pressedScale: Float = 0.86f +): Modifier { + val interactionSource = remember { MutableInteractionSource() } + val scale = rememberPressScale(interactionSource, pressedScale) + return this + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + onClickLabel = onClickLabel, + onClick = onClick + ) + .scale(scale) +} diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index 5549f43f..5d3af681 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle 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( @@ -161,7 +162,8 @@ private fun SecurityVerificationHeader( color = accent ) Spacer(modifier = Modifier.weight(1f)) - CloseButton(onClick = onClose) + val dismiss = LocalSheetDismiss.current + CloseButton(onClick = { dismiss?.invoke() ?: onClose() }) } } diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index a81938d5..76aed7d8 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -72,6 +72,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 @@ -235,7 +236,8 @@ private fun VerificationHeader( fontFamily = FontFamily.Monospace, color = accent ) - CloseButton(onClick = onClose) + val dismiss = LocalSheetDismiss.current + CloseButton(onClick = { dismiss?.invoke() ?: onClose() }) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0987249..05d99721 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -256,6 +256,7 @@ People (%1$d) On location Teleported in + %1$d more\u2026 Grant location permission Location permission denied. Enable in settings to use location channels. diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt new file mode 100644 index 00000000..8a00d1c8 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt @@ -0,0 +1,113 @@ +package com.bitchat.android.ui + +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A busy geohash is mostly anonymous drive-by participants. If they sort by recency alongside + * everyone else they bury the handful of people worth recognising, so they are pushed to the end of + * the list and capped. + */ +class GeohashAnonOrderingTest { + + private fun person(name: String, secondsAgo: Long = 0) = GeoPerson( + id = "id-$name", + displayName = name, + lastSeen = Date(1_000_000L - secondsAgo * 1000L) + ) + + @Test + fun `bare anon is anonymous`() { + assertTrue(person("anon").isAnonymous()) + assertTrue(person("anon#04af").isAnonymous()) + } + + @Test + fun `numbered anon is anonymous`() { + assertTrue(person("anon7674").isAnonymous()) + assertTrue(person("anon7674#df5b").isAnonymous()) + } + + @Test + fun `a chosen nickname is not anonymous`() { + assertFalse(person("alice").isAnonymous()) + assertFalse(person("alice#1234").isAnonymous()) + } + + @Test + fun `a nickname that merely starts with anon is not anonymous`() { + // "anonymous" and "anonracer" are deliberate names, not the generated placeholder. + assertFalse(person("anonymous").isAnonymous()) + assertFalse(person("anonracer#04af").isAnonymous()) + } + + @Test + fun `named participants sort ahead of anons regardless of recency`() { + val me = person("me") + val people = listOf( + person("anon1", secondsAgo = 0), // most recent of all + person("zoe", secondsAgo = 500), // stalest named user + person("anon2", secondsAgo = 10), + person("alice", secondsAgo = 200) + ) + + val ordered = (people + me).sortedWith( + compareByDescending { it.id == me.id } + .thenBy { it.isAnonymous() } + .thenByDescending { it.lastSeen } + ) + + assertEquals( + listOf("me", "alice", "zoe", "anon1", "anon2"), + ordered.map { it.displayName } + ) + } + + @Test + fun `recency still orders within each group`() { + val people = listOf( + person("bob", secondsAgo = 100), + person("alice", secondsAgo = 10), + person("anon9", secondsAgo = 300), + person("anon1", secondsAgo = 5) + ) + + val ordered = people.sortedWith( + compareBy { it.isAnonymous() }.thenByDescending { it.lastSeen } + ) + + assertEquals( + listOf("alice", "bob", "anon1", "anon9"), + ordered.map { it.displayName } + ) + } + + @Test + fun `named participants are never trimmed`() { + val named = (1..20).map { person("user$it") } + assertEquals(20, named.filterNot { it.isAnonymous() }.size) + } + + @Test + fun `anons beyond the cap are counted as hidden`() { + val people = (1..12).map { person("anon$it") } + person("alice") + + val anons = people.filter { it.isAnonymous() } + val visible = anons.take(MaxVisibleAnons) + + assertEquals(MaxVisibleAnons, visible.size) + assertEquals(12 - MaxVisibleAnons, anons.size - visible.size) + } + + @Test + fun `a short anon list is shown in full with nothing hidden`() { + val people = listOf(person("alice"), person("anon1"), person("anon2")) + + val anons = people.filter { it.isAnonymous() } + assertEquals(2, anons.take(MaxVisibleAnons).size) + assertEquals(0, anons.size - anons.take(MaxVisibleAnons).size) + } +} From 5428bc9a03c7c53f41e956d3de7423118ff06b5d Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:22:16 +0200 Subject: [PATCH 15/70] animations --- .../ui/component/button/BitChatBrandButton.kt | 56 ++++++++++++------- .../ui/component/sheet/BitchatBottomSheet.kt | 1 - .../java/com/bitchat/android/ui/ChatHeader.kt | 33 ++++++----- .../android/ui/LocationChannelsSheet.kt | 1 - .../bitchat/android/ui/LocationNotesButton.kt | 3 +- 5 files changed, 56 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt index f4afd5b6..731a85fb 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt @@ -1,8 +1,11 @@ package com.bitchat.android.core.ui.component.button +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -12,8 +15,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -import androidx.compose.foundation.interaction.MutableInteractionSource +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 @@ -45,34 +49,44 @@ fun BitChatBrandButton( val interactionSource = remember { MutableInteractionSource() } val pressScale = rememberPressScale(interactionSource) - IconButton( - onClick = { - tapCount += 1 - resetJob?.cancel() + // 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() - } + if (tapCount == 3) { tapCount = 0 resetJob = null + currentOnTripleClick() + } else { + resetJob = coroutineScope.launch { + delay(MultiClickThreshold) + if (tapCount == 1) { + currentOnClick() + } + tapCount = 0 + resetJob = null + } } - } - }, - modifier = modifier.scale(pressScale), - interactionSource = interactionSource, + }, + contentAlignment = Alignment.Center ) { Icon( imageVector = BitChatIcon, contentDescription = contentDescription, tint = tint, - modifier = Modifier.size(iconSize), + modifier = Modifier + .size(iconSize) + .scale(pressScale), ) } } diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt index 2b14c09e..e53fed46 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt @@ -49,7 +49,6 @@ fun BitchatBottomSheet( runCatching { sheetState.hide() } onDismissRequest() } - Unit } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index fad7b82e..6e79ab91 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -452,21 +452,25 @@ private fun ChannelHeader( .align(Alignment.Center) .clip(HeaderClusterShape) .pressScaleClickable(onClick = onSidebarClick) - .padding(horizontal = 8.dp, vertical = 4.dp) + .heightIn(min = HeaderTapTarget) + .wrapContentHeight(Alignment.CenterVertically) + .padding(horizontal = 10.dp) ) // Leave button - positioned on the right - TextButton( - onClick = onLeaveChannel, - modifier = Modifier.align(Alignment.CenterEnd) - ) { - Text( - text = stringResource(R.string.chat_leave), - style = MaterialTheme.typography.labelMedium, - fontSize = 15.sp, - color = palette.accentRed - ) - } + Text( + text = stringResource(R.string.chat_leave), + style = MaterialTheme.typography.labelMedium, + fontSize = 15.sp, + color = palette.accentRed, + modifier = Modifier + .align(Alignment.CenterEnd) + .clip(HeaderClusterShape) + .pressScaleClickable(onClick = onLeaveChannel) + .heightIn(min = HeaderTapTarget) + .wrapContentHeight(Alignment.CenterVertically) + .padding(horizontal = 10.dp) + ) } } @@ -632,7 +636,10 @@ private fun LocationChannelsButton( horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier .clip(HeaderClusterShape) - .clickable(onClickLabel = stringResource(R.string.location_channels_title)) { onClick() } + .pressScaleClickable( + onClick = onClick, + onClickLabel = stringResource(R.string.location_channels_title) + ) .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. diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 84ba3709..5c4c230e 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -169,7 +169,6 @@ fun LocationChannelsSheet( delay(SelectionConfirmDelayMs) animatedDismiss?.invoke() ?: onDismiss() } - Unit } Box(modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index ab14a999..23157e5d 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -2,7 +2,6 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Description -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -63,7 +62,7 @@ fun LocationNotesButton( modifier = modifier .size(44.dp) .clip(CircleShape) - .clickable(onClickLabel = contentDescription) { onClick() }, + .pressScaleClickable(onClick = onClick, onClickLabel = contentDescription), contentAlignment = Alignment.Center ) { TorAwareHeaderIcon( From 9703ebfcaf0a1d9dc412dc1c5f3950ad8848a672 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:09:37 +0200 Subject: [PATCH 16/70] Fix layout jumpiness in chat and geohash people list Three separate causes of things moving when they should not: - Chat lurched whenever a bottom sheet closed. Placement animation is meant to soften insertions and removals, but any relayout moves every item -- a sheet's text field opening the keyboard changes the chat's IME inset, and closing it changes it back. Placement animation is now armed only briefly around a real change to the message list, so items otherwise track the viewport exactly. - Anon list changed height as participants churned. Rows sized to their content, so any reorder could change the card's height; and the card sized to the live anon count, which moves constantly in a busy geohash. Rows now have an exact height, and a trimmed anon card reserves the full capped height regardless of how many are present beyond the cap. - Anons are now their own trailing section rather than a tail on each of "on location" and "teleported in", which had pushed the few recognisable names out of view twice over. Self is never grouped as an anon. Adds 7 tests covering the sectioning and the fixed-length behaviour. --- .../com/bitchat/android/ui/AboutSections.kt | 9 ++ .../bitchat/android/ui/GeohashPeopleList.kt | 129 +++++++++++------- .../bitchat/android/ui/MessageComponents.kt | 29 +++- app/src/main/res/values/strings.xml | 1 + .../android/ui/GeohashAnonOrderingTest.kt | 72 ++++++++++ 5 files changed, 191 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index 8dfb63fb..8685705e 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -73,6 +73,15 @@ 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 diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 75fe577c..bca7fe62 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.outlined.Explore +import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.LocationOn import android.util.Log import androidx.compose.animation.AnimatedVisibility @@ -125,7 +126,15 @@ fun GeohashPeopleList( viewModel.isPersonTeleported(person.id) } - val (teleportedPeople, localPeople) = orderedPeople.partition { personTeleported(it) } + // Anonymous participants form their own trailing section rather than a tail on each + // of the others. A busy geohash is mostly anons, and splitting them across "on + // location" and "teleported in" pushed the few recognisable names out of view twice + // over. Self is never grouped as an anon even when unnamed — you always want to find + // yourself where you actually are. + val isSelf: (GeoPerson) -> Boolean = { myHex != null && it.id == myHex } + val namedPeople = orderedPeople.filter { isSelf(it) || !it.isAnonymous() } + val anonPeople = orderedPeople.filter { !isSelf(it) && it.isAnonymous() } + val (teleportedPeople, localPeople) = namedPeople.partition { personTeleported(it) } @Composable fun personRow(person: GeoPerson) { @@ -164,6 +173,17 @@ fun GeohashPeopleList( ) PeopleCard(people = teleportedPeople, row = { personRow(it) }) } + + if (anonPeople.isNotEmpty()) { + SheetIconSectionHeader( + icon = Icons.Outlined.Person, + title = stringResource(R.string.section_anonymous), + modifier = Modifier.padding( + top = if (localPeople.isNotEmpty() || teleportedPeople.isNotEmpty()) 20.dp else 0.dp + ) + ) + PeopleCard(people = anonPeople, capped = true, row = { personRow(it) }) + } } } } @@ -183,26 +203,29 @@ internal fun GeoPerson.isAnonymous(): Boolean { } /** - * One grouped card of people, with a cap on how many anonymous participants are shown. + * One grouped card of people. * - * A popular geohash can hold dozens of anons, which pushed everyone worth recognising off screen and - * turned the sheet into a wall of near-identical rows. Named participants are always listed in full; - * anons are trimmed to [MaxVisibleAnons], and the overflow is collapsed behind a count. The last - * visible anon fades out under a gradient so the truncation is legible as truncation rather than - * looking like the list simply ended. + * When [capped] the list is trimmed to [MaxVisibleAnons] rows and the remainder is collapsed behind + * a count. That matters for the anonymous section: a popular geohash can hold dozens of anons, which + * pushed everyone worth recognising off screen and turned the sheet into a wall of near-identical + * rows. + * + * The capped card is a **fixed height** — [MaxVisibleAnons] rows plus the overflow line, always, + * regardless of how many anons are currently present beyond the cap. Anons join and leave a busy + * geohash constantly, and sizing to the live count made the card grow and shrink under the reader + * every few seconds. */ @Composable private fun PeopleCard( people: List, - row: @Composable (GeoPerson) -> Unit + row: @Composable (GeoPerson) -> Unit, + capped: Boolean = false ) { val palette = LocalBitchatPalette.current - val named = people.filterNot { it.isAnonymous() } - val anons = people.filter { it.isAnonymous() } - val visibleAnons = anons.take(MaxVisibleAnons) - val hiddenAnonCount = anons.size - visibleAnons.size - val visible = named + visibleAnons + val visible = if (capped) people.take(MaxVisibleAnons) else people + val hiddenCount = people.size - visible.size + val isTrimmed = capped && people.size > MaxVisibleAnons Surface( modifier = Modifier @@ -213,50 +236,57 @@ private fun PeopleCard( shape = AboutCardShape ) { Column { - AnimatedRowColumn(items = visible, key = { it.id }) { index, person -> - Column { - if (index > 0) SheetCardDivider() - if (hiddenAnonCount > 0 && index == visible.lastIndex) { - // Fade only the final row, so the gradient reads as "the list continues" - // rather than dimming content that is still meant to be read. - Box { - row(person) - Box( - modifier = Modifier - .matchParentSize() - .background( - Brush.verticalGradient( - listOf( - palette.surface.copy(alpha = 0f), - palette.surface.copy(alpha = 0.85f) + Box( + // Reserve the full capped height up front so the card cannot resize as anons + // churn. Rows are a fixed height, so this is exact rather than an estimate. + modifier = if (isTrimmed) { + Modifier.height(SheetRowHeight * MaxVisibleAnons) + } else { + Modifier + } + ) { + AnimatedRowColumn(items = visible, key = { it.id }) { index, person -> + Column { + if (index > 0) SheetCardDivider() + if (isTrimmed && index == visible.lastIndex) { + // Fade only the final row, so the gradient reads as "the list + // continues" rather than dimming content still meant to be read. + Box { + row(person) + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + listOf( + palette.surface.copy(alpha = 0f), + palette.surface.copy(alpha = 0.85f) + ) ) ) - ) - ) + ) + } + } else { + row(person) } - } else { - row(person) } } } - AnimatedVisibility( - visible = hiddenAnonCount > 0, - enter = fadeIn(tween(BitchatMotion.STANDARD_MS)), - exit = fadeOut(tween(BitchatMotion.QUICK_MS)) - ) { - Text( - text = stringResource(R.string.people_n_more, hiddenAnonCount), + if (isTrimmed) { + // Always laid out when trimmed, so the count changing never moves anything. + // Only the number itself animates. + AnimatedCountLabel( + count = hiddenCount, + text = stringResource(R.string.people_n_more, hiddenCount), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = palette.textTertiary, - modifier = Modifier - .fillMaxWidth() - .padding( - start = SheetRowHorizontal, - end = SheetRowHorizontal, - bottom = SheetRowVertical - ) + modifier = Modifier.padding( + start = SheetRowHorizontal, + end = SheetRowHorizontal, + bottom = SheetRowVertical + ) ) } } @@ -298,8 +328,11 @@ private fun GeohashPersonItem( Row( modifier = Modifier .fillMaxWidth() + // 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, vertical = SheetRowVertical), + .padding(horizontal = SheetRowHorizontal), verticalAlignment = Alignment.CenterVertically ) { Box( diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index aac25e91..31db2ef1 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -72,6 +72,7 @@ import com.bitchat.android.ui.theme.BitchatMotion 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 @@ -106,6 +107,14 @@ private val MessagePlacementSpec: FiniteAnimationSpec = spring( /** Removals are not worth dwelling on. */ private val MessageFadeOutSpec: FiniteAnimationSpec = tween(BitchatMotion.QUICK_MS) +/** + * How long placement animation stays armed after the list gains or loses a message. + * + * Comfortably longer than [MessagePlacementSpec] takes to settle, so an arrival's push is never cut + * short. + */ +private const val PlacementArmWindowMs = 600L + /** * Above this many simultaneous arrivals, entry animations are skipped. * @@ -226,6 +235,24 @@ fun MessagesList( 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 { mutableStateOf(false) } + var previousMessageCount by remember { mutableStateOf(null) } + LaunchedEffect(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, @@ -294,7 +321,7 @@ fun MessagesList( // Entry fade is handled by entryModifier, together with the slide, so the // two cannot drift out of step. fadeInSpec = null, - placementSpec = MessagePlacementSpec, + placementSpec = if (placementArmed) MessagePlacementSpec else null, fadeOutSpec = MessageFadeOutSpec ) .then(entryModifier) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 275c5f79..357eedbd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -316,6 +316,7 @@ People (%1$d) On location Teleported in + Anonymous %1$d more\u2026 Grant location permission diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt index 8a00d1c8..342e03b0 100644 --- a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt @@ -102,6 +102,78 @@ class GeohashAnonOrderingTest { assertEquals(12 - MaxVisibleAnons, anons.size - visible.size) } + // MARK: - Sectioning + + /** Mirrors the grouping in GeohashPeopleList: self is never treated as an anon. */ + private fun sections(people: List, myId: String?): Triple, List, List> { + val isSelf: (GeoPerson) -> Boolean = { myId != null && it.id == myId } + val named = people.filter { isSelf(it) || !it.isAnonymous() } + val anons = people.filter { !isSelf(it) && it.isAnonymous() } + return Triple( + named.map { it.displayName }, + anons.map { it.displayName }, + people.map { it.displayName } + ) + } + + @Test + fun `anons are grouped out of the named sections entirely`() { + val people = listOf(person("alice"), person("anon1"), person("bob"), person("anon2")) + val (named, anons, _) = sections(people, myId = null) + + assertEquals(listOf("alice", "bob"), named) + assertEquals(listOf("anon1", "anon2"), anons) + } + + @Test + fun `self stays in the named sections even when unnamed`() { + // You always want to find yourself where you actually are, not buried in the anon section. + val me = person("anon") + val people = listOf(me, person("alice"), person("anon2")) + val (named, anons, _) = sections(people, myId = me.id) + + assertTrue("self must not be grouped as an anon", named.contains("anon")) + assertFalse(anons.contains("anon")) + assertEquals(listOf("anon2"), anons) + } + + @Test + fun `a list of only anons yields no named section`() { + val people = (1..4).map { person("anon$it") } + val (named, anons, _) = sections(people, myId = null) + + assertTrue(named.isEmpty()) + assertEquals(4, anons.size) + } + + // MARK: - Stable length + + @Test + fun `a trimmed anon list always renders exactly the cap`() { + // The reserved height is MaxVisibleAnons rows whenever trimmed, so the card cannot resize + // as anons churn above the cap. + for (total in listOf(MaxVisibleAnons + 1, MaxVisibleAnons + 7, MaxVisibleAnons + 40)) { + val anons = (1..total).map { person("anon$it") } + val visible = anons.take(MaxVisibleAnons) + assertEquals( + "row count must not depend on how many anons are present beyond the cap", + MaxVisibleAnons, + visible.size + ) + assertEquals(total - MaxVisibleAnons, anons.size - visible.size) + } + } + + @Test + fun `reordering never changes how many rows are rendered`() { + val anons = (1..9).map { person("anon$it", secondsAgo = it.toLong()) } + val byRecency = anons.sortedByDescending { it.lastSeen }.take(MaxVisibleAnons) + val reversed = anons.sortedBy { it.lastSeen }.take(MaxVisibleAnons) + + assertEquals(byRecency.size, reversed.size) + assertEquals(MaxVisibleAnons, byRecency.size) + } + @Test fun `a short anon list is shown in full with nothing hidden`() { val people = listOf(person("alice"), person("anon1"), person("anon2")) From bcfc33f35c38c955e3beab97812a17fe506f28d3 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:33:29 +0200 Subject: [PATCH 17/70] Group geohash people as People and Anon Replaces the "on location" / "teleported in" / "anonymous" split with two sections: peers who announced a nickname, then the anons. Teleport state was never worth a section of its own -- every row already carries it as a distinct glyph -- and splitting on it fragmented the short list people actually read, in a channel where most participants are anonymous anyway. Self stays in the People section even when unnamed. --- .../bitchat/android/ui/GeohashPeopleList.kt | 45 ++++++++----------- app/src/main/res/values/strings.xml | 5 +-- .../android/ui/GeohashAnonOrderingTest.kt | 11 +++-- 3 files changed, 28 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index bca7fe62..1196bf32 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.outlined.Explore +import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.LocationOn import android.util.Log @@ -59,8 +60,8 @@ fun GeohashPeopleList( Column(modifier = modifier) { if (geohashPeople.isEmpty()) { SheetIconSectionHeader( - icon = Icons.Outlined.LocationOn, - title = stringResource(R.string.section_on_location) + icon = Icons.Outlined.Person, + title = stringResource(R.string.section_people) ) Surface( modifier = Modifier @@ -126,15 +127,18 @@ fun GeohashPeopleList( viewModel.isPersonTeleported(person.id) } - // Anonymous participants form their own trailing section rather than a tail on each - // of the others. A busy geohash is mostly anons, and splitting them across "on - // location" and "teleported in" pushed the few recognisable names out of view twice - // over. Self is never grouped as an anon even when unnamed — you always want to find - // yourself where you actually are. + // Two groups: peers who announced a nickname, then the anons. + // + // A busy geohash is mostly anonymous drive-by participants, and mixing them in pushed + // the few recognisable names out of view. Teleport state is not a grouping any more — + // it is already on every row as its own glyph, so splitting "on location" from + // "teleported in" only fragmented the short list that people actually read. + // + // Self is never grouped as an anon even when unnamed: you always want to find yourself + // among the people, not buried at the bottom. val isSelf: (GeoPerson) -> Boolean = { myHex != null && it.id == myHex } val namedPeople = orderedPeople.filter { isSelf(it) || !it.isAnonymous() } val anonPeople = orderedPeople.filter { !isSelf(it) && it.isAnonymous() } - val (teleportedPeople, localPeople) = namedPeople.partition { personTeleported(it) } @Composable fun personRow(person: GeoPerson) { @@ -157,30 +161,19 @@ fun GeohashPeopleList( ) } - if (localPeople.isNotEmpty()) { + if (namedPeople.isNotEmpty()) { SheetIconSectionHeader( - icon = Icons.Outlined.LocationOn, - title = stringResource(R.string.section_on_location) + icon = Icons.Outlined.Person, + title = stringResource(R.string.section_people) ) - PeopleCard(people = localPeople, row = { personRow(it) }) - } - - if (teleportedPeople.isNotEmpty()) { - SheetIconSectionHeader( - icon = Icons.Outlined.Explore, - title = stringResource(R.string.section_teleported_in), - modifier = Modifier.padding(top = if (localPeople.isNotEmpty()) 20.dp else 0.dp) - ) - PeopleCard(people = teleportedPeople, row = { personRow(it) }) + PeopleCard(people = namedPeople, row = { personRow(it) }) } if (anonPeople.isNotEmpty()) { SheetIconSectionHeader( - icon = Icons.Outlined.Person, - title = stringResource(R.string.section_anonymous), - modifier = Modifier.padding( - top = if (localPeople.isNotEmpty() || teleportedPeople.isNotEmpty()) 20.dp else 0.dp - ) + icon = Icons.Outlined.HelpOutline, + title = stringResource(R.string.section_anon), + modifier = Modifier.padding(top = if (namedPeople.isNotEmpty()) 20.dp else 0.dp) ) PeopleCard(people = anonPeople, capped = true, row = { personRow(it) }) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 357eedbd..c5da7e33 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -314,9 +314,8 @@ People (%1$d) - On location - Teleported in - Anonymous + People + Anon %1$d more\u2026 Grant location permission diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt index 342e03b0..c4a76fcf 100644 --- a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt @@ -104,7 +104,10 @@ class GeohashAnonOrderingTest { // MARK: - Sectioning - /** Mirrors the grouping in GeohashPeopleList: self is never treated as an anon. */ + /** + * Mirrors the two-group split in GeohashPeopleList: peers who announced a nickname, then the + * anons. Self is never treated as an anon. + */ private fun sections(people: List, myId: String?): Triple, List, List> { val isSelf: (GeoPerson) -> Boolean = { myId != null && it.id == myId } val named = people.filter { isSelf(it) || !it.isAnonymous() } @@ -117,7 +120,7 @@ class GeohashAnonOrderingTest { } @Test - fun `anons are grouped out of the named sections entirely`() { + fun `anons are grouped out of the people section entirely`() { val people = listOf(person("alice"), person("anon1"), person("bob"), person("anon2")) val (named, anons, _) = sections(people, myId = null) @@ -126,7 +129,7 @@ class GeohashAnonOrderingTest { } @Test - fun `self stays in the named sections even when unnamed`() { + fun `self stays in the people section even when unnamed`() { // You always want to find yourself where you actually are, not buried in the anon section. val me = person("anon") val people = listOf(me, person("alice"), person("anon2")) @@ -138,7 +141,7 @@ class GeohashAnonOrderingTest { } @Test - fun `a list of only anons yields no named section`() { + fun `a list of only anons yields no people section`() { val people = (1..4).map { person("anon$it") } val (named, anons, _) = sections(people, myId = null) From fa43821cf4e7acf71d1f8eb7765c06823c8e95fc Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:36:09 +0200 Subject: [PATCH 18/70] Key message list state per conversation Switching channels reused every piece of state in MessagesList, because none of it was keyed on which conversation was being shown: - The LazyListState carried the previous channel's scroll offset, so the new channel opened at a stale position and then corrected itself. - hasScrolledToInitialPosition and followIncomingMessages carried over, so a channel entered after scrolling up in another one did not land on its newest message at all. - The arrival tracker had never seen the incoming channel's ids, so a backlog of six or fewer messages was treated as six simultaneous arrivals and each one slid in. - previousMessageCount carried over, arming placement animation for the relayout that the switch itself caused. All of it is now keyed on a conversationKey derived the same way displayMessages is. The tracker also detects a list sharing no ids with the previous one and adopts it silently, which covers /clear and any caller that does not supply a distinct key. Adds 4 tests for wholesale replacement, including the case that the burst cap cannot catch on its own. --- .../java/com/bitchat/android/ui/ChatScreen.kt | 15 ++++++ .../android/ui/LocationChannelsSheet.kt | 50 +++++++++++++++++++ .../bitchat/android/ui/MeshPeerListSheet.kt | 1 + .../bitchat/android/ui/MessageComponents.kt | 48 ++++++++++++++---- .../android/ui/LocationChannelsSheetTest.kt | 49 ++++++++++++++++++ .../android/ui/MessageArrivalTrackerTest.kt | 45 +++++++++++++++++ 6 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index ba3c627c..746135a7 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -187,6 +187,20 @@ 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" + } + } + } + // Determine whether to show media buttons (only hide in geohash location chats) val showMediaButtons = when { currentChannel != null -> true @@ -229,6 +243,7 @@ fun ChatScreen(viewModel: ChatViewModel) { currentUserNickname = nickname, meshService = viewModel.meshServiceFacade, modifier = Modifier.fillMaxSize(), + conversationKey = conversationKey, contentPadding = PaddingValues( top = statusBarHeight + headerHeight + (if (showNotesStrip) notesStripHeight else 0.dp), diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 740673be..cb43cab0 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -154,6 +154,9 @@ fun LocationChannelsSheet( val nearbyChannels = remember(availableChannels) { availableChannels.filter { it.level != GeohashChannelLevel.BUILDING } } + val selectedChannelOutsideNearby = remember(selectedChannel, nearbyChannels) { + selectedLocationChannelOutsideNearby(selectedChannel, nearbyChannels) + } val showNearbyLoading = nearbyChannels.isEmpty() && permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled @@ -272,6 +275,35 @@ fun LocationChannelsSheet( shape = AboutCardShape ) { Column { + selectedChannelOutsideNearby?.let { channel -> + val coverage = coverageString(channel.geohash.length) + val name = bookmarkNames[channel.geohash] + val subtitle = "#${channel.geohash} • $coverage" + + (name?.let { " • ${formattedNamePrefix(channel.level)}$it" } ?: "") + val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 + val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) + + ChannelOptionRow( + title = geohashTitleWithCount(channel, participantCount), + subtitle = subtitle, + isSelected = true, + participantCount = participantCount, + titleColor = standardGreen, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = isBookmarked, + onClick = { bookmarksStore.toggle(channel.geohash) } + ) + }, + onClick = { + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } + ) + SheetCardDivider() + } + if (locationServicesEnabled) { if (nearbyChannels.isNotEmpty()) { nearbyChannels.forEachIndexed { index, channel -> @@ -952,6 +984,24 @@ private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelI } } +/** + * Returns the active location channel when it has no row in the nearby-channel list. + * + * This commonly happens after teleporting to a remote geohash. Keeping the selected channel in the + * main channel card makes its selection and bookmark action available even before it is bookmarked. + */ +internal fun selectedLocationChannelOutsideNearby( + selectedChannel: ChannelID?, + nearbyChannels: List +): GeohashChannel? { + val selected = (selectedChannel as? ChannelID.Location)?.channel ?: return null + return selected.takeUnless { active -> + nearbyChannels.any { nearby -> + nearby.geohash.equals(active.geohash, ignoreCase = true) + } + } +} + private fun validateGeohash(geohash: String): Boolean { if (geohash.isEmpty() || geohash.length > 12) return false val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index cfda3952..baaed09e 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -887,6 +887,7 @@ fun PrivateChatSheet( currentUserNickname = nickname, meshService = viewModel.meshServiceFacade, modifier = Modifier.weight(1f), + conversationKey = "dm:$peerID", forceScrollToBottom = forceScrollToBottom, onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, onNicknameClick = { /* handle mention */ }, diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 31db2ef1..c53aa7c0 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -29,8 +29,8 @@ 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.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ColorScheme import androidx.compose.material3.Icon @@ -42,6 +42,7 @@ 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 @@ -153,6 +154,12 @@ internal fun MessageArrivalTracker.arrivals(messages: List): Set 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) } @@ -163,6 +170,7 @@ internal fun MessageArrivalTracker.arrivals(messages: List): Set } return when { + isWholesaleReplacement -> emptySet() added.isEmpty() || added.size > MaxAnimatedArrivals -> emptySet() else -> added.mapTo(HashSet(added.size)) { it.id } } @@ -181,6 +189,14 @@ fun MessagesList( * 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, @@ -188,11 +204,19 @@ fun MessagesList( onCancelTransfer: ((BitchatMessage) -> Unit)? = null, onImageClick: ((String, List, Int) -> Unit)? = null ) { - val listState = rememberLazyListState() - + // 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) { @@ -208,7 +232,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 @@ -230,8 +254,10 @@ 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. - val arrivalTracker = remember { MessageArrivalTracker() } - val enteringIds = remember(messages.size, messages.lastOrNull()?.id) { + // 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) } @@ -240,9 +266,9 @@ fun MessagesList( // 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 { mutableStateOf(false) } - var previousMessageCount by remember { mutableStateOf(null) } - LaunchedEffect(messages.size) { + var placementArmed by remember(conversationKey) { mutableStateOf(false) } + var previousMessageCount by remember(conversationKey) { mutableStateOf(null) } + LaunchedEffect(conversationKey, messages.size) { val previous = previousMessageCount previousMessageCount = messages.size // Skip the first composition: the list settling into its initial padding is not a change diff --git a/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt b/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt new file mode 100644 index 00000000..e53665ca --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt @@ -0,0 +1,49 @@ +package com.bitchat.android.ui + +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class LocationChannelsSheetTest { + + @Test + fun `teleported channel gets a standalone row`() { + val nearby = listOf(channel("u33dc")) + val teleported = channel("dr5ru") + + assertEquals( + teleported, + selectedLocationChannelOutsideNearby(ChannelID.Location(teleported), nearby) + ) + } + + @Test + fun `nearby selected channel is not duplicated`() { + val nearby = channel("u33dc") + + assertNull( + selectedLocationChannelOutsideNearby( + ChannelID.Location(channel("U33DC")), + listOf(nearby) + ) + ) + } + + @Test + fun `mesh selection has no standalone location row`() { + assertNull( + selectedLocationChannelOutsideNearby( + ChannelID.Mesh, + listOf(channel("u33dc")) + ) + ) + } + + private fun channel(geohash: String) = GeohashChannel( + level = GeohashChannelLevel.CITY, + geohash = geohash + ) +} diff --git a/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt index 0c848281..fb534e69 100644 --- a/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt @@ -126,6 +126,51 @@ class MessageArrivalTrackerTest { assertEquals(setOf("a"), tracker.arrivals(listOf(msg("a")))) } + @Test + fun `a wholesale replacement animates nothing`() { + // Switching channels, or /clear followed by fresh content: the incoming list shares no ids + // with the outgoing one, so it is a different conversation rather than a burst of arrivals. + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"), msg("b"))) + + val other = listOf(msg("x"), msg("y")) + assertTrue( + "a different conversation must not slide every message in", + tracker.arrivals(other).isEmpty() + ) + } + + @Test + fun `a small replacement below the burst cap still animates nothing`() { + // The burst cap alone would not catch this: two messages is well under it. + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"), msg("b"), msg("c"))) + + assertTrue(tracker.arrivals(listOf(msg("x"), msg("y"))).isEmpty()) + } + + @Test + fun `a replacement that overlaps is treated as normal arrivals`() { + // Still the same conversation if anything carries over, so genuine new messages animate. + val tracker = MessageArrivalTracker() + val kept = msg("a") + tracker.arrivals(listOf(kept, msg("b"))) + + assertEquals(setOf("c"), tracker.arrivals(listOf(kept, msg("c")))) + } + + @Test + fun `after a wholesale replacement, later arrivals animate normally`() { + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"))) + + val switched = mutableListOf(msg("x")) + tracker.arrivals(switched) // adopted silently + + switched += msg("y") + assertEquals(setOf("y"), tracker.arrivals(switched)) + } + @Test fun `the known set never outgrows the conversation`() { val tracker = MessageArrivalTracker() From 562aa787d6a93b144915d49c4f85bb48c2ff1bf9 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:49:57 +0200 Subject: [PATCH 19/70] fix location channel layout --- .../android/ui/LocationChannelsSheet.kt | 201 ++++++++++-------- app/src/main/res/values/strings.xml | 3 +- 2 files changed, 115 insertions(+), 89 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index cb43cab0..bd8f2b9d 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -257,53 +257,129 @@ fun LocationChannelsSheet( } } - // Location channels: globe + title, geohash subtitle, nearby levels + teleport + item(key = "location_channels_header") { + SheetIconSectionHeader( + icon = Icons.Outlined.Public, + title = stringResource(R.string.location_channels_heading), + subtitle = stringResource(R.string.location_channels_desc), + modifier = Modifier.padding(top = 20.dp) + ) + } + + selectedChannelOutsideNearby?.let { channel -> + item(key = "teleported_card") { + val coverage = coverageString(channel.geohash.length) + val name = bookmarkNames[channel.geohash] + val subtitle = "#${channel.geohash} • $coverage" + + (name?.let { " • ${formattedNamePrefix(channel.level)}$it" } ?: "") + val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 + val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) + + Column { + AboutSectionLabel(text = stringResource(R.string.cd_teleported)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape + ) { + ChannelOptionRow( + title = geohashHashTitleWithCount( + channel.geohash, + participantCount + ), + subtitle = subtitle, + isSelected = true, + participantCount = participantCount, + titleColor = standardGreen, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = isBookmarked, + onClick = { + bookmarksStore.toggle(channel.geohash) + } + ) + }, + onClick = { + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } + ) + } + } + } + } + + if (bookmarks.isNotEmpty()) { + item(key = "bookmarks_card") { + Column { + AboutSectionLabel(text = stringResource(R.string.bookmarked)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + bookmarks.forEachIndexed { index, gh -> + if (index > 0) SheetCardDivider() + val level = levelForLength(gh.length) + val channel = GeohashChannel(level = level, geohash = gh) + val coverage = coverageString(gh.length) + val name = bookmarkNames[gh] + val subtitle = "#$gh • $coverage" + + (name?.let { " • ${formattedNamePrefix(level)}$it" } ?: "") + val participantCount = geohashParticipantCounts[gh] ?: 0 + + ChannelOptionRow( + title = geohashHashTitleWithCount(gh, participantCount), + subtitle = subtitle, + isSelected = isChannelSelected(channel, selectedChannel), + participantCount = participantCount, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = true, + onClick = { bookmarksStore.toggle(gh) } + ) + }, + onClick = { + val inRegional = + availableChannels.any { it.geohash == gh } + locationManager.setTeleported( + !inRegional && availableChannels.isNotEmpty() + ) + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } + ) + LaunchedEffect(gh) { + bookmarksStore.resolveNameIfNeeded(gh) + } + } + } + } + } + } + } + + // Nearby location channels and the control for teleporting somewhere new. item(key = "channels_card") { Column { - SheetIconSectionHeader( - icon = Icons.Outlined.Public, - title = stringResource(R.string.location_channels_heading), - subtitle = stringResource(R.string.location_channels_desc), - modifier = Modifier.padding(top = 20.dp) + AboutSectionLabel( + text = stringResource(R.string.location_channels_nearby) ) Surface( modifier = Modifier .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding) - .padding(top = 10.dp), + .padding(horizontal = AboutHorizontalPadding), color = palette.surface, shape = AboutCardShape ) { Column { - selectedChannelOutsideNearby?.let { channel -> - val coverage = coverageString(channel.geohash.length) - val name = bookmarkNames[channel.geohash] - val subtitle = "#${channel.geohash} • $coverage" + - (name?.let { " • ${formattedNamePrefix(channel.level)}$it" } ?: "") - val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 - val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) - - ChannelOptionRow( - title = geohashTitleWithCount(channel, participantCount), - subtitle = subtitle, - isSelected = true, - participantCount = participantCount, - titleColor = standardGreen, - titleBold = participantCount > 0, - trailingContent = { - ChannelBookmarkButton( - bookmarked = isBookmarked, - onClick = { bookmarksStore.toggle(channel.geohash) } - ) - }, - onClick = { - locationManager.select(ChannelID.Location(channel)) - onDismiss() - } - ) - SheetCardDivider() - } - if (locationServicesEnabled) { if (nearbyChannels.isNotEmpty()) { nearbyChannels.forEachIndexed { index, channel -> @@ -418,57 +494,6 @@ fun LocationChannelsSheet( } } - if (bookmarks.isNotEmpty()) { - item(key = "bookmarks_card") { - Column { - AboutSectionLabel(text = stringResource(R.string.bookmarked)) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, - shape = AboutCardShape - ) { - Column { - bookmarks.forEachIndexed { index, gh -> - if (index > 0) SheetCardDivider() - val level = levelForLength(gh.length) - val channel = GeohashChannel(level = level, geohash = gh) - val coverage = coverageString(gh.length) - val name = bookmarkNames[gh] - val subtitle = "#$gh • $coverage" + - (name?.let { " • ${formattedNamePrefix(level)}$it" } ?: "") - val participantCount = geohashParticipantCounts[gh] ?: 0 - - ChannelOptionRow( - title = geohashHashTitleWithCount(gh, participantCount), - subtitle = subtitle, - isSelected = isChannelSelected(channel, selectedChannel), - participantCount = participantCount, - titleBold = participantCount > 0, - trailingContent = { - ChannelBookmarkButton( - bookmarked = true, - onClick = { bookmarksStore.toggle(gh) } - ) - }, - onClick = { - val inRegional = availableChannels.any { it.geohash == gh } - locationManager.setTeleported( - !inRegional && availableChannels.isNotEmpty() - ) - locationManager.select(ChannelID.Location(channel)) - onDismiss() - } - ) - LaunchedEffect(gh) { bookmarksStore.resolveNameIfNeeded(gh) } - } - } - } - } - } - } - item(key = "tor_routing") { val torProvider = remember { ArtiTorManager.getInstance() } val torAvailable = remember { torProvider.isTorAvailable() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5da7e33..d7aba32e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -303,7 +303,8 @@ Channels Location Channels - Chat nearby over geohash channels. Only a coarse location is shared, never exact GPS. + Chat by coarse location. Never shares exact GPS. + Nearby Offline mesh via Bluetooth. No internet required. From 7ad709c38ef464e30abae2863fe559dc2484b16e Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:51:34 +0200 Subject: [PATCH 20/70] icon --- app/src/main/java/com/bitchat/android/ui/ChatHeader.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 6e79ab91..acf8b1e7 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -598,7 +598,7 @@ private fun MainHeader( } /** - * Current channel indicator: a globe for geohash channels, a Bluetooth glyph for the local mesh. + * 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 @@ -618,18 +618,18 @@ private fun LocationChannelsButton( 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 Bluetooth glyph already says what it - // is, so it is plain "mesh". + // 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) } val channelColor = if (isLocation) palette.accentGreen else palette.accentBlue - // Tor status only tints the globe (location channels). Mesh Bluetooth stays blue. + // 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 badgeIcon = if (isLocation) Icons.Outlined.Public else Icons.Filled.Bluetooth + val badgeIcon = if (isLocation) Icons.Outlined.Public else Icons.Filled.Hub Row( verticalAlignment = Alignment.CenterVertically, From a277b5e753c5bf63803b8de2e87cae41937ef3be Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:57:03 +0200 Subject: [PATCH 21/70] location sheet --- .../com/bitchat/android/ui/LocationChannelsSheet.kt | 12 +++++++++--- app/src/main/res/values/strings.xml | 5 +++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index bd8f2b9d..97ffa061 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -67,6 +67,7 @@ import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.wifiaware.WifiAwareController import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -115,6 +116,7 @@ fun LocationChannelsSheet( val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle() val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle() val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle() + val wifiAwareEnabled by WifiAwareController.enabled.collectAsStateWithLifecycle() var customGeohash by remember { mutableStateOf("") } var customError by remember { mutableStateOf(null) } @@ -241,8 +243,12 @@ fun LocationChannelsSheet( ChannelOptionRow( title = meshTitleWithCount(viewModel), subtitle = stringResource( - R.string.location_bluetooth_subtitle, - bluetoothRangeString() + if (wifiAwareEnabled) { + R.string.location_bluetooth_wifi_subtitle + } else { + R.string.location_bluetooth_subtitle + }, + meshRangeString() ), isSelected = selectedChannel is ChannelID.Mesh, participantCount = meshCount(viewModel), @@ -1069,6 +1075,6 @@ private fun formatDistance(value: Double): String { } } -private fun bluetoothRangeString(): String = "~10–50 m" +private fun meshRangeString(): String = "~10–50m" private fun formattedNamePrefix(level: GeohashChannelLevel): String = "~" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d7aba32e..212f1be2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -275,7 +275,8 @@ Offline Mesh Chat Online Geohash Channels End-to-End Encryption - #bluetooth • %1$s + Bluetooth • %1$s + Bluetooth • Wifi • %1$s Image %1$d of %2$d Image unavailable @@ -305,7 +306,7 @@ Location Channels Chat by coarse location. Never shares exact GPS. Nearby - Offline mesh via Bluetooth. No internet required. + Offline mesh. No internet required. Nearby From 703fcd0e026f0ceb844e9a8ab708b345c6291c48 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:02:11 +0200 Subject: [PATCH 22/70] move location error --- .../android/ui/LocationChannelsSheet.kt | 77 ++++++++++--------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 97ffa061..92c10264 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -187,42 +187,6 @@ fun LocationChannelsSheet( contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(0.dp) ) { - if (locationServicesEnabled && - permissionState == LocationChannelManager.PermissionState.DENIED - ) { - item(key = "permissions") { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding) - .padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = stringResource(R.string.location_permission_denied), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = palette.accentRed - ) - TextButton( - onClick = { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - } - context.startActivity(intent) - }, - contentPadding = PaddingValues(0.dp) - ) { - Text( - text = stringResource(R.string.open_settings), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace - ) - } - } - } - } - // Mesh section: icon + title header, offline subtitle, then selection card item(key = "mesh_card") { Column { @@ -372,6 +336,47 @@ fun LocationChannelsSheet( } } + if (locationServicesEnabled && + permissionState == LocationChannelManager.PermissionState.DENIED + ) { + item(key = "permissions") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(R.string.location_permission_denied), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = palette.accentRed + ) + TextButton( + onClick = { + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts( + "package", + context.packageName, + null + ) + } + context.startActivity(intent) + }, + contentPadding = PaddingValues(0.dp) + ) { + Text( + text = stringResource(R.string.open_settings), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace + ) + } + } + } + } + // Nearby location channels and the control for teleporting somewhere new. item(key = "channels_card") { Column { From 8db6ccfdc3bb93d291251324b830ec4289b5f4c4 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:06:00 +0200 Subject: [PATCH 23/70] fix location channel lifecycle bug --- .../android/ui/LocationChannelsSheet.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 92c10264..dd892cfc 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -51,6 +51,7 @@ import androidx.activity.result.contract.ActivityResultContracts import com.bitchat.android.nostr.NearbyNotesController import com.bitchat.android.nostr.geohashesForSampling import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet @@ -588,15 +589,16 @@ fun LocationChannelsSheet( } } - DisposableEffect(isPresented, permissionState, locationServicesEnabled) { - if (isPresented && - permissionState == LocationChannelManager.PermissionState.AUTHORIZED && - locationServicesEnabled - ) { - locationManager.refreshChannels() - locationManager.beginLiveRefresh() + LifecycleResumeEffect(isPresented, locationServicesEnabled) { + if (isPresented && locationServicesEnabled) { + locationManager.enableLocationChannels() + if (locationManager.permissionState.value == + LocationChannelManager.PermissionState.AUTHORIZED + ) { + locationManager.beginLiveRefresh() + } } - onDispose { locationManager.endLiveRefresh() } + onPauseOrDispose { locationManager.endLiveRefresh() } } // Sampling management: update sampling when channels/bookmarks change From 7b03863eaaeb600b2e55832da7e336f82790f97a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:10:51 +0200 Subject: [PATCH 24/70] remove empty lable --- .../java/com/bitchat/android/ui/ChatScreen.kt | 42 ------------------- 1 file changed, 42 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 746135a7..58e777f0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -30,9 +30,6 @@ 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 @@ -300,21 +297,6 @@ fun ChatScreen(viewModel: ChatViewModel) { } ) - if ( - displayMessages.isEmpty() && - isMeshTimeline && - !nearbyNotesRevealed && - locationEnabled && - locationPermissionState == - LocationChannelManager.PermissionState.AUTHORIZED && - buildingGeohash != null - ) { - NearbyNotesRevealHint( - onClick = nearbyNotesController::reveal, - modifier = Modifier.align(Alignment.Center), - ) - } - if (showNotesStrip) { NearbyNotesStrip( noteCount = nearbyNotes.size, @@ -527,30 +509,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, From a85ba406377fcc87d867f4bfac8374c1301a17d8 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:27:15 +0200 Subject: [PATCH 25/70] geist mono --- .../ui/component/sheet/BitchatSheetTopBar.kt | 4 +- .../android/hotspot/HotspotActivity.kt | 8 +- .../BackgroundLocationPermissionScreen.kt | 18 +-- .../onboarding/BatteryOptimizationScreen.kt | 26 ++-- .../onboarding/BluetoothCheckScreen.kt | 16 +-- .../android/onboarding/InitializingScreen.kt | 20 +-- .../android/onboarding/LocationCheckScreen.kt | 20 +-- .../onboarding/PermissionExplanationScreen.kt | 10 +- .../com/bitchat/android/ui/AboutSections.kt | 30 ++-- .../java/com/bitchat/android/ui/AboutSheet.kt | 26 ++-- .../java/com/bitchat/android/ui/ChatHeader.kt | 5 +- .../java/com/bitchat/android/ui/ChatScreen.kt | 6 +- .../com/bitchat/android/ui/ChatUIUtils.kt | 85 ++++++++---- .../com/bitchat/android/ui/ChatUserSheet.kt | 12 +- .../bitchat/android/ui/GeohashPeopleList.kt | 12 +- .../android/ui/GeohashPickerActivity.kt | 8 +- .../com/bitchat/android/ui/InputComponents.kt | 24 ++-- .../com/bitchat/android/ui/LinkPreviewPill.kt | 6 +- .../android/ui/LocationChannelsSheet.kt | 30 ++-- .../bitchat/android/ui/LocationNotesSheet.kt | 36 ++--- .../bitchat/android/ui/MeshPeerListSheet.kt | 16 +-- .../bitchat/android/ui/MessageComponents.kt | 28 ++-- .../com/bitchat/android/ui/MessageGrouping.kt | 15 +- .../android/ui/SecurityVerificationSheet.kt | 28 ++-- .../bitchat/android/ui/VerificationSheet.kt | 24 ++-- .../android/ui/debug/DebugSettingsSheet.kt | 130 +++++++++--------- .../android/ui/media/AudioMessageItem.kt | 4 +- .../android/ui/media/FileSendingAnimation.kt | 7 +- .../android/ui/media/FullScreenImageViewer.kt | 3 +- .../android/ui/media/ImageMessageItem.kt | 6 +- .../android/ui/media/VoiceNotePlayer.kt | 4 +- .../android/ui/theme/BitchatPalette.kt | 2 +- .../android/ui/theme/ChatVisualTokens.kt | 64 +++++++++ .../bitchat/android/ui/theme/Typography.kt | 37 ++--- .../com/bitchat/android/util/AppConstants.kt | 2 +- app/src/main/res/font/geist_mono_bold.ttf | Bin 0 -> 150492 bytes app/src/main/res/font/geist_mono_medium.ttf | Bin 0 -> 149328 bytes app/src/main/res/font/geist_mono_regular.ttf | Bin 0 -> 148516 bytes app/src/main/res/font/geist_mono_semibold.ttf | Bin 0 -> 149700 bytes app/src/main/res/raw/geist_mono_ofl.txt | 93 +++++++++++++ .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 61 ++++++++ .../bitchat/android/ui/MessageGroupingTest.kt | 10 +- 42 files changed, 590 insertions(+), 346 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt create mode 100644 app/src/main/res/font/geist_mono_bold.ttf create mode 100644 app/src/main/res/font/geist_mono_medium.ttf create mode 100644 app/src/main/res/font/geist_mono_regular.ttf create mode 100644 app/src/main/res/font/geist_mono_semibold.ttf create mode 100644 app/src/main/res/raw/geist_mono_ofl.txt diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt index 333e3ae6..b8a027dc 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt @@ -10,10 +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) @@ -83,7 +83,7 @@ fun BitchatSheetTitle(text: String) { text = text, style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt index 795cce34..dbd3888b 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt @@ -30,12 +30,12 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BitchatTheme import com.bitchat.android.util.UniversalApkManager import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -112,7 +112,7 @@ fun HotspotScreen( title = { Text( text = "Share BitChat", - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) }, navigationIcon = { @@ -398,7 +398,7 @@ fun ActiveHotspotScreen(state: HotspotViewModel.HotspotState.Active) { text = { Text( text = title, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal ) } @@ -615,7 +615,7 @@ fun CredentialCard( Text( text = value, style = MaterialTheme.typography.bodyLarge, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSecondaryContainer ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt index 93101abf..50a58beb 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt @@ -28,11 +28,11 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -99,7 +99,7 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.background_location_settings_tip), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = colorScheme.onBackground.copy(alpha = 0.8f) ) @@ -140,14 +140,14 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.background_location_needs_bullets), style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.8f) ) Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(R.string.background_location_privacy_note), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium ), color = colorScheme.onBackground @@ -183,7 +183,7 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.grant_background_location), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -201,7 +201,7 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.check_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } @@ -213,7 +213,7 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.battery_optimization_skip), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } @@ -234,7 +234,7 @@ private fun HeaderSection(colorScheme: ColorScheme) { Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -244,7 +244,7 @@ private fun HeaderSection(colorScheme: ColorScheme) { Text( text = stringResource(R.string.background_location_required_subtitle), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } diff --git a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt index 0b3036aa..713fe7f8 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt @@ -15,11 +15,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -105,7 +105,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -115,7 +115,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.battery_optimization_detected_title), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } @@ -225,7 +225,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.disable_battery_optimization), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ) ) @@ -243,7 +243,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.check_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } @@ -259,7 +259,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.battery_optimization_skip), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } @@ -284,7 +284,7 @@ private fun BatteryOptimizationCheckingContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -294,7 +294,7 @@ private fun BatteryOptimizationCheckingContent( Text( text = stringResource(R.string.battery_optimization_disabled_title), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } @@ -322,7 +322,7 @@ private fun BatteryOptimizationCheckingContent( Text( text = stringResource(R.string.battery_optimization_success_message), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.8f) ), textAlign = TextAlign.Center @@ -347,7 +347,7 @@ private fun BatteryOptimizationNotSupportedContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -357,7 +357,7 @@ private fun BatteryOptimizationNotSupportedContent( Text( text = stringResource(R.string.battery_optimization_not_required), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } @@ -372,7 +372,7 @@ private fun BatteryOptimizationNotSupportedContent( Text( text = stringResource(R.string.battery_optimization_not_supported_message), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.8f) ), textAlign = TextAlign.Center @@ -388,7 +388,7 @@ private fun BatteryOptimizationNotSupportedContent( Text( text = stringResource(R.string.continue_btn), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt index 933656bd..3eb6eea9 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt @@ -10,11 +10,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 /** @@ -83,7 +83,7 @@ private fun BluetoothDisabledContent( Text( text = stringResource(R.string.bluetooth_recommended), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -114,7 +114,7 @@ private fun BluetoothDisabledContent( Text( text = stringResource(R.string.bluetooth_needs_bullets), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ) ) @@ -138,7 +138,7 @@ private fun BluetoothDisabledContent( Text( text = stringResource(R.string.enable_bluetooth), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -187,7 +187,7 @@ private fun BluetoothNotSupportedContent( Text( text = stringResource(R.string.bluetooth_not_supported), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.error ), @@ -204,7 +204,7 @@ private fun BluetoothNotSupportedContent( Text( text = stringResource(R.string.bluetooth_unsupported_explanation), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface ), modifier = Modifier.padding(16.dp), @@ -235,7 +235,7 @@ private fun BluetoothCheckingContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -247,7 +247,7 @@ private fun BluetoothCheckingContent( Text( text = stringResource(R.string.checking_bluetooth_status), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.7f) ) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt index bd231afd..f7895c65 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt @@ -8,11 +8,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -63,7 +63,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -92,7 +92,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.initializing_mesh_network), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.7f) ) ) @@ -102,7 +102,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.dot), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = alpha) ) ) @@ -127,7 +127,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.setting_up_bluetooth), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ), textAlign = TextAlign.Center @@ -136,7 +136,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.should_take_seconds), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.6f) ), textAlign = TextAlign.Center @@ -184,7 +184,7 @@ fun InitializationErrorScreen( Text( text = stringResource(R.string.setup_not_complete), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.error ), @@ -201,7 +201,7 @@ fun InitializationErrorScreen( Text( text = errorMessage, style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface ), modifier = Modifier.padding(16.dp), @@ -220,7 +220,7 @@ fun InitializationErrorScreen( Text( text = stringResource(R.string.try_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -234,7 +234,7 @@ fun InitializationErrorScreen( Text( text = stringResource(R.string.open_settings), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), modifier = Modifier.padding(vertical = 4.dp) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt index 32c86c0b..3654aba8 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt @@ -11,11 +11,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 /** @@ -80,7 +80,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.location_services_required), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -122,7 +122,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.location_explanation), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ) ) @@ -142,7 +142,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.location_needs_bullets), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ) ) @@ -166,7 +166,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.open_location_settings), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -180,7 +180,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.check_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), modifier = Modifier.padding(vertical = 4.dp) ) @@ -209,7 +209,7 @@ private fun LocationNotAvailableContent( Text( text = stringResource(R.string.location_services_unavailable), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.error ), @@ -226,7 +226,7 @@ private fun LocationNotAvailableContent( Text( text = stringResource(R.string.location_unavailable_explanation), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface ), modifier = Modifier.padding(16.dp), @@ -247,7 +247,7 @@ private fun LocationCheckingContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -259,7 +259,7 @@ private fun LocationCheckingContent( Text( text = stringResource(R.string.checking_location_services), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.7f) ) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt index a9b61476..afd91da3 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -18,12 +18,12 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -68,7 +68,7 @@ fun PermissionExplanationScreen( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -79,7 +79,7 @@ fun PermissionExplanationScreen( Text( text = stringResource(R.string.about_tagline), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } @@ -117,7 +117,7 @@ fun PermissionExplanationScreen( Text( text = stringResource(R.string.privacy_bullets), style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.8f) ) } @@ -164,7 +164,7 @@ fun PermissionExplanationScreen( Text( text = stringResource(R.string.grant_permissions), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index 8685705e..af0cc08b 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -46,10 +46,10 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset 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 +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 @@ -108,7 +108,7 @@ internal fun AboutSectionLabel( val palette = LocalBitchatPalette.current Text( text = text.uppercase(), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.8.sp, @@ -149,7 +149,7 @@ internal fun SheetIconSectionHeader( Text( text = title, fontSize = 17.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.SemiBold, color = colorScheme.primary ) @@ -159,7 +159,7 @@ internal fun SheetIconSectionHeader( text = subtitle, fontSize = 12.sp, lineHeight = 17.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textSecondary ) } @@ -205,7 +205,7 @@ internal fun AboutHero( Text( text = stringResource(R.string.app_name), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 40.sp, // Monospace at display size leaves too much air between glyphs; pull it in slightly @@ -218,7 +218,7 @@ internal fun AboutHero( Text( text = stringResource(R.string.about_tagline), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 16.sp, color = palette.textSecondary ) @@ -227,7 +227,7 @@ internal fun AboutHero( Text( text = stringResource(R.string.version_prefix, versionName), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = palette.textTertiary ) @@ -320,7 +320,7 @@ private fun AboutTabLabel( ) { Text( text = text.uppercase(), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 13.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.8.sp, @@ -355,7 +355,7 @@ private fun AboutInstructionRow( ) Text( text = text, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, lineHeight = 20.sp, color = palette.textPrimary @@ -373,7 +373,7 @@ internal fun AboutHowToUseSection(modifier: Modifier = Modifier) { Column(modifier = modifier.fillMaxWidth()) { Text( text = stringResource(R.string.about_how_to_use_heading), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = colorScheme.primary, @@ -481,7 +481,7 @@ private fun AboutFeatureRow( Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( text = title, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp, @@ -489,7 +489,7 @@ private fun AboutFeatureRow( ) Text( text = subtitle, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, lineHeight = 17.sp, color = palette.textSecondary @@ -514,7 +514,7 @@ internal fun BitchatBadge( ) { Text( text = text.uppercase(), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.5.sp, @@ -539,7 +539,7 @@ internal fun SheetSectionLabel( val palette = LocalBitchatPalette.current Text( text = text.uppercase(), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.8.sp, @@ -608,7 +608,7 @@ internal fun SheetDestructiveButton( Box(contentAlignment = Alignment.Center) { Text( text = text.uppercase(), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.8.sp, diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 34418056..907f7673 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -52,12 +52,12 @@ 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 @@ -111,7 +111,7 @@ private fun ThemeChip( ) { Text( text = label, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 13.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, color = labelColor @@ -188,7 +188,7 @@ private fun SettingsToggleRow( ) { Text( text = title, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = titleColor @@ -197,7 +197,7 @@ private fun SettingsToggleRow( } Text( text = subtitle, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = subtitleColor, lineHeight = 17.sp @@ -789,7 +789,7 @@ fun AboutSheet( Text( text = stringResource(R.string.tor_not_available_in_this_build), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textTertiary, modifier = Modifier.padding( start = AboutHorizontalPadding + 16.dp, @@ -825,7 +825,7 @@ fun AboutSheet( ) { Text( text = stringResource(R.string.about_difficulty), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = palette.textPrimary @@ -837,7 +837,7 @@ fun AboutSheet( powDifficulty, NostrProofOfWork.estimateMiningTime(powDifficulty) ), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = palette.textSecondary ) @@ -867,7 +867,7 @@ fun AboutSheet( else -> stringResource(R.string.about_pow_desc_extreme) }, fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textTertiary ) } @@ -911,7 +911,7 @@ fun AboutSheet( } else { stringResource(R.string.about_tor_disconnected) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = palette.textPrimary @@ -921,7 +921,7 @@ fun AboutSheet( Text( text = torStatus.lastLogLine.take(120), fontSize = 11.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textTertiary, maxLines = 2 ) @@ -952,7 +952,7 @@ fun AboutSheet( Text( text = stringResource(R.string.about_debug_settings), fontSize = 13.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.primary ) } @@ -960,7 +960,7 @@ fun AboutSheet( Text( text = stringResource(R.string.about_footer), fontSize = 11.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textTertiary ) Spacer(modifier = Modifier.height(20.dp)) @@ -1028,7 +1028,7 @@ fun PasswordPromptDialog( onValueChange = onPasswordChange, label = { Text(stringResource(R.string.pwd_label), style = MaterialTheme.typography.bodyMedium) }, textStyle = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = colorScheme.primary, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index acf8b1e7..2811bff4 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -1,6 +1,5 @@ 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.* @@ -35,12 +34,12 @@ import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalFocusManager 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.input.ImeAction 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.BitChatBrandButton import com.bitchat.android.net.ArtiTorManager @@ -277,7 +276,7 @@ fun NicknameEditor( onValueChange = onValueChange, textStyle = MaterialTheme.typography.bodyMedium.copy( color = colorScheme.primary, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = HeaderTextSize ), cursorBrush = SolidColor(colorScheme.primary), diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 58e777f0..911933ba 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -1,4 +1,7 @@ package com.bitchat.android.ui + +import com.bitchat.android.ui.theme.BitchatFontFamily +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 @@ -24,7 +27,6 @@ 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 @@ -535,7 +537,7 @@ private fun NearbyNotesStrip( }, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.primary, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, ) Text( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index d1415fbe..33cb0ab4 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.unit.sp import com.bitchat.android.model.BitchatMessage import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BitchatPalette +import com.bitchat.android.ui.theme.ChatVisualTokens import java.text.SimpleDateFormat import java.util.* @@ -18,13 +19,13 @@ import java.util.* */ /** Opacity applied to the `#abcd` disambiguation suffix so the readable name dominates. */ -internal const val SUFFIX_ALPHA = 0.55f +internal const val SUFFIX_ALPHA = ChatVisualTokens.SenderSuffixAlpha /** Background opacity for a mention chip referring to somebody else. */ -internal const val MENTION_CHIP_ALPHA = 0.18f +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 = 0.22f +internal const val MENTION_CHIP_ALPHA_SELF = ChatVisualTokens.HighlightAlpha /** * Get RSSI-based color for signal strength visualization @@ -54,13 +55,13 @@ fun formatTextMessageSender( val builder = AnnotatedString.Builder() val isSelf = message.isFromSelf(currentUserNickname, myPeerID) val senderColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark) - val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold + 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 ) ) @@ -82,8 +83,8 @@ fun formatTextMessageSender( builder.pushStyle( SpanStyle( color = senderColor.copy(alpha = SUFFIX_ALPHA), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = senderWeight + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.Normal ) ) builder.append(suffix) @@ -97,7 +98,7 @@ 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 [appendTrailingTimestamp]. + * their body via [appendBodyTimestamp]. */ fun formatTextMessageMetadata( message: BitchatMessage, @@ -125,24 +126,49 @@ fun formatTextMessageMetadata( * Deliberately carries no click annotation: the timestamp is decoration, and making it * tappable would create dead zones inside the message body. */ -private fun appendTrailingTimestamp( +private fun appendTimestampText( builder: AnnotatedString.Builder, message: BitchatMessage, - palette: BitchatPalette, timeFormatter: SimpleDateFormat ) { - builder.pushStyle( - SpanStyle( - color = palette.textTertiary, - fontSize = (BASE_FONT_SIZE - 4).sp, - fontWeight = FontWeight.Normal - ) - ) 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, + palette: BitchatPalette, + timeFormatter: SimpleDateFormat, +) { + builder.pushStyle( + SpanStyle( + color = palette.textPrimary.copy(alpha = ChatVisualTokens.MutedTextAlpha), + fontSize = ChatVisualTokens.SystemTimeFontSize, + fontWeight = FontWeight.Normal, + ) + ) + appendTimestampText(builder, message, timeFormatter) builder.pop() } @@ -170,7 +196,7 @@ fun formatTextMessageBody( ) if (includeTimestamp) { - appendTrailingTimestamp(builder, message, palette, timeFormatter) + appendBodyTimestamp(builder, message, palette, timeFormatter) } return builder.toAnnotatedString() } @@ -189,16 +215,16 @@ fun formatSystemMessage( val builder = AnnotatedString.Builder() builder.pushStyle( SpanStyle( - color = palette.textSecondary, - fontSize = (BASE_FONT_SIZE - 2).sp, - fontWeight = FontWeight.Normal + color = palette.textPrimary.copy(alpha = ChatVisualTokens.MutedTextAlpha), + fontSize = ChatVisualTokens.SystemActionFontSize, + fontWeight = FontWeight.Medium, ) ) builder.append("// ") builder.append(message.content) builder.pop() - appendTrailingTimestamp(builder, message, palette, timeFormatter) + appendMutedTimestamp(builder, message, palette, timeFormatter) return builder.toAnnotatedString() } @@ -226,14 +252,13 @@ fun formatMessageHeaderAnnotatedString( if (includeSender) { val baseColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark) - val weight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold val (baseName, suffix) = splitSuffix(message.sender) builder.pushStyle( SpanStyle( color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = weight + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.SemiBold, ) ) builder.append("@") @@ -254,8 +279,8 @@ fun formatMessageHeaderAnnotatedString( builder.pushStyle( SpanStyle( color = baseColor.copy(alpha = SUFFIX_ALPHA), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = weight + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.Normal, ) ) builder.append(suffix) @@ -263,7 +288,7 @@ fun formatMessageHeaderAnnotatedString( } } - appendTrailingTimestamp(builder, message, palette, timeFormatter) + appendMutedTimestamp(builder, message, palette, timeFormatter) return builder.toAnnotatedString() } @@ -318,8 +343,8 @@ fun colorForPeerSeed(seed: String, isDark: Boolean): Color { hue = (hue + 0.12) % 1.0 } - val saturation = if (isDark) 0.62 else 0.85 - val brightness = if (isDark) 0.92 else 0.45 + val saturation = if (isDark) 1.0 else 0.85 + val brightness = if (isDark) 1.0 else 0.45 return Color.hsv( hue = (hue * 360).toFloat(), diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt index 0cfa91ba..b6bf7606 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt @@ -6,10 +6,10 @@ 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 androidx.compose.ui.res.stringResource import com.bitchat.android.R @@ -59,7 +59,7 @@ fun ChatUserSheet( Text( text = stringResource(R.string.at_nickname, targetNickname), fontSize = 18.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) @@ -67,7 +67,7 @@ fun ChatUserSheet( Text( text = if (selectedMessage != null) stringResource(R.string.choose_action_message_or_user) else stringResource(R.string.choose_action_user), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -183,7 +183,7 @@ fun ChatUserSheet( Text( text = stringResource(R.string.cancel_lower), fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } @@ -214,7 +214,7 @@ private fun UserActionRow( Text( text = title, fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, color = titleColor ) @@ -222,7 +222,7 @@ private fun UserActionRow( Text( text = subtitle, fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 1196bf32..8bbe89c4 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -19,13 +19,13 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush -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.text.style.TextOverflow import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette @@ -73,7 +73,7 @@ fun GeohashPeopleList( ) { Text( text = stringResource(R.string.nobody_around), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = palette.textTertiary, modifier = Modifier.padding( @@ -272,7 +272,7 @@ private fun PeopleCard( AnimatedCountLabel( count = hiddenCount, text = stringResource(R.string.people_n_more, hiddenCount), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = palette.textTertiary, modifier = Modifier.padding( @@ -357,7 +357,7 @@ private fun GeohashPersonItem( ) { Text( text = baseName, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, color = baseColor, @@ -368,7 +368,7 @@ private fun GeohashPersonItem( if (suffix.isNotEmpty()) { Text( text = suffix, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, color = baseColor.copy(alpha = SUFFIX_ALPHA) @@ -378,7 +378,7 @@ private fun GeohashPersonItem( if (isMe) { Text( text = stringResource(R.string.you_suffix), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, color = baseColor ) diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt index 0f2f53bb..bece684a 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -31,13 +31,13 @@ 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 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 @@ -186,7 +186,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 +211,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 +277,7 @@ class GeohashPickerActivity : OrientationAwareActivity() { Text( text = stringResource(R.string.select), fontSize = (BASE_FONT_SIZE - 2).sp, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index d673bb79..7b556fb9 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -1,4 +1,7 @@ package com.bitchat.android.ui + +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.BitchatFontFamily // [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher @@ -45,7 +48,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue @@ -98,7 +100,7 @@ class SlashCommandVisualTransformation( withStyle( style = SpanStyle( color = commandColor, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, background = commandBackground ) @@ -145,7 +147,7 @@ class MentionVisualTransformation( withStyle( style = SpanStyle( color = mentionColor, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.SemiBold, // Mirrors the mention chip used in rendered messages, so what you type // looks like what everyone will see. @@ -367,7 +369,7 @@ fun MessageInput( // type into. textStyle = MaterialTheme.typography.bodyMedium.copy( color = palette.textPrimary, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), cursorBrush = SolidColor( if (isRecording) Color.Transparent else palette.textPrimary @@ -411,7 +413,7 @@ fun MessageInput( Text( text = stringResource(R.string.type_a_message_placeholder), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = palette.textTertiary, maxLines = 1, @@ -446,7 +448,7 @@ fun MessageInput( "%02d:%02d / %02d:%02d", secs / 60, secs % 60, maxSecs / 60, maxSecs % 60 ), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.accentRed, fontSize = (BASE_FONT_SIZE - 4).sp ) @@ -664,7 +666,7 @@ fun CommandSuggestionItem( Text( text = allCommands.joinToString(", "), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium ), color = colorScheme.primary, @@ -676,7 +678,7 @@ fun CommandSuggestionItem( Text( text = syntax, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = palette.textSecondary, fontSize = (BASE_FONT_SIZE - 4).sp @@ -687,7 +689,7 @@ fun CommandSuggestionItem( Text( text = suggestion.description, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = palette.textTertiary, fontSize = (BASE_FONT_SIZE - 4).sp, @@ -737,7 +739,7 @@ fun MentionSuggestionItem( Text( text = stringResource(R.string.mention_suggestion_at, suggestion), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.SemiBold ), color = palette.accentOrange, @@ -749,7 +751,7 @@ fun MentionSuggestionItem( Text( text = stringResource(R.string.mention), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = palette.textTertiary, fontSize = (BASE_FONT_SIZE - 4).sp diff --git a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt index cc0d3026..9b34285c 100644 --- a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt +++ b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt @@ -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 @@ -110,7 +110,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 +121,7 @@ fun LinkPreviewPill( // Host - matches iOS styling Text( text = displayHost, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = textColor.copy(alpha = 0.6f), maxLines = 1, diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index dd892cfc..4004c520 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -42,12 +42,12 @@ 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.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.nostr.NearbyNotesController import com.bitchat.android.nostr.geohashesForSampling import com.bitchat.android.ui.theme.BASE_FONT_SIZE @@ -351,7 +351,7 @@ fun LocationChannelsSheet( Text( text = stringResource(R.string.location_permission_denied), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.accentRed ) TextButton( @@ -371,7 +371,7 @@ fun LocationChannelsSheet( Text( text = stringResource(R.string.open_settings), fontSize = 12.sp, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } @@ -495,7 +495,7 @@ fun LocationChannelsSheet( Text( text = shownError, fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.accentRed, modifier = Modifier.padding( start = AboutHorizontalPadding + ChannelRowHorizontal, @@ -541,7 +541,7 @@ fun LocationChannelsSheet( Text( text = stringResource(R.string.tor_not_available_in_this_build), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textTertiary, modifier = Modifier.padding( start = AboutHorizontalPadding + ChannelRowHorizontal, @@ -683,7 +683,7 @@ private fun ChannelOptionRow( Text( text = baseTitle, fontSize = 14.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium, color = titleColor ?: palette.textPrimary ) @@ -692,7 +692,7 @@ private fun ChannelOptionRow( count = participantCount, text = count, fontSize = 11.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textSecondary ) } @@ -700,7 +700,7 @@ private fun ChannelOptionRow( Text( text = subtitle, fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, lineHeight = 17.sp, color = palette.textSecondary ) @@ -759,7 +759,7 @@ private fun ChannelLoadingRow() { Text( text = stringResource(R.string.finding_nearby_channels), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textSecondary ) } @@ -822,7 +822,7 @@ private fun CustomGeohashRow( Text( text = stringResource(R.string.hash_symbol), fontSize = 14.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textTertiary ) @@ -833,7 +833,7 @@ private fun CustomGeohashRow( onValueChange = onGeohashChange, textStyle = TextStyle( fontSize = 14.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.primary ), cursorBrush = SolidColor(colorScheme.primary), @@ -846,7 +846,7 @@ private fun CustomGeohashRow( Text( text = stringResource(R.string.geohash_placeholder), fontSize = 14.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textTertiary ) } @@ -882,7 +882,7 @@ private fun CustomGeohashRow( fontSize = 11.sp, letterSpacing = 0.8.sp, fontWeight = FontWeight.Medium, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = teleportColor, modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp) ) @@ -927,7 +927,7 @@ private fun ChannelSettingsToggleRow( ) { Text( text = title, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = if (enabled) palette.textPrimary else palette.textTertiary @@ -936,7 +936,7 @@ private fun ChannelSettingsToggleRow( } Text( text = subtitle, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, lineHeight = 17.sp, color = if (enabled) palette.textSecondary else palette.textTertiary diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index 7292df59..c250f6a1 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -18,9 +18,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 @@ -251,7 +251,7 @@ private fun LocationNotesHeader( if (name.isNotEmpty()) { Text( text = name, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = accentGreen ) @@ -262,7 +262,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 +272,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 +303,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 +312,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 +324,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 +343,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 +351,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 +385,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 +404,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 +412,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 +440,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 +448,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) @@ -482,7 +482,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 +506,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 +523,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() diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index baaed09e..05af5f24 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -4,6 +4,7 @@ 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 @@ -28,7 +29,6 @@ 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.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -271,7 +271,7 @@ private fun ChannelRow( } else { Text( text = "#", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = palette.textTertiary @@ -283,7 +283,7 @@ private fun ChannelRow( Text( text = channel, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, color = if (isSelected) colorScheme.primary else palette.textPrimary, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, @@ -337,7 +337,7 @@ fun PeopleSection( if (connectedPeers.isEmpty()) { Text( text = stringResource(id = R.string.no_one_connected), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = palette.textTertiary, modifier = Modifier @@ -655,7 +655,7 @@ private fun PeerItem( ) { Text( text = baseName, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, color = baseColor, @@ -666,7 +666,7 @@ private fun PeerItem( if (suffix.isNotEmpty()) { Text( text = suffix, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, color = baseColor.copy(alpha = SUFFIX_ALPHA) @@ -741,7 +741,7 @@ private fun UnreadBadge( style = MaterialTheme.typography.labelSmall, fontSize = 10.sp, fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = Color.Black ) } @@ -1007,7 +1007,7 @@ fun PrivateChatSheet( text = titleText, style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = if (isNostrPeer || isNostrReachableFavorite) Color(0xFFFF9500) else colorScheme.onSurface ) diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index c53aa7c0..2561082a 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -1,6 +1,5 @@ package com.bitchat.android.ui - import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.animation.AnimatedContent @@ -54,13 +53,13 @@ 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 @@ -70,6 +69,7 @@ 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 @@ -290,8 +290,8 @@ fun MessagesList( top = 8.dp + contentPadding.calculateTopPadding(), bottom = 12.dp + contentPadding.calculateBottomPadding() ), - // Spacing is owned by each item so that a continuation of the same author can sit - // tighter than the start of a new speaker's run. + // 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 @@ -515,7 +515,7 @@ fun MessageItem( } }, onLongPress = { onMessageLongPress?.invoke(message) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textPrimary, ) @@ -579,7 +579,7 @@ fun MessageItem( } else { Text( text = stringResource(R.string.file_unavailable), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textTertiary ) } @@ -610,13 +610,11 @@ fun MessageItem( } ) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, softWrap = true, overflow = TextOverflow.Visible, - style = MessageBodyTextStyle.copy( - fontSize = (BASE_FONT_SIZE - 2).sp, - lineHeight = (BASE_FONT_SIZE + 3).sp, - color = palette.textSecondary + style = ChatVisualTokens.SystemActionStyle.copy( + color = palette.textPrimary.copy(alpha = ChatVisualTokens.MutedTextAlpha), ) ) } else { @@ -696,8 +694,10 @@ internal fun TextMessageLayout( } }, onLongPress = handleLongPress, - modifier = Modifier.fillMaxWidth(), - fontFamily = FontFamily.Monospace, + modifier = Modifier + .fillMaxWidth() + .padding(top = MessageGrouping.SENDER_TOP_PADDING), + fontFamily = BitchatFontFamily, softWrap = false, overflow = TextOverflow.Ellipsis, style = MessageSenderTextStyle, @@ -725,7 +725,7 @@ internal fun TextMessageLayout( } }, onLongPress = handleLongPress, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, softWrap = true, overflow = TextOverflow.Visible, style = MessageBodyTextStyle.copy(color = palette.textPrimary), diff --git a/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt index f03e1b27..9b3278c4 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt @@ -3,24 +3,29 @@ 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. Grouped messages sit - * closer together; a new group gets extra breathing room above it. + * 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 = 10.dp + val NEW_GROUP_SPACING: Dp = ChatVisualTokens.MessageItemSpacing /** Space above a continuation message inside an existing group. */ - val GROUPED_SPACING: Dp = 4.dp + val GROUPED_SPACING: Dp = ChatVisualTokens.MessageItemSpacing /** Gap between the sender label and the first line of the body. */ - val SENDER_TO_BODY_SPACING: Dp = 2.dp + 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: diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index 5d3af681..d4ba21fe 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -39,12 +39,12 @@ 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 @@ -93,7 +93,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 { @@ -156,7 +156,7 @@ 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 @@ -221,7 +221,7 @@ private fun SecurityStatusCard( Text( text = displayName, style = MaterialTheme.typography.titleMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), color = accent @@ -229,7 +229,7 @@ private fun SecurityStatusCard( Text( text = statusInfo.text, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = accent.copy(alpha = 0.8f) ) @@ -259,7 +259,7 @@ private fun SecurityVerificationActions( ) { Text( text = stringResource(R.string.fingerprint_start_handshake), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -275,7 +275,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(), @@ -291,7 +291,7 @@ private fun SecurityVerificationActions( ) { Text( text = stringResource(R.string.verify_remove), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -305,7 +305,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(), @@ -322,7 +322,7 @@ private fun SecurityVerificationActions( ) { Text( text = stringResource(R.string.fingerprint_mark_verified), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -351,7 +351,7 @@ private fun VerificationStatusRow( Text( text = text, style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), color = textTint @@ -377,7 +377,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) @@ -387,7 +387,7 @@ private fun FingerprintBlock( Text( text = formatFingerprint(fingerprint), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp ), color = accent, @@ -419,7 +419,7 @@ private fun FingerprintBlock( } else { Text( text = stringResource(R.string.fingerprint_pending), - style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily), color = Color(0xFFFF9500), modifier = Modifier.padding(16.dp) ) diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index 76aed7d8..4fc5bc34 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -60,7 +60,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,6 +69,7 @@ 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 @@ -145,7 +145,7 @@ fun VerificationSheet( text = { Text( text = "My QR", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp ) } @@ -156,7 +156,7 @@ fun VerificationSheet( text = { Text( text = "Scan", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp ) } @@ -209,7 +209,7 @@ fun VerificationSheet( ) { Text( text = stringResource(R.string.verify_remove), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -233,7 +233,7 @@ private fun VerificationHeader( Text( text = stringResource(R.string.verify_title).uppercase(), fontSize = 14.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = accent ) val dismiss = LocalSheetDismiss.current @@ -260,7 +260,7 @@ private fun MyQrTabContent( Text( text = stringResource(R.string.verify_my_qr_title), style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = accent ) @@ -285,7 +285,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) ) @@ -298,7 +298,7 @@ private fun MyQrTabContent( Text( text = nickname, style = MaterialTheme.typography.headlineSmall, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center ) @@ -309,7 +309,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 ) @@ -357,7 +357,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) @@ -388,7 +388,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 ) @@ -399,7 +399,7 @@ private fun ScanTabContent( ) { Text( text = stringResource(R.string.verify_request_camera), - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index afee16ec..9c8c48b5 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -26,11 +26,11 @@ 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, @@ -97,7 +97,7 @@ fun MeshTopologySection( val label = "${node.peerID.take(8)} • ${node.nickname ?: "Unknown"}" Text( text = label, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.85f) ) @@ -128,7 +128,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF5856D6)) Text( "Distribution info", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium ) @@ -137,7 +137,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI if (info == null) { Text( "Inspecting installed package…", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -170,7 +170,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI }, contentPadding = PaddingValues(horizontal = 0.dp) ) { - Text("Copy certificate fingerprint", fontFamily = FontFamily.Monospace) + Text("Copy certificate fingerprint", fontFamily = BitchatFontFamily) } } } @@ -184,13 +184,13 @@ private fun DistributionInfoRow(label: String, value: String) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( label, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.55f) ) Text( value, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.9f) ) @@ -347,7 +347,7 @@ fun DebugSettingsSheet( item { Text( text = stringResource(R.string.debug_tools_desc), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -361,13 +361,13 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF00C851)) - Text(stringResource(R.string.debug_verbose_logging), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_verbose_logging), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) Switch(checked = verboseLogging, onCheckedChange = { manager.setVerboseLoggingEnabled(it) }) } Text( stringResource(R.string.debug_verbose_hint), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -392,10 +392,10 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) - Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_gatt_server), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text(stringResource(R.string.debug_gatt_server), fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) Switch(checked = gattServerEnabled, onCheckedChange = { manager.setGattServerEnabled(it) scope.launch { @@ -404,9 +404,9 @@ fun DebugSettingsSheet( }) } val serverCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_SERVER } - Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_max_server), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp)) + Text(stringResource(R.string.debug_max_server), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp)) Slider( value = maxServer.toFloat(), onValueChange = { manager.setMaxServerConnections(it.toInt().coerceAtLeast(1)) }, @@ -415,7 +415,7 @@ fun DebugSettingsSheet( ) } Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_gatt_client), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text(stringResource(R.string.debug_gatt_client), fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) Switch(checked = gattClientEnabled, onCheckedChange = { manager.setGattClientEnabled(it) scope.launch { @@ -424,9 +424,9 @@ fun DebugSettingsSheet( }) } val clientCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_CLIENT } - Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_max_client), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp)) + Text(stringResource(R.string.debug_max_client), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp)) Slider( value = maxClient.toFloat(), onValueChange = { manager.setMaxClientConnections(it.toInt().coerceAtLeast(1)) }, @@ -435,9 +435,9 @@ fun DebugSettingsSheet( ) } val overallCount = connectedDevices.size - Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_max_overall), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp)) + Text(stringResource(R.string.debug_max_overall), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp)) Slider( value = maxOverall.toFloat(), onValueChange = { manager.setMaxConnectionsOverall(it.toInt().coerceAtLeast(1)) }, @@ -447,7 +447,7 @@ fun DebugSettingsSheet( } Text( stringResource(R.string.debug_roles_hint), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -461,12 +461,12 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50)) - Text("Transports", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Transports", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) Spacer(Modifier.width(8.dp)) - Text("BLE", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text("BLE", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) Switch(checked = bleEnabled, onCheckedChange = { manager.setBleEnabled(it) }) @@ -474,7 +474,7 @@ fun DebugSettingsSheet( Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Filled.Wifi, contentDescription = null, tint = Color(0xFF9C27B0)) Spacer(Modifier.width(8.dp)) - Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text("Wi‑Fi Aware", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) val wifiSwitchEnabled = wifiAwareSupported Text( when { @@ -482,7 +482,7 @@ fun DebugSettingsSheet( wifiAwareAvailable -> "available" else -> "unavailable" }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -497,7 +497,7 @@ fun DebugSettingsSheet( } Row(verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.width(24.dp)) - Text("Wi‑Fi Aware verbose logging", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text("Wi‑Fi Aware verbose logging", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) Switch(checked = wifiAwareVerbose, onCheckedChange = { manager.setWifiAwareVerbose(it) }) } } @@ -512,7 +512,7 @@ fun DebugSettingsSheet( Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500)) - Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_packet_relay), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) Switch(checked = packetRelayed, onCheckedChange = { manager.setPacketRelayEnabled(it) }) } @@ -648,10 +648,10 @@ fun DebugSettingsSheet( // Helper functions moved to top-level composable below to avoid scope issues // Render two blocks: Incoming and Outgoing - Text("Incoming", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text("Incoming", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Text( "${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}", - fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) DrawGraphBlock( title = "Incoming", @@ -704,10 +704,10 @@ fun DebugSettingsSheet( if (graphMode != GraphMode.OVERALL && stackedKeysIncoming.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ } Spacer(Modifier.height(8.dp)) - Text("Outgoing", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text("Outgoing", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Text( "${relayStats.lastSecondOutgoing}/s • ${relayStats.lastMinuteOutgoing}/m • ${relayStats.last15MinuteOutgoing}/15m • total ${relayStats.totalOutgoingCount}", - fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) DrawGraphBlock( title = "Outgoing", @@ -770,7 +770,7 @@ fun DebugSettingsSheet( val running by com.bitchat.android.wifiaware.WifiAwareController.running.collectAsState() Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.WifiTethering, contentDescription = null, tint = Color(0xFF9C27B0)) - Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Wi‑Fi Aware", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) val wifiStatusText = when { !wifiAwareSupported -> "unsupported" @@ -778,12 +778,12 @@ fun DebugSettingsSheet( !wifiAwareAvailable -> "unavailable" else -> "stopped" } - Text(wifiStatusText, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(wifiStatusText, fontFamily = BitchatFontFamily, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } if (!wifiAwareSupported) { Text( wifiAwareSupportStatus?.reason ?: "Wi-Fi Aware is not supported on this device", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -801,21 +801,21 @@ fun DebugSettingsSheet( label = { Text("Announce") } ) } - Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = BitchatFontFamily, fontSize = 12.sp) if (wifiAwareDiscovered.isEmpty()) { - Text("No discoveries yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("No discoveries yet", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { wifiAwareDiscovered.entries.take(50).forEach { (peer, nick) -> - Text("• ${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("• ${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = BitchatFontFamily, fontSize = 12.sp) } } Divider() - Text("Connected: ${wifiAwareConnected.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("Connected: ${wifiAwareConnected.size}", fontFamily = BitchatFontFamily, fontSize = 12.sp) if (wifiAwareConnected.isEmpty()) { - Text("No active sockets", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("No active sockets", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { wifiAwareConnected.entries.take(50).forEach { (peer, ip) -> - Text("• ${peer.take(8)}… @ $ip", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("• ${peer.take(8)}… @ $ip", fontFamily = BitchatFontFamily, fontSize = 12.sp) } } } @@ -828,17 +828,17 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF9C27B0)) - Text("Sync settings", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Sync settings", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } - Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Slider(value = seenCapacity.toFloat(), onValueChange = { manager.setSeenPacketCapacity(it.toInt()) }, valueRange = 10f..1000f, steps = 99) - Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Slider(value = gcsMaxBytes.toFloat(), onValueChange = { manager.setGcsMaxBytes(it.toInt()) }, valueRange = 128f..1024f, steps = 0) - Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Slider(value = gcsFpr.toFloat(), onValueChange = { manager.setGcsFprPercent(it.toDouble()) }, valueRange = 0.1f..5.0f, steps = 49) val p = remember(gcsFpr) { com.bitchat.android.sync.GCSFilter.deriveP(gcsFpr / 100.0) } val nmax = remember(gcsFpr, gcsMaxBytes) { com.bitchat.android.sync.GCSFilter.estimateMaxElementsForSize(gcsMaxBytes, p) } - Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } } } @@ -849,22 +849,22 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50)) - Text(stringResource(R.string.debug_connected_devices), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_connected_devices), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } val localAddr = remember { meshService.connectionManager.getLocalAdapterAddress() } - Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) if (connectedDevices.isEmpty()) { - Text("None", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("None", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { connectedDevices.forEach { dev -> Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) { Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { - Text((dev.peerID ?: stringResource(R.string.unknown)) + " • ${dev.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text((dev.peerID ?: stringResource(R.string.unknown)) + " • ${dev.deviceAddress}", fontFamily = BitchatFontFamily, fontSize = 12.sp) val roleLabel = if (dev.connectionType == ConnectionType.GATT_SERVER) stringResource(R.string.debug_role_server) else stringResource(R.string.debug_role_client) - Text("${dev.nickname ?: ""} • " + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + " • $roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text("${dev.nickname ?: ""} • " + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + " • $roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } - Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable { + Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = BitchatFontFamily, modifier = Modifier.clickable { meshService.connectionManager.disconnectAddress(dev.deviceAddress) }) } @@ -881,19 +881,19 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) - Text(stringResource(R.string.debug_recent_scan_results), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_recent_scan_results), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } if (scanResults.isEmpty()) { - Text(stringResource(R.string.debug_none), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text(stringResource(R.string.debug_none), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { scanResults.forEach { res -> Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) { Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { - Text((res.peerID ?: stringResource(R.string.unknown)) + " • ${res.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) - Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text((res.peerID ?: stringResource(R.string.unknown)) + " • ${res.deviceAddress}", fontFamily = BitchatFontFamily, fontSize = 12.sp) + Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } - Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable { + Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = BitchatFontFamily, modifier = Modifier.clickable { meshService.connectionManager.connectToAddress(res.deviceAddress) }) } @@ -910,15 +910,15 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500)) - Text(stringResource(R.string.debug_debug_console), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_debug_console), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) - Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable { + Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = BitchatFontFamily, modifier = Modifier.clickable { manager.clearDebugMessages() }) } Column(Modifier.heightIn(max = 260.dp).background(colorScheme.surface.copy(alpha = 0.5f)).padding(8.dp)) { debugMessages.takeLast(100).reversed().forEach { msg -> - Text("${msg.content}", fontFamily = FontFamily.Monospace, fontSize = 11.sp) + Text("${msg.content}", fontFamily = BitchatFontFamily, fontSize = 11.sp) } } } @@ -1039,7 +1039,7 @@ private fun DrawGraphBlock( Box(Modifier.width(leftGutter).fillMaxHeight()) { Text( "p/s", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f) @@ -1053,14 +1053,14 @@ private fun DrawGraphBlock( } Text( topLabel, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp) ) Text( "0", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp) @@ -1085,8 +1085,8 @@ private fun DrawGraphBlock( ) { Box(Modifier.size(10.dp).background(swatchColor, RoundedCornerShape(2.dp))) Column { - Text(legendTitleFor(key), fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f)) - Text(legendMetricsFor(key), fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f)) + Text(legendTitleFor(key), fontFamily = BitchatFontFamily, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f)) + Text(legendMetricsFor(key), fontFamily = BitchatFontFamily, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f)) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 8722ab29..5627111d 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -13,9 +13,9 @@ 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 @@ -72,7 +72,7 @@ fun AudioMessageItem( } }, onLongPress = { onMessageLongPress?.invoke(message) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textPrimary, ) diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt b/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt index 2ec7cd9a..51e65108 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.dp import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import kotlinx.coroutines.delay @@ -96,7 +97,7 @@ fun FileSendingAnimation( androidx.compose.material3.Text( text = revealedText, style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = Color.White ), modifier = Modifier.padding(end = 2.dp) @@ -107,7 +108,7 @@ fun FileSendingAnimation( androidx.compose.material3.Text( text = stringResource(R.string.underscore), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = Color.White ) ) @@ -149,7 +150,7 @@ private fun FileProgressBars( androidx.compose.material3.Text( text = progressString, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = Color(0xFF00FF7F) // Matrix green ), modifier = modifier diff --git a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt index bebc1293..3544b637 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import java.io.File @@ -101,7 +102,7 @@ fun FullScreenImageViewer(imagePaths: List, initialIndex: Int = 0, onClo text = stringResource(R.string.image_counter, (pagerState.currentPage ?: 0) + 1, imagePaths.size), color = Color.White, fontSize = 14.sp, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index a0961cfc..30387944 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -21,7 +21,7 @@ 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 @@ -70,7 +70,7 @@ fun ImageMessageItem( } }, onLongPress = { onMessageLongPress?.invoke(message) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textPrimary, ) @@ -142,7 +142,7 @@ fun ImageMessageItem( } } } else { - Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray) + Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = BitchatFontFamily, color = Color.Gray) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt index 5341ec1f..10109194 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt @@ -1,5 +1,6 @@ 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 @@ -18,7 +19,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.text.font.FontFamily @Composable fun VoiceNotePlayer( @@ -110,7 +110,7 @@ fun VoiceNotePlayer( onSeek = seekTo ) val durText = if (durationMs > 0) String.format("%02d:%02d", (durationMs / 1000) / 60, (durationMs / 1000) % 60) else "--:--" - Text(text = durText, fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text(text = durText, fontFamily = BitchatFontFamily, fontSize = 12.sp) } } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt index 697567b3..dab45ff5 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -90,7 +90,7 @@ val DarkBitchatPalette = BitchatPalette( inputSurface = Color(0xFF0B0B0B), inputSurfaceFocused = Color(0xFF151515), inputButton = Color(0xFF1E1E1E), - textPrimary = Color(0xFFE8EDE8), + textPrimary = Color(0xFFF5F5F5), textSecondary = Color(0xFF9AA69A), textTertiary = Color(0xFF6B776B), accentGreen = Color(0xFF32D74B), diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt new file mode 100644 index 00000000..cdd4d418 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitchat.android.R + +/** + * The bundled Geist Mono family used throughout the app. + * + * Keeping the fonts in the APK preserves offline behavior and guarantees that the design-spec + * metrics do not depend on which monospace family a device happens to provide. + */ +internal val BitchatFontFamily = FontFamily( + Font(R.font.geist_mono_regular, FontWeight.Normal), + Font(R.font.geist_mono_medium, FontWeight.Medium), + Font(R.font.geist_mono_semibold, FontWeight.SemiBold), + Font(R.font.geist_mono_bold, FontWeight.Bold), +) + +/** Exact typography, spacing, and opacity values exported for the chat transcript. */ +internal object ChatVisualTokens { + val MessageBodyFontSize: TextUnit = 14.sp + val MessageBodyLineHeight: TextUnit = 20.sp + val SenderFontSize: TextUnit = 14.sp + val SenderLineHeight: TextUnit = 16.sp + val SystemActionFontSize: TextUnit = 12.sp + val SystemActionLineHeight: TextUnit = 16.sp + val SystemTimeFontSize: TextUnit = 10.sp + + val MessageItemSpacing: Dp = 8.dp + val SenderTopPadding: Dp = 8.dp + val SenderToBodySpacing: Dp = 4.dp + + const val SenderSuffixAlpha: Float = 0.60f + const val HighlightAlpha: Float = 0.20f + const val MutedTextAlpha: Float = 0.50f + + val MessageBodyStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Normal, + fontSize = MessageBodyFontSize, + lineHeight = MessageBodyLineHeight, + ) + + val SenderStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = SenderFontSize, + lineHeight = SenderLineHeight, + ) + + val SystemActionStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + fontSize = SystemActionFontSize, + lineHeight = SystemActionLineHeight, + ) +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt index 66236747..2002a44a 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt @@ -2,92 +2,81 @@ package com.bitchat.android.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp // Base font size for consistent scaling across the app -internal const val BASE_FONT_SIZE = com.bitchat.android.util.AppConstants.UI.BASE_FONT_SIZE_SP // sp - increased from 14sp for better readability +internal const val BASE_FONT_SIZE = com.bitchat.android.util.AppConstants.UI.BASE_FONT_SIZE_SP /** * Message body style. The generous leading (1.4x) is what gives the redesigned chat surface * its readable rhythm; without an explicit lineHeight, Compose falls back to the font's own * metrics and lines sit too tightly for long paragraphs. */ -val MessageBodyTextStyle = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Normal, - fontSize = BASE_FONT_SIZE.sp, - lineHeight = (BASE_FONT_SIZE + 6).sp -) +val MessageBodyTextStyle = ChatVisualTokens.MessageBodyStyle /** Sender label above a message group. Single line, never wraps. */ -val MessageSenderTextStyle = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = BASE_FONT_SIZE.sp, - lineHeight = (BASE_FONT_SIZE + 5).sp -) +val MessageSenderTextStyle = ChatVisualTokens.SenderStyle // Typography matching the iOS monospace design - using BASE_FONT_SIZE for consistency val Typography = Typography( bodyLarge = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = (BASE_FONT_SIZE + 1).sp, lineHeight = (BASE_FONT_SIZE + 7).sp ), bodyMedium = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = BASE_FONT_SIZE.sp, lineHeight = (BASE_FONT_SIZE + 6).sp ), bodySmall = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = (BASE_FONT_SIZE - 3).sp, lineHeight = (BASE_FONT_SIZE + 1).sp ), headlineSmall = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE + 3).sp, lineHeight = (BASE_FONT_SIZE + 9).sp ), // Previously unset, which leaked the Roboto default into onboarding + sheet titles. headlineLarge = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = (BASE_FONT_SIZE + 13).sp, lineHeight = (BASE_FONT_SIZE + 21).sp ), titleLarge = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE + 5).sp, lineHeight = (BASE_FONT_SIZE + 13).sp ), titleMedium = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE + 1).sp, lineHeight = (BASE_FONT_SIZE + 7).sp ), labelLarge = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE - 1).sp, lineHeight = (BASE_FONT_SIZE + 5).sp ), labelMedium = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE - 2).sp, lineHeight = (BASE_FONT_SIZE + 3).sp ), labelSmall = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = (BASE_FONT_SIZE - 4).sp, lineHeight = (BASE_FONT_SIZE + 1).sp diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 11df5b08..4905c927 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -120,7 +120,7 @@ object AppConstants { object UI { const val MAX_NICKNAME_LENGTH: Int = 15 - const val BASE_FONT_SIZE_SP: Int = 15 + const val BASE_FONT_SIZE_SP: Int = 14 const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L diff --git a/app/src/main/res/font/geist_mono_bold.ttf b/app/src/main/res/font/geist_mono_bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..90eb8a86551d5425fb1e965a8deff31de1792d49 GIT binary patch literal 150492 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/font/geist_mono_medium.ttf b/app/src/main/res/font/geist_mono_medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ff49ece5a3dbc6012320749a398ea6daca096aeb GIT binary patch literal 149328 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�yj3@rDuD5@3s_u9~XZY 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/font/geist_mono_regular.ttf b/app/src/main/res/font/geist_mono_regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..50c9d5a667bc397a1495b0bb45a52bef4184661c GIT binary patch literal 148516 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

qKjwHQ!ob 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%0DkIT7^Cr|?j=S6Pw9Kpm~8w?-&>9_ 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 Gk@;Vye(^v6 literal 0 HcmV?d00001 diff --git a/app/src/main/res/raw/geist_mono_ofl.txt b/app/src/main/res/raw/geist_mono_ofl.txt new file mode 100644 index 00000000..34ffeb6f --- /dev/null +++ b/app/src/main/res/raw/geist_mono_ofl.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index e35e37f1..1f9dc2b9 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -1,9 +1,16 @@ package com.bitchat.android.ui +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.ChatVisualTokens import com.bitchat.android.ui.theme.DarkBitchatPalette import com.bitchat.android.ui.theme.LightBitchatPalette +import com.bitchat.android.ui.theme.MessageBodyTextStyle +import com.bitchat.android.ui.theme.MessageSenderTextStyle import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -68,6 +75,12 @@ class ChatUIUtilsTest { ) assertEquals("hello there 00:00:00", body.text) + val timestamp = body.spanStyles.first { + body.text.substring(it.start, it.end) == " 00:00:00" + }.item + assertEquals(10.sp, timestamp.fontSize) + assertEquals(FontWeight.Normal, timestamp.fontWeight) + assertEquals(palette.textTertiary, timestamp.color) } @Test @@ -80,6 +93,11 @@ class ChatUIUtilsTest { ) assertEquals("mined 00:00:00 ⛨8b", body.text) + val timestampAndPow = body.spanStyles.first { + body.text.substring(it.start, it.end) == " 00:00:00 ⛨8b" + }.item + assertEquals(10.sp, timestampAndPow.fontSize) + assertEquals(palette.textTertiary, timestampAndPow.color) } @Test @@ -109,6 +127,17 @@ class ChatUIUtilsTest { assertEquals(palette.textPrimary, textStyle.item.color) } + @Test + fun `chat text styles match the exported type scale`() { + assertEquals(14.sp, MessageBodyTextStyle.fontSize) + assertEquals(20.sp, MessageBodyTextStyle.lineHeight) + assertEquals(FontWeight.Normal, MessageBodyTextStyle.fontWeight) + assertEquals(BitchatFontFamily, MessageBodyTextStyle.fontFamily) + assertEquals(14.sp, MessageSenderTextStyle.fontSize) + assertEquals(16.sp, MessageSenderTextStyle.lineHeight) + assertEquals(FontWeight.SemiBold, MessageSenderTextStyle.fontWeight) + } + @Test fun `body does not bold plain text for the sender's own messages`() { // Regression guard: the old renderer bolded the entire body when the message was yours, @@ -165,6 +194,7 @@ class ChatUIUtilsTest { val chip = mentionChipSpans(body).single() assertEquals(palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA_SELF), chip.item.background) + assertEquals(0.2f, chip.item.background.alpha) val nameStyle: SpanStyle = body.spanStyles .first { it.start == chip.start && it.item.color == palette.accentOrange } @@ -226,6 +256,29 @@ class ChatUIUtilsTest { assertTrue(annotated.spanStyles.all { it.item.fontStyle == null }) } + @Test + fun `system action and timestamp use their exported weights sizes and opacity`() { + val annotated = formatSystemMessage( + message = message("tor restarting", sender = "system"), + palette = palette, + timeFormatter = timeFormatter, + ) + + val action = annotated.spanStyles.first { + annotated.text.substring(it.start, it.end) == "// tor restarting" + }.item + val time = annotated.spanStyles.first { + annotated.text.substring(it.start, it.end) == " 00:00:00" + }.item + + assertEquals(12.sp, action.fontSize) + assertEquals(FontWeight.Medium, action.fontWeight) + assertEquals(palette.textPrimary.copy(alpha = 0.5f), action.color) + assertEquals(10.sp, time.fontSize) + assertEquals(FontWeight.Normal, time.fontWeight) + assertEquals(palette.textPrimary.copy(alpha = 0.5f), time.color) + } + // MARK: - Sender label @Test @@ -242,6 +295,11 @@ class ChatUIUtilsTest { val suffixSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "#04af" } val nameSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "@carol" } assertNotNull(suffixSpan.item.color) + assertEquals(14.sp, nameSpan.item.fontSize) + assertEquals(FontWeight.SemiBold, nameSpan.item.fontWeight) + assertEquals(14.sp, suffixSpan.item.fontSize) + assertEquals(FontWeight.Normal, suffixSpan.item.fontWeight) + assertEquals(ChatVisualTokens.SenderSuffixAlpha, suffixSpan.item.color.alpha) assertTrue( "suffix must be dimmer than the name", suffixSpan.item.color.alpha < nameSpan.item.color.alpha @@ -282,6 +340,8 @@ class ChatUIUtilsTest { rgbToHsv(light.red, light.green, light.blue, lightHsv) assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0) + assertEquals(1.0, darkHsv[1].toDouble(), 0.01) + assertEquals(1.0, darkHsv[2].toDouble(), 0.01) } @Test @@ -302,6 +362,7 @@ class ChatUIUtilsTest { @Test fun `light palette is not the dark palette`() { assertNull(null) + assertEquals(Color(0xFFF5F5F5), DarkBitchatPalette.textPrimary) assertTrue(LightBitchatPalette.textPrimary != DarkBitchatPalette.textPrimary) assertTrue(LightBitchatPalette.isDark != DarkBitchatPalette.isDark) } diff --git a/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt index 4ba8ffa0..eea760f7 100644 --- a/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt @@ -138,13 +138,15 @@ class MessageGroupingTest { } @Test - fun `grouped rows sit tighter than the start of a new group`() { + fun `all transcript rows use the exported eight dp rhythm`() { val grouped = MessageGrouping.topSpacingFor(isGrouped = true, isFirstInList = false) val newGroup = MessageGrouping.topSpacingFor(isGrouped = false, isFirstInList = false) - assertEquals(MessageGrouping.GROUPED_SPACING, grouped) - assertEquals(MessageGrouping.NEW_GROUP_SPACING, newGroup) - assertTrue("a new speaker needs more air than a continuation", newGroup > grouped) + assertEquals(8.dp, grouped) + assertEquals(8.dp, newGroup) + assertEquals(grouped, newGroup) + assertEquals(8.dp, MessageGrouping.SENDER_TOP_PADDING) + assertEquals(4.dp, MessageGrouping.SENDER_TO_BODY_SPACING) } private fun BitchatMessage.copyWithOffset(offsetMs: Long) = From 7cef3f329ec12e90bd3192336214c2efc97054ec Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:28:35 +0200 Subject: [PATCH 26/70] timestamp no seconds --- .../java/com/bitchat/android/ui/ChatUIUtils.kt | 11 +++++++---- .../bitchat/android/ui/MessageComponents.kt | 2 +- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 18 +++++++++--------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 33cb0ab4..5f5fa778 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -21,6 +21,9 @@ import java.util.* /** 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 @@ -102,7 +105,7 @@ fun formatTextMessageSender( */ 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( @@ -183,7 +186,7 @@ fun formatTextMessageBody( message: BitchatMessage, currentUserNickname: String, palette: BitchatPalette, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()), + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), includeTimestamp: Boolean = true ): AnnotatedString { val builder = AnnotatedString.Builder() @@ -210,7 +213,7 @@ fun formatTextMessageBody( fun formatSystemMessage( message: BitchatMessage, palette: BitchatPalette, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) ): AnnotatedString { val builder = AnnotatedString.Builder() builder.pushStyle( @@ -240,7 +243,7 @@ fun formatMessageHeaderAnnotatedString( currentUserNickname: String, myPeerID: String, palette: BitchatPalette, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()), + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), includeSender: Boolean = true ): AnnotatedString { val builder = AnnotatedString.Builder() diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 2561082a..a0581a51 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -372,7 +372,7 @@ fun MessageItem( 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 diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index 1f9dc2b9..d58d3b4a 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -28,7 +28,7 @@ import org.robolectric.RobolectricTestRunner */ @RunWith(RobolectricTestRunner::class) class ChatUIUtilsTest { - private val timeFormatter = SimpleDateFormat("HH:mm:ss", Locale.ROOT).apply { + private val timeFormatter = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.ROOT).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } @@ -50,7 +50,7 @@ class ChatUIUtilsTest { @Test fun `text message metadata separates PoW badge with one space`() { assertEquals( - "00:00:00 ⛨12b", + "00:00 ⛨12b", formatTextMessageMetadata(message("hello", powDifficulty = 12), timeFormatter).text, ) } @@ -58,7 +58,7 @@ class ChatUIUtilsTest { @Test fun `text message metadata omits non-positive PoW difficulty`() { assertEquals( - "00:00:00", + "00:00", formatTextMessageMetadata(message("hello", powDifficulty = 0), timeFormatter).text, ) } @@ -74,9 +74,9 @@ class ChatUIUtilsTest { timeFormatter = timeFormatter, ) - assertEquals("hello there 00:00:00", body.text) + assertEquals("hello there 00:00", body.text) val timestamp = body.spanStyles.first { - body.text.substring(it.start, it.end) == " 00:00:00" + body.text.substring(it.start, it.end) == " 00:00" }.item assertEquals(10.sp, timestamp.fontSize) assertEquals(FontWeight.Normal, timestamp.fontWeight) @@ -92,9 +92,9 @@ class ChatUIUtilsTest { timeFormatter = timeFormatter, ) - assertEquals("mined 00:00:00 ⛨8b", body.text) + assertEquals("mined 00:00 ⛨8b", body.text) val timestampAndPow = body.spanStyles.first { - body.text.substring(it.start, it.end) == " 00:00:00 ⛨8b" + body.text.substring(it.start, it.end) == " 00:00 ⛨8b" }.item assertEquals(10.sp, timestampAndPow.fontSize) assertEquals(palette.textTertiary, timestampAndPow.color) @@ -240,7 +240,7 @@ class ChatUIUtilsTest { timeFormatter = timeFormatter, ).text - assertEquals("// Tor started. Routing all chats via Tor 00:00:00", text) + assertEquals("// Tor started. Routing all chats via Tor 00:00", text) } @Test @@ -268,7 +268,7 @@ class ChatUIUtilsTest { annotated.text.substring(it.start, it.end) == "// tor restarting" }.item val time = annotated.spanStyles.first { - annotated.text.substring(it.start, it.end) == " 00:00:00" + annotated.text.substring(it.start, it.end) == " 00:00" }.item assertEquals(12.sp, action.fontSize) From 352dd6e93fd2d0182e58081597536069535a5991 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:45:51 +0200 Subject: [PATCH 27/70] new icons --- app/src/main/assets/design-icons/README.md | 6 ++ .../assets/design-icons/bookmark-filled.svg | 3 + .../assets/design-icons/bookmark-outline.svg | 3 + .../main/assets/design-icons/chat-bubbles.svg | 6 ++ app/src/main/assets/design-icons/command.svg | 6 ++ app/src/main/assets/design-icons/eye-off.svg | 6 ++ app/src/main/assets/design-icons/globe.svg | 7 ++ app/src/main/assets/design-icons/lock.svg | 7 ++ app/src/main/assets/design-icons/mention.svg | 6 ++ app/src/main/assets/design-icons/panic.svg | 3 + app/src/main/assets/design-icons/people.svg | 6 ++ app/src/main/assets/design-icons/person.svg | 6 ++ app/src/main/assets/design-icons/range.svg | 6 ++ app/src/main/assets/design-icons/shuffle.svg | 3 + app/src/main/assets/design-icons/star.svg | 3 + app/src/main/assets/design-icons/teleport.svg | 5 ++ app/src/main/assets/design-icons/waveform.svg | 3 + app/src/main/assets/design-icons/wifi-off.svg | 8 +++ .../com/bitchat/android/ui/AboutSections.kt | 49 ++++++------- .../java/com/bitchat/android/ui/ChatHeader.kt | 70 ++++++++++++++++++- .../bitchat/android/ui/GeohashPeopleList.kt | 15 ++-- .../android/ui/LocationChannelsSheet.kt | 13 +++- .../bitchat/android/ui/LocationNotesButton.kt | 5 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 13 ++-- .../res/drawable/ic_spec_bookmark_filled.xml | 6 ++ .../res/drawable/ic_spec_bookmark_outline.xml | 6 ++ .../res/drawable/ic_spec_chat_bubbles.xml | 14 ++++ app/src/main/res/drawable/ic_spec_command.xml | 14 ++++ app/src/main/res/drawable/ic_spec_eye_off.xml | 7 ++ app/src/main/res/drawable/ic_spec_globe.xml | 17 +++++ app/src/main/res/drawable/ic_spec_lock.xml | 11 +++ app/src/main/res/drawable/ic_spec_mention.xml | 7 ++ app/src/main/res/drawable/ic_spec_panic.xml | 6 ++ app/src/main/res/drawable/ic_spec_people.xml | 14 ++++ app/src/main/res/drawable/ic_spec_person.xml | 14 ++++ app/src/main/res/drawable/ic_spec_range.xml | 11 +++ app/src/main/res/drawable/ic_spec_shuffle.xml | 6 ++ app/src/main/res/drawable/ic_spec_star.xml | 8 +++ .../main/res/drawable/ic_spec_teleport.xml | 9 +++ .../main/res/drawable/ic_spec_waveform.xml | 5 ++ .../main/res/drawable/ic_spec_wifi_off.xml | 12 ++++ 41 files changed, 374 insertions(+), 51 deletions(-) create mode 100644 app/src/main/assets/design-icons/README.md create mode 100644 app/src/main/assets/design-icons/bookmark-filled.svg create mode 100644 app/src/main/assets/design-icons/bookmark-outline.svg create mode 100644 app/src/main/assets/design-icons/chat-bubbles.svg create mode 100644 app/src/main/assets/design-icons/command.svg create mode 100644 app/src/main/assets/design-icons/eye-off.svg create mode 100644 app/src/main/assets/design-icons/globe.svg create mode 100644 app/src/main/assets/design-icons/lock.svg create mode 100644 app/src/main/assets/design-icons/mention.svg create mode 100644 app/src/main/assets/design-icons/panic.svg create mode 100644 app/src/main/assets/design-icons/people.svg create mode 100644 app/src/main/assets/design-icons/person.svg create mode 100644 app/src/main/assets/design-icons/range.svg create mode 100644 app/src/main/assets/design-icons/shuffle.svg create mode 100644 app/src/main/assets/design-icons/star.svg create mode 100644 app/src/main/assets/design-icons/teleport.svg create mode 100644 app/src/main/assets/design-icons/waveform.svg create mode 100644 app/src/main/assets/design-icons/wifi-off.svg create mode 100644 app/src/main/res/drawable/ic_spec_bookmark_filled.xml create mode 100644 app/src/main/res/drawable/ic_spec_bookmark_outline.xml create mode 100644 app/src/main/res/drawable/ic_spec_chat_bubbles.xml create mode 100644 app/src/main/res/drawable/ic_spec_command.xml create mode 100644 app/src/main/res/drawable/ic_spec_eye_off.xml create mode 100644 app/src/main/res/drawable/ic_spec_globe.xml create mode 100644 app/src/main/res/drawable/ic_spec_lock.xml create mode 100644 app/src/main/res/drawable/ic_spec_mention.xml create mode 100644 app/src/main/res/drawable/ic_spec_panic.xml create mode 100644 app/src/main/res/drawable/ic_spec_people.xml create mode 100644 app/src/main/res/drawable/ic_spec_person.xml create mode 100644 app/src/main/res/drawable/ic_spec_range.xml create mode 100644 app/src/main/res/drawable/ic_spec_shuffle.xml create mode 100644 app/src/main/res/drawable/ic_spec_star.xml create mode 100644 app/src/main/res/drawable/ic_spec_teleport.xml create mode 100644 app/src/main/res/drawable/ic_spec_waveform.xml create mode 100644 app/src/main/res/drawable/ic_spec_wifi_off.xml diff --git a/app/src/main/assets/design-icons/README.md b/app/src/main/assets/design-icons/README.md new file mode 100644 index 00000000..cd04de8f --- /dev/null +++ b/app/src/main/assets/design-icons/README.md @@ -0,0 +1,6 @@ +# Design-spec icons + +These standalone SVGs were extracted from the supplied 393 px Figma screen exports. The matching +Android vector resources in `res/drawable/ic_spec_*.xml` are the runtime copies used by Compose. +Paths and stroke weights remain faithful to the exports; UI tint and opacity are applied at the +call site so selected and disabled states remain theme-aware. diff --git a/app/src/main/assets/design-icons/bookmark-filled.svg b/app/src/main/assets/design-icons/bookmark-filled.svg new file mode 100644 index 00000000..a2f90c78 --- /dev/null +++ b/app/src/main/assets/design-icons/bookmark-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/bookmark-outline.svg b/app/src/main/assets/design-icons/bookmark-outline.svg new file mode 100644 index 00000000..7ffb105f --- /dev/null +++ b/app/src/main/assets/design-icons/bookmark-outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/chat-bubbles.svg b/app/src/main/assets/design-icons/chat-bubbles.svg new file mode 100644 index 00000000..32546ff8 --- /dev/null +++ b/app/src/main/assets/design-icons/chat-bubbles.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/command.svg b/app/src/main/assets/design-icons/command.svg new file mode 100644 index 00000000..3be46195 --- /dev/null +++ b/app/src/main/assets/design-icons/command.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/eye-off.svg b/app/src/main/assets/design-icons/eye-off.svg new file mode 100644 index 00000000..1e8ad44d --- /dev/null +++ b/app/src/main/assets/design-icons/eye-off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/globe.svg b/app/src/main/assets/design-icons/globe.svg new file mode 100644 index 00000000..e0dc1a45 --- /dev/null +++ b/app/src/main/assets/design-icons/globe.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/assets/design-icons/lock.svg b/app/src/main/assets/design-icons/lock.svg new file mode 100644 index 00000000..28a95cb0 --- /dev/null +++ b/app/src/main/assets/design-icons/lock.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/assets/design-icons/mention.svg b/app/src/main/assets/design-icons/mention.svg new file mode 100644 index 00000000..59f2fa19 --- /dev/null +++ b/app/src/main/assets/design-icons/mention.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/panic.svg b/app/src/main/assets/design-icons/panic.svg new file mode 100644 index 00000000..614b9070 --- /dev/null +++ b/app/src/main/assets/design-icons/panic.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/people.svg b/app/src/main/assets/design-icons/people.svg new file mode 100644 index 00000000..f0bce1af --- /dev/null +++ b/app/src/main/assets/design-icons/people.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/person.svg b/app/src/main/assets/design-icons/person.svg new file mode 100644 index 00000000..c0c93053 --- /dev/null +++ b/app/src/main/assets/design-icons/person.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/range.svg b/app/src/main/assets/design-icons/range.svg new file mode 100644 index 00000000..88c36115 --- /dev/null +++ b/app/src/main/assets/design-icons/range.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/shuffle.svg b/app/src/main/assets/design-icons/shuffle.svg new file mode 100644 index 00000000..7f054978 --- /dev/null +++ b/app/src/main/assets/design-icons/shuffle.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/star.svg b/app/src/main/assets/design-icons/star.svg new file mode 100644 index 00000000..b385fc1f --- /dev/null +++ b/app/src/main/assets/design-icons/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/teleport.svg b/app/src/main/assets/design-icons/teleport.svg new file mode 100644 index 00000000..4209d337 --- /dev/null +++ b/app/src/main/assets/design-icons/teleport.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/assets/design-icons/waveform.svg b/app/src/main/assets/design-icons/waveform.svg new file mode 100644 index 00000000..558ccc9d --- /dev/null +++ b/app/src/main/assets/design-icons/waveform.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/wifi-off.svg b/app/src/main/assets/design-icons/wifi-off.svg new file mode 100644 index 00000000..5bb5a4d7 --- /dev/null +++ b/app/src/main/assets/design-icons/wifi-off.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index af0cc08b..e6da1eee 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -1,15 +1,6 @@ package com.bitchat.android.ui -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AlternateEmail -import androidx.compose.material.icons.filled.DeleteForever -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Terminal -import androidx.compose.material.icons.filled.WifiOff -import androidx.compose.material.icons.outlined.BookmarkBorder -import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.Public -import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.annotation.DrawableRes import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.animateColorAsState @@ -45,6 +36,7 @@ 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 @@ -122,7 +114,7 @@ internal fun AboutSectionLabel( */ @Composable internal fun SheetIconSectionHeader( - icon: ImageVector, + @DrawableRes iconRes: Int, title: String, subtitle: String? = null, modifier: Modifier = Modifier @@ -141,7 +133,7 @@ internal fun SheetIconSectionHeader( horizontalArrangement = Arrangement.spacedBy(10.dp) ) { Icon( - imageVector = icon, + painter = painterResource(iconRes), contentDescription = null, tint = colorScheme.primary, modifier = Modifier.size(22.dp) @@ -332,7 +324,7 @@ private fun AboutTabLabel( /** One line of the "How To Use" list: an icon plus a single instruction. */ @Composable private fun AboutInstructionRow( - icon: ImageVector, + @DrawableRes iconRes: Int, text: String ) { val colorScheme = MaterialTheme.colorScheme @@ -346,7 +338,7 @@ private fun AboutInstructionRow( verticalAlignment = Alignment.Top ) { Icon( - imageVector = icon, + painter = painterResource(iconRes), contentDescription = null, tint = colorScheme.primary, modifier = Modifier @@ -385,31 +377,31 @@ internal fun AboutHowToUseSection(modifier: Modifier = Modifier) { ) AboutInstructionRow( - icon = Icons.Filled.AlternateEmail, + iconRes = R.drawable.ic_spec_person, text = stringResource(R.string.about_howto_nickname) ) AboutInstructionRow( - icon = Icons.Outlined.Public, + iconRes = R.drawable.ic_spec_globe, text = stringResource(R.string.about_howto_channels) ) AboutInstructionRow( - icon = Icons.Filled.Person, + iconRes = R.drawable.ic_spec_people, text = stringResource(R.string.about_howto_people) ) AboutInstructionRow( - icon = Icons.Outlined.BookmarkBorder, + iconRes = R.drawable.ic_spec_bookmark_outline, text = stringResource(R.string.about_howto_bookmark) ) AboutInstructionRow( - icon = Icons.Filled.AlternateEmail, + iconRes = R.drawable.ic_spec_mention, text = stringResource(R.string.about_howto_mention) ) AboutInstructionRow( - icon = Icons.Filled.Terminal, + iconRes = R.drawable.ic_spec_command, text = stringResource(R.string.about_howto_commands) ) AboutInstructionRow( - icon = Icons.Filled.DeleteForever, + iconRes = R.drawable.ic_spec_waveform, text = stringResource(R.string.about_howto_panic) ) } @@ -422,22 +414,22 @@ internal fun AboutHowToUseSection(modifier: Modifier = Modifier) { internal fun AboutFeatureCard(modifier: Modifier = Modifier) { val features = listOf( Triple( - Icons.Filled.WifiOff, + R.drawable.ic_spec_wifi_off, R.string.about_offline_mesh_title, R.string.about_offline_mesh_desc ), Triple( - Icons.Outlined.Lock, + R.drawable.ic_spec_lock, R.string.about_e2e_title, R.string.about_e2e_desc ), Triple( - Icons.Outlined.Public, + R.drawable.ic_spec_globe, R.string.about_online_geohash_title, R.string.about_online_geohash_desc ), Triple( - Icons.Outlined.VisibilityOff, + R.drawable.ic_spec_eye_off, R.string.about_no_tracking_title, R.string.about_no_tracking_desc ), @@ -446,7 +438,7 @@ internal fun AboutFeatureCard(modifier: Modifier = Modifier) { Column(modifier = modifier.fillMaxWidth()) { features.forEach { (icon, titleRes, descRes) -> AboutFeatureRow( - icon = icon, + iconRes = icon, title = stringResource(titleRes), subtitle = stringResource(descRes) ) @@ -456,7 +448,7 @@ internal fun AboutFeatureCard(modifier: Modifier = Modifier) { @Composable private fun AboutFeatureRow( - icon: ImageVector, + @DrawableRes iconRes: Int, title: String, subtitle: String ) { @@ -471,7 +463,7 @@ private fun AboutFeatureRow( verticalAlignment = Alignment.Top ) { Icon( - imageVector = icon, + painter = painterResource(iconRes), contentDescription = null, tint = colorScheme.primary, modifier = Modifier @@ -617,4 +609,3 @@ internal fun SheetDestructiveButton( } } } - diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 2811bff4..50cd5ab4 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -32,7 +32,9 @@ 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.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -203,6 +205,64 @@ internal fun TorAwareHeaderIcon( } } +/** 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 + ) + } +} + @Composable fun NoiseSessionIcon( sessionState: String?, @@ -342,7 +402,7 @@ fun PeerCounter( Icon( // A single silhouette rather than a crowd: at 22.dp a multi-person glyph collapses // into an indistinct blob, and the number beside it already conveys "how many". - imageVector = Icons.Filled.Person, + 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) @@ -628,7 +688,11 @@ private fun LocationChannelsButton( } else { TorConnectionVisual(tint = channelColor, isProgress = false) } - val badgeIcon = if (isLocation) Icons.Outlined.Public else Icons.Filled.Hub + val badgeIconRes = if (isLocation) { + R.drawable.ic_spec_globe + } else { + R.drawable.ic_spec_range + } Row( verticalAlignment = Alignment.CenterVertically, @@ -645,7 +709,7 @@ private fun LocationChannelsButton( .padding(start = 0.dp, end = 6.dp) ) { TorAwareHeaderIcon( - imageVector = badgeIcon, + painter = painterResource(badgeIconRes), tint = torVisual.tint, isProgress = torVisual.isProgress, contentDescription = stringResource(R.string.cd_tor_status) diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 8bbe89c4..0efab46f 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp 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.R @@ -60,7 +61,7 @@ fun GeohashPeopleList( Column(modifier = modifier) { if (geohashPeople.isEmpty()) { SheetIconSectionHeader( - icon = Icons.Outlined.Person, + iconRes = R.drawable.ic_spec_people, title = stringResource(R.string.section_people) ) Surface( @@ -163,7 +164,7 @@ fun GeohashPeopleList( if (namedPeople.isNotEmpty()) { SheetIconSectionHeader( - icon = Icons.Outlined.Person, + iconRes = R.drawable.ic_spec_people, title = stringResource(R.string.section_people) ) PeopleCard(people = namedPeople, row = { personRow(it) }) @@ -171,7 +172,7 @@ fun GeohashPeopleList( if (anonPeople.isNotEmpty()) { SheetIconSectionHeader( - icon = Icons.Outlined.HelpOutline, + iconRes = R.drawable.ic_spec_teleport, title = stringResource(R.string.section_anon), modifier = Modifier.padding(top = if (namedPeople.isNotEmpty()) 20.dp else 0.dp) ) @@ -307,9 +308,9 @@ private fun GeohashPersonItem( isMe -> "face.smiling" to palette.accentOrange else -> "face.smiling" to palette.textSecondary } - val statusIcon = when (iconName) { - "face.dashed" -> Icons.Outlined.Explore - else -> Icons.Outlined.LocationOn + val statusIconRes = when (iconName) { + "face.dashed" -> R.drawable.ic_spec_teleport + else -> R.drawable.ic_spec_person } val (baseNameRaw, suffixRaw) = splitSuffix(person.displayName) @@ -341,7 +342,7 @@ private fun GeohashPersonItem( ) } else { Icon( - imageVector = statusIcon, + painter = painterResource(statusIconRes), contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User", modifier = Modifier.size(22.dp), tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 4004c520..b6db766e 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -192,7 +193,7 @@ fun LocationChannelsSheet( item(key = "mesh_card") { Column { SheetIconSectionHeader( - icon = Icons.Filled.Hub, + iconRes = R.drawable.ic_spec_range, title = stringResource(R.string.mesh_title), subtitle = stringResource(R.string.mesh_section_subtitle), modifier = Modifier.padding(top = 8.dp) @@ -230,7 +231,7 @@ fun LocationChannelsSheet( item(key = "location_channels_header") { SheetIconSectionHeader( - icon = Icons.Outlined.Public, + iconRes = R.drawable.ic_spec_globe, title = stringResource(R.string.location_channels_heading), subtitle = stringResource(R.string.location_channels_desc), modifier = Modifier.padding(top = 20.dp) @@ -726,7 +727,13 @@ private fun ChannelBookmarkButton( contentAlignment = Alignment.Center ) { Icon( - imageVector = if (bookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, + painter = painterResource( + if (bookmarked) { + R.drawable.ic_spec_bookmark_filled + } else { + R.drawable.ic_spec_bookmark_outline + } + ), contentDescription = stringResource( if (bookmarked) R.string.cd_remove_bookmark else R.string.cd_add_bookmark ), diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 23157e5d..70f98d5a 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -1,7 +1,5 @@ package com.bitchat.android.ui -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Description import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -17,6 +15,7 @@ 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 @@ -66,7 +65,7 @@ fun LocationNotesButton( contentAlignment = Alignment.Center ) { TorAwareHeaderIcon( - imageVector = Icons.Outlined.Description, + painter = painterResource(R.drawable.ic_spec_chat_bubbles), tint = torVisual.tint, isProgress = torVisual.isProgress, contentDescription = contentDescription diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 05af5f24..a09ff487 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -28,6 +28,7 @@ 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.FontWeight import androidx.compose.ui.unit.dp @@ -122,7 +123,7 @@ fun MeshPeerListSheet( item(key = "channels_section") { Column { SheetIconSectionHeader( - icon = Icons.Filled.Tag, + iconRes = R.drawable.ic_spec_chat_bubbles, title = stringResource(R.string.channels), modifier = Modifier.padding(top = 8.dp) ) @@ -321,7 +322,7 @@ fun PeopleSection( Column(modifier = modifier) { SheetIconSectionHeader( - icon = Icons.Filled.Person, + iconRes = R.drawable.ic_spec_people, title = stringResource(R.string.people_count_title, peopleCount) ) @@ -616,7 +617,7 @@ private fun PeerItem( ) } else if (showNostrGlobe) { Icon( - imageVector = Icons.Filled.Public, + painter = painterResource(R.drawable.ic_spec_globe), contentDescription = stringResource(R.string.cd_reachable_via_nostr), modifier = Modifier.size(PeerRowIconSize), tint = palette.accentPurple @@ -690,7 +691,7 @@ private fun PeerItem( contentAlignment = Alignment.Center ) { Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, + painter = painterResource(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 @@ -971,7 +972,7 @@ fun PrivateChatSheet( when { isNostrPeer || isNostrReachableFavorite -> { Icon( - imageVector = Icons.Filled.Public, + painter = painterResource(R.drawable.ic_spec_globe), contentDescription = stringResource(R.string.cd_nostr_reachable), modifier = Modifier.size(14.dp), tint = Color(0xFF9C27B0) @@ -1039,7 +1040,7 @@ fun PrivateChatSheet( modifier = Modifier.size(28.dp) ) { Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, + painter = painterResource(R.drawable.ic_spec_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) diff --git a/app/src/main/res/drawable/ic_spec_bookmark_filled.xml b/app/src/main/res/drawable/ic_spec_bookmark_filled.xml new file mode 100644 index 00000000..2e73b458 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_bookmark_filled.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_bookmark_outline.xml b/app/src/main/res/drawable/ic_spec_bookmark_outline.xml new file mode 100644 index 00000000..988fe44c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_bookmark_outline.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_chat_bubbles.xml b/app/src/main/res/drawable/ic_spec_chat_bubbles.xml new file mode 100644 index 00000000..76109058 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_chat_bubbles.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_command.xml b/app/src/main/res/drawable/ic_spec_command.xml new file mode 100644 index 00000000..366173cd --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_command.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_eye_off.xml b/app/src/main/res/drawable/ic_spec_eye_off.xml new file mode 100644 index 00000000..2e852cf4 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_eye_off.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_globe.xml b/app/src/main/res/drawable/ic_spec_globe.xml new file mode 100644 index 00000000..b56bb3a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_globe.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_lock.xml b/app/src/main/res/drawable/ic_spec_lock.xml new file mode 100644 index 00000000..42713a11 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_lock.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_mention.xml b/app/src/main/res/drawable/ic_spec_mention.xml new file mode 100644 index 00000000..a57bf9af --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_mention.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_panic.xml b/app/src/main/res/drawable/ic_spec_panic.xml new file mode 100644 index 00000000..7c876da3 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_panic.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_people.xml b/app/src/main/res/drawable/ic_spec_people.xml new file mode 100644 index 00000000..3b2d46f5 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_people.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_person.xml b/app/src/main/res/drawable/ic_spec_person.xml new file mode 100644 index 00000000..33c42dcb --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_person.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_range.xml b/app/src/main/res/drawable/ic_spec_range.xml new file mode 100644 index 00000000..5ba47d6c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_range.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_shuffle.xml b/app/src/main/res/drawable/ic_spec_shuffle.xml new file mode 100644 index 00000000..9f431732 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_shuffle.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_star.xml b/app/src/main/res/drawable/ic_spec_star.xml new file mode 100644 index 00000000..c48025ab --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_star.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_teleport.xml b/app/src/main/res/drawable/ic_spec_teleport.xml new file mode 100644 index 00000000..6d215dea --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_teleport.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_waveform.xml b/app/src/main/res/drawable/ic_spec_waveform.xml new file mode 100644 index 00000000..2e0070b6 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_waveform.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_wifi_off.xml b/app/src/main/res/drawable/ic_spec_wifi_off.xml new file mode 100644 index 00000000..4a716e57 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_wifi_off.xml @@ -0,0 +1,12 @@ + + + + + + + + From 34175988a5b2c0c6b66d8375109dba95c4a07990 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:49:18 +0200 Subject: [PATCH 28/70] icons --- .../main/assets/design-icons/star-filled.svg | 3 +++ .../java/com/bitchat/android/ui/ChatHeader.kt | 17 ++++++++--------- .../com/bitchat/android/ui/MeshPeerListSheet.kt | 12 ++++++++++-- .../main/res/drawable/ic_spec_star_filled.xml | 8 ++++++++ 4 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 app/src/main/assets/design-icons/star-filled.svg create mode 100644 app/src/main/res/drawable/ic_spec_star_filled.xml diff --git a/app/src/main/assets/design-icons/star-filled.svg b/app/src/main/assets/design-icons/star-filled.svg new file mode 100644 index 00000000..ebc21c4f --- /dev/null +++ b/app/src/main/assets/design-icons/star-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 50cd5ab4..f1b2fbe2 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -58,13 +58,12 @@ import com.bitchat.android.ui.theme.LocalBitchatPalette val ChatHeaderHeight = 52.dp /** - * The single icon size used by every glyph in the top bar. + * The single visible glyph size used by every icon in the top bar. * - * Previously the bar mixed 12/14/16/18.dp icons, which is why it read as a collection of - * unrelated glyphs rather than one control strip. One size for everything, and large enough to - * actually see. + * 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 = 22.dp +internal val HeaderIconSize = 19.dp /** * Text size for the top bar's labels: nickname, channel name, peer count. @@ -392,7 +391,7 @@ fun PeerCounter( Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier .clip(HeaderClusterShape) .pressScaleClickable(onClick = onClick) @@ -400,8 +399,8 @@ fun PeerCounter( .padding(horizontal = 6.dp) ) { Icon( - // A single silhouette rather than a crowd: at 22.dp a multi-person glyph collapses - // into an indistinct blob, and the number beside it already conveys "how many". + // 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) @@ -696,7 +695,7 @@ private fun LocationChannelsButton( Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier .clip(HeaderClusterShape) .pressScaleClickable( diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index a09ff487..9e1639b0 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -691,7 +691,9 @@ private fun PeerItem( contentAlignment = Alignment.Center ) { Icon( - painter = painterResource(R.drawable.ic_spec_star), + 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 @@ -1040,7 +1042,13 @@ fun PrivateChatSheet( modifier = Modifier.size(28.dp) ) { Icon( - painter = painterResource(R.drawable.ic_spec_star), + painter = painterResource( + if (isFavorite) { + R.drawable.ic_spec_star_filled + } else { + R.drawable.ic_spec_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) diff --git a/app/src/main/res/drawable/ic_spec_star_filled.xml b/app/src/main/res/drawable/ic_spec_star_filled.xml new file mode 100644 index 00000000..d29d62d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_star_filled.xml @@ -0,0 +1,8 @@ + + + + + From fe4920465d7dab116d6930f64234b6f03f5f766c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:02:24 +0200 Subject: [PATCH 29/70] cleanup --- .../bitchat/android/ui/LocationChannelsSheet.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index b6db766e..7581dd46 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -966,10 +966,19 @@ private fun ChannelSettingsToggleRow( // MARK: - Helper Functions +/** Separates the channel name from the animated people-count label in composed titles. */ +private const val TITLE_COUNT_SEP = '\u001F' + private fun splitTitleAndCount(title: String): Pair { + val sep = title.indexOf(TITLE_COUNT_SEP) + if (sep != -1) { + return Pair(title.substring(0, sep), title.substring(sep + 1).ifEmpty { null }) + } + // Legacy "[n people]" form val lastBracketIndex = title.lastIndexOf('[') return if (lastBracketIndex != -1) { - Pair(title.substring(0, lastBracketIndex).trim(), title.substring(lastBracketIndex)) + val count = title.substring(lastBracketIndex + 1).removeSuffix("]") + Pair(title.substring(0, lastBracketIndex).trim(), count.ifEmpty { null }) } else { Pair(title, null) } @@ -981,7 +990,7 @@ private fun meshTitleWithCount(viewModel: ChatViewModel): String { val ctx = LocalContext.current val peopleText = ctx.resources.getQuantityString(R.plurals.people_count, meshCount, meshCount) val meshLabel = stringResource(R.string.mesh_title) - return "$meshLabel [$peopleText]" + return "$meshLabel$TITLE_COUNT_SEP$peopleText" } private fun meshCount(viewModel: ChatViewModel): Int { @@ -1006,7 +1015,7 @@ private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int GeohashChannelLevel.PROVINCE -> stringResource(R.string.location_level_province) GeohashChannelLevel.REGION -> stringResource(R.string.location_level_region) } - return "$levelName [$peopleText]" + return "$levelName$TITLE_COUNT_SEP$peopleText" } @Composable @@ -1019,7 +1028,7 @@ private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): S } else { ctx.resources.getQuantityString(R.plurals.people_count, participantCount, participantCount) } - return "#$geohash [$peopleText]" + return "#$geohash$TITLE_COUNT_SEP$peopleText" } private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean { From 4863381dbb7c600c8d8fe471ed4857a7c3414505 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:27:15 +0200 Subject: [PATCH 30/70] mentions --- .../bitchat/android/ui/CommandProcessor.kt | 38 ++++++++++--- .../com/bitchat/android/ui/InputComponents.kt | 37 +++++++++---- .../android/ui/MentionSuggestionsTest.kt | 54 +++++++++++++++++++ 3 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index a92d2d60..04c0b0d7 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import java.util.Date +import java.util.Locale /** * Handles processing of IRC-style commands @@ -448,12 +449,13 @@ 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() geohashPeople.mapNotNull { person -> @@ -469,13 +471,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 +533,27 @@ class CommandProcessor( } } } + +/** + * Keep mention autocomplete useful in crowded channels: a bare `anon` identity has not announced + * a username and is not actionable. Names such as `anon1234` are announced usernames and remain + * valid mention targets. + */ +internal fun filterMentionCandidates( + candidates: List, + query: String +): List { + return candidates.asSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .filterNot(::isUnannouncedMentionNickname) + .filter { nickname -> nickname.startsWith(query, ignoreCase = true) } + .distinctBy { nickname -> nickname.lowercase(Locale.ROOT) } + .sortedWith(String.CASE_INSENSITIVE_ORDER) + .toList() +} + +internal fun isUnannouncedMentionNickname(displayName: String): Boolean { + val base = splitSuffix(displayName.trim()).first + return base.equals("anon", ignoreCase = true) +} diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 7b556fb9..d0377a8f 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -1,6 +1,5 @@ package com.bitchat.android.ui -import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BitchatFontFamily // [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher @@ -28,6 +27,8 @@ import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -707,13 +708,18 @@ fun MentionSuggestionsBox( ) { val palette = LocalBitchatPalette.current - Column( + LazyColumn( modifier = modifier + .heightIn(max = MentionSuggestionsMaxHeight) + .clip(MentionSuggestionsShape) .background(palette.surface) - .border(1.dp, palette.outlineVariant, RoundedCornerShape(8.dp)) - .padding(vertical = 6.dp) + .border(1.dp, palette.outlineVariant, MentionSuggestionsShape), + contentPadding = PaddingValues(vertical = MentionSuggestionsVerticalPadding) ) { - suggestions.forEach { suggestion: String -> + items( + items = suggestions, + key = { suggestion -> suggestion.lowercase() } + ) { suggestion -> MentionSuggestionItem( suggestion = suggestion, onClick = { onSuggestionClick(suggestion) } @@ -732,8 +738,9 @@ fun MentionSuggestionItem( Row( modifier = Modifier .fillMaxWidth() + .height(MentionSuggestionRowHeight) .clickable { onClick() } - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -743,10 +750,13 @@ fun MentionSuggestionItem( fontWeight = FontWeight.SemiBold ), color = palette.accentOrange, - fontSize = (BASE_FONT_SIZE - 2).sp + fontSize = (BASE_FONT_SIZE - 2).sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.width(12.dp)) Text( text = stringResource(R.string.mention), @@ -754,7 +764,16 @@ fun MentionSuggestionItem( fontFamily = BitchatFontFamily ), color = palette.textTertiary, - fontSize = (BASE_FONT_SIZE - 4).sp + fontSize = (BASE_FONT_SIZE - 4).sp, + maxLines = 1 ) } } + +/** Mention autocomplete stays compact even in crowded channels. */ +internal const val MaxVisibleMentionSuggestions = 5 +private val MentionSuggestionRowHeight = 48.dp +private val MentionSuggestionsVerticalPadding = 6.dp +private val MentionSuggestionsMaxHeight = + (48 * MaxVisibleMentionSuggestions + 12).dp +private val MentionSuggestionsShape = RoundedCornerShape(8.dp) diff --git a/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt new file mode 100644 index 00000000..75fb6c01 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt @@ -0,0 +1,54 @@ +package com.bitchat.android.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MentionSuggestionsTest { + + @Test + fun `only users without an announced nickname are excluded from mentions`() { + val suggestions = filterMentionCandidates( + candidates = listOf( + "anon", + "anon#04af", + "anon7674#df5b", + "alice#1234", + "anonymous", + "anonracer#04af" + ), + query = "" + ) + + assertEquals( + listOf("alice#1234", "anon7674#df5b", "anonracer#04af", "anonymous"), + suggestions + ) + assertTrue(suggestions.none(::isUnannouncedMentionNickname)) + } + + @Test + fun `mention filtering is case insensitive and removes duplicates`() { + val suggestions = filterMentionCandidates( + candidates = listOf("Bob#1234", "bob#1234", "bobby#5678", "alice#9999"), + query = "BO" + ) + + assertEquals(listOf("Bob#1234", "bobby#5678"), suggestions) + } + + @Test + fun `announced names beginning with anon stay mentionable`() { + assertTrue(isUnannouncedMentionNickname("anon")) + assertTrue(isUnannouncedMentionNickname("anon#04af")) + assertFalse(isUnannouncedMentionNickname("anon1234#04af")) + assertFalse(isUnannouncedMentionNickname("anonymous#04af")) + assertFalse(isUnannouncedMentionNickname("anonracer")) + } + + @Test + fun `mention popup viewport is capped at five rows`() { + assertEquals(5, MaxVisibleMentionSuggestions) + } +} From b1d709fc4a302e51e127321a7567578729a612b0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:32:33 +0200 Subject: [PATCH 31/70] fix mentions --- .../java/com/bitchat/android/ui/ChatScreen.kt | 50 +++++++++++++---- .../com/bitchat/android/ui/InputComponents.kt | 54 +++++++++++++++++-- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 911933ba..ec3c5d4f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -1,6 +1,5 @@ package com.bitchat.android.ui -import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BitchatFontFamily // [Goose] Bridge file share events to ViewModel via dispatcher is installed in ChatScreen composition @@ -593,14 +592,47 @@ fun ChatInputSection( ) HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) } - // Mention suggestions box - if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) { - MentionSuggestionsBox( - suggestions = mentionSuggestions, - onSuggestionClick = onMentionSuggestionClick, - modifier = Modifier.fillMaxWidth() - ) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + // Retain the final populated list while the picker exits. The state layer clears + // suggestions together with visibility; without this snapshot the panel would empty and + // snap shut before its shrink/fade animation had a frame to run. + var retainedMentionSuggestions by remember { mutableStateOf(emptyList()) } + LaunchedEffect(mentionSuggestions) { + if (mentionSuggestions.isNotEmpty()) { + retainedMentionSuggestions = mentionSuggestions + } + } + 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 = displayedMentionSuggestions, + onSuggestionClick = onMentionSuggestionClick, + modifier = Modifier.fillMaxWidth() + ) + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + } } MessageInput( value = messageText, diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index d0377a8f..07f633dc 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -19,9 +19,11 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.expandVertically import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource @@ -700,6 +702,7 @@ fun CommandSuggestionItem( } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun MentionSuggestionsBox( suggestions: List, @@ -711,6 +714,12 @@ fun MentionSuggestionsBox( LazyColumn( modifier = modifier .heightIn(max = MentionSuggestionsMaxHeight) + .animateContentSize( + animationSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ) + ) .clip(MentionSuggestionsShape) .background(palette.surface) .border(1.dp, palette.outlineVariant, MentionSuggestionsShape), @@ -722,7 +731,18 @@ fun MentionSuggestionsBox( ) { suggestion -> MentionSuggestionItem( suggestion = suggestion, - onClick = { onSuggestionClick(suggestion) } + onClick = { onSuggestionClick(suggestion) }, + modifier = Modifier.animateItem( + fadeInSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ), + placementSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ), + fadeOutSpec = tween(BitchatMotion.QUICK_MS) + ) ) } } @@ -731,15 +751,41 @@ fun MentionSuggestionsBox( @Composable fun MentionSuggestionItem( suggestion: String, - onClick: () -> Unit + onClick: () -> Unit, + modifier: Modifier = Modifier ) { val palette = LocalBitchatPalette.current + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val pressedBackground by animateColorAsState( + targetValue = if (isPressed) { + palette.accentOrange.copy(alpha = 0.10f) + } else { + Color.Transparent + }, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + label = "mentionSuggestionPressedBackground" + ) + val pressedScale by animateFloatAsState( + targetValue = if (isPressed) 0.985f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "mentionSuggestionPressedScale" + ) Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .height(MentionSuggestionRowHeight) - .clickable { onClick() } + .scale(pressedScale) + .background(pressedBackground) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick + ) .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { From c1c7704c32f8d35a8251c1e59fdf833d3e19fe07 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:46:11 +0200 Subject: [PATCH 32/70] grouping of geohash channel list --- .../design-icons/on-location-person.svg | 5 + .../core/ui/component/button/CloseButton.kt | 5 +- .../com/bitchat/android/ui/ChatUIUtils.kt | 10 + .../bitchat/android/ui/CommandProcessor.kt | 7 +- .../bitchat/android/ui/GeohashPeopleList.kt | 314 +++++++----------- .../drawable/ic_spec_on_location_person.xml | 13 + app/src/main/res/values/strings.xml | 4 +- .../android/ui/GeohashAnonOrderingTest.kt | 188 ----------- .../android/ui/GeohashPresenceGroupingTest.kt | 83 +++++ .../android/ui/MentionSuggestionsTest.kt | 12 +- 10 files changed, 249 insertions(+), 392 deletions(-) create mode 100644 app/src/main/assets/design-icons/on-location-person.svg create mode 100644 app/src/main/res/drawable/ic_spec_on_location_person.xml delete mode 100644 app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt create mode 100644 app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt diff --git a/app/src/main/assets/design-icons/on-location-person.svg b/app/src/main/assets/design-icons/on-location-person.svg new file mode 100644 index 00000000..5b448ae9 --- /dev/null +++ b/app/src/main/assets/design-icons/on-location-person.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt index 36ad7526..091f93fc 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults 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 @@ -24,8 +25,8 @@ fun CloseButton( // 44.dp to match every other tap target in the app's chrome. modifier = modifier.size(44.dp), colors = IconButtonDefaults.iconButtonColors( - contentColor = palette.textSecondary, - containerColor = palette.surfaceVariant + contentColor = palette.accentGreen, + containerColor = Color.Transparent ) ) { Icon( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 5f5fa778..05425f60 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -373,6 +373,16 @@ fun splitSuffix(name: String): Pair { return Pair(name, "") } +/** + * 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. * diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index 04c0b0d7..4ac9344a 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -546,14 +546,9 @@ internal fun filterMentionCandidates( return candidates.asSequence() .map(String::trim) .filter(String::isNotEmpty) - .filterNot(::isUnannouncedMentionNickname) + .filterNot(::isUnannouncedNickname) .filter { nickname -> nickname.startsWith(query, ignoreCase = true) } .distinctBy { nickname -> nickname.lowercase(Locale.ROOT) } .sortedWith(String.CASE_INSENSITIVE_ORDER) .toList() } - -internal fun isUnannouncedMentionNickname(displayName: String): Boolean { - val base = splitSuffix(displayName.trim()).first - return base.equals("anon", ignoreCase = true) -} diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 0efab46f..7610fedf 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -2,23 +2,13 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Email -import androidx.compose.material.icons.outlined.Explore -import androidx.compose.material.icons.outlined.HelpOutline -import androidx.compose.material.icons.outlined.Person -import androidx.compose.material.icons.outlined.LocationOn import android.util.Log -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.background 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.Brush import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -28,7 +18,6 @@ import androidx.compose.ui.res.painterResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R -import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette import java.util.* @@ -48,22 +37,74 @@ fun GeohashPeopleList( onTapPerson: () -> Unit, modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme - 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() val palette = LocalBitchatPalette.current + 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 + } + } + 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 baseNameCounts = remember(displayedPeople) { + buildMap { + displayedPeople.forEach { person -> + val baseName = splitSuffix(person.displayName).first + put(baseName, (get(baseName) ?: 0) + 1) + } + } + } Column(modifier = modifier) { - if (geohashPeople.isEmpty()) { - SheetIconSectionHeader( - iconRes = R.drawable.ic_spec_people, - title = stringResource(R.string.section_people) - ) + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_people, + title = stringResource(R.string.people_count_title, displayedPeople.size) + ) + + if (displayedPeople.isEmpty()) { Surface( modifier = Modifier .fillMaxWidth() @@ -84,77 +125,23 @@ fun GeohashPeopleList( ) } } else { - 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 - } - } - - // Self first, then anyone who chose a nickname, then the anons — all by recency - // within their group. A busy geohash is mostly anonymous drive-by participants, and - // letting them sort by recency alone buried the handful of people worth recognising. - val orderedPeople = remember(geohashPeople, myHex) { - geohashPeople.sortedWith( - compareByDescending { myHex != null && it.id == myHex } - .thenBy { it.isAnonymous() } - .thenByDescending { it.lastSeen } - ) - } - - val baseNameCounts = remember(geohashPeople) { - val counts = mutableMapOf() - geohashPeople.forEach { person -> - val (b, _) = splitSuffix(person.displayName) - counts[b] = (counts[b] ?: 0) + 1 - } - counts - } - - fun personTeleported(person: GeoPerson): Boolean = if (person.id == myHex) { - isTeleported - } else { - viewModel.isPersonTeleported(person.id) - } - - // Two groups: peers who announced a nickname, then the anons. - // - // A busy geohash is mostly anonymous drive-by participants, and mixing them in pushed - // the few recognisable names out of view. Teleport state is not a grouping any more — - // it is already on every row as its own glyph, so splitting "on location" from - // "teleported in" only fragmented the short list that people actually read. - // - // Self is never grouped as an anon even when unnamed: you always want to find yourself - // among the people, not buried at the bottom. - val isSelf: (GeoPerson) -> Boolean = { myHex != null && it.id == myHex } - val namedPeople = orderedPeople.filter { isSelf(it) || !it.isAnonymous() } - val anonPeople = orderedPeople.filter { !isSelf(it) && it.isAnonymous() } - @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, - 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[splitSuffix(person.displayName).first] ?: 0) > 1, onTap = { - if (person.id != myHex) { + if (!isMe) { viewModel.startGeohashDM(person.id) onTapPerson() } @@ -162,65 +149,71 @@ fun GeohashPeopleList( ) } - if (namedPeople.isNotEmpty()) { - SheetIconSectionHeader( - iconRes = R.drawable.ic_spec_people, - title = stringResource(R.string.section_people) + if (sections.onLocation.isNotEmpty()) { + AboutSectionLabel(text = stringResource(R.string.section_on_location)) + PeopleCard( + people = sections.onLocation, + row = { personRow(it) } ) - PeopleCard(people = namedPeople, row = { personRow(it) }) } - if (anonPeople.isNotEmpty()) { - SheetIconSectionHeader( - iconRes = R.drawable.ic_spec_teleport, - title = stringResource(R.string.section_anon), - modifier = Modifier.padding(top = if (namedPeople.isNotEmpty()) 20.dp else 0.dp) + if (sections.teleportedIn.isNotEmpty()) { + AboutSectionLabel(text = stringResource(R.string.section_teleported_in)) + PeopleCard( + people = sections.teleportedIn, + row = { personRow(it) } ) - PeopleCard(people = anonPeople, capped = true, row = { personRow(it) }) } } } } -/** Anonymous participants beyond this many are hidden behind the "n more" affordance. */ -internal const val MaxVisibleAnons = 5 +internal data class GeohashPeopleSections( + val onLocation: List, + val teleportedIn: List +) /** - * Whether this participant never set a nickname. - * - * The app labels them `anon` or `anon1234` before the `#abcd` disambiguator, so the base name is - * what identifies them. + * 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 GeoPerson.isAnonymous(): Boolean { - val base = splitSuffix(displayName).first - return base == "anon" || (base.startsWith("anon") && base.drop(4).all { it.isDigit() }) +internal fun sectionGeohashPeople( + people: List, + myId: String?, + selfIsTeleported: Boolean, + teleportedIds: Set +): GeohashPeopleSections { + val normalizedMyId = myId?.lowercase(Locale.ROOT) + val normalizedTeleportedIds = teleportedIds + .mapTo(mutableSetOf()) { it.lowercase(Locale.ROOT) } + fun isSelf(person: GeoPerson): Boolean = + normalizedMyId != null && person.id.lowercase(Locale.ROOT) == normalizedMyId + fun isTeleported(person: GeoPerson): Boolean = + if (isSelf(person)) selfIsTeleported + else person.id.lowercase(Locale.ROOT) in normalizedTeleportedIds + + val displayedPeople = people.filter { person -> + isSelf(person) || !isUnannouncedNickname(person.displayName) + } + val ordered = displayedPeople.sortedWith( + compareByDescending(::isSelf) + .thenByDescending { it.lastSeen } + ) + return GeohashPeopleSections( + onLocation = ordered.filterNot(::isTeleported), + teleportedIn = ordered.filter(::isTeleported) + ) } -/** - * One grouped card of people. - * - * When [capped] the list is trimmed to [MaxVisibleAnons] rows and the remainder is collapsed behind - * a count. That matters for the anonymous section: a popular geohash can hold dozens of anons, which - * pushed everyone worth recognising off screen and turned the sheet into a wall of near-identical - * rows. - * - * The capped card is a **fixed height** — [MaxVisibleAnons] rows plus the overflow line, always, - * regardless of how many anons are currently present beyond the cap. Anons join and leave a busy - * geohash constantly, and sizing to the live count made the card grow and shrink under the reader - * every few seconds. - */ +/** One uncapped card of people. The enclosing sheet owns scrolling. */ @Composable private fun PeopleCard( people: List, - row: @Composable (GeoPerson) -> Unit, - capped: Boolean = false + row: @Composable (GeoPerson) -> Unit ) { val palette = LocalBitchatPalette.current - val visible = if (capped) people.take(MaxVisibleAnons) else people - val hiddenCount = people.size - visible.size - val isTrimmed = capped && people.size > MaxVisibleAnons - Surface( modifier = Modifier .fillMaxWidth() @@ -229,59 +222,10 @@ private fun PeopleCard( color = palette.surface, shape = AboutCardShape ) { - Column { - Box( - // Reserve the full capped height up front so the card cannot resize as anons - // churn. Rows are a fixed height, so this is exact rather than an estimate. - modifier = if (isTrimmed) { - Modifier.height(SheetRowHeight * MaxVisibleAnons) - } else { - Modifier - } - ) { - AnimatedRowColumn(items = visible, key = { it.id }) { index, person -> - Column { - if (index > 0) SheetCardDivider() - if (isTrimmed && index == visible.lastIndex) { - // Fade only the final row, so the gradient reads as "the list - // continues" rather than dimming content still meant to be read. - Box { - row(person) - Box( - modifier = Modifier - .matchParentSize() - .background( - Brush.verticalGradient( - listOf( - palette.surface.copy(alpha = 0f), - palette.surface.copy(alpha = 0.85f) - ) - ) - ) - ) - } - } else { - row(person) - } - } - } - } - - if (isTrimmed) { - // Always laid out when trimmed, so the count changing never moves anything. - // Only the number itself animates. - AnimatedCountLabel( - count = hiddenCount, - text = stringResource(R.string.people_n_more, hiddenCount), - fontFamily = BitchatFontFamily, - fontSize = 11.sp, - color = palette.textTertiary, - modifier = Modifier.padding( - start = SheetRowHorizontal, - end = SheetRowHorizontal, - bottom = SheetRowVertical - ) - ) + AnimatedRowColumn(items = people, key = { it.id }) { index, person -> + Column { + if (index > 0) SheetCardDivider() + row(person) } } } @@ -293,25 +237,15 @@ private fun GeohashPersonItem( isMe: Boolean, hasUnreadDM: Boolean, isTeleported: Boolean, - isMyTeleported: Boolean, - nickname: String, - colorScheme: ColorScheme, viewModel: ChatViewModel, showHashSuffix: Boolean, onTap: () -> Unit ) { val palette = LocalBitchatPalette.current - val (iconName, iconColor) = when { - isMe && isMyTeleported -> "face.dashed" to palette.accentOrange - isTeleported -> "face.dashed" to palette.textSecondary - isMe -> "face.smiling" to palette.accentOrange - else -> "face.smiling" to palette.textSecondary - } - val statusIconRes = when (iconName) { - "face.dashed" -> R.drawable.ic_spec_teleport - else -> R.drawable.ic_spec_person - } + val statusIconRes = + if (isTeleported) R.drawable.ic_spec_teleport + else R.drawable.ic_spec_on_location_person val (baseNameRaw, suffixRaw) = splitSuffix(person.displayName) val baseName = truncateNickname(baseNameRaw) @@ -343,9 +277,13 @@ private fun GeohashPersonItem( } else { Icon( painter = painterResource(statusIconRes), - contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User", + contentDescription = if (isTeleported) { + stringResource(R.string.cd_teleported) + } else { + stringResource(R.string.section_on_location) + }, modifier = Modifier.size(22.dp), - tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) + tint = baseColor ) } } diff --git a/app/src/main/res/drawable/ic_spec_on_location_person.xml b/app/src/main/res/drawable/ic_spec_on_location_person.xml new file mode 100644 index 00000000..507d47d8 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_on_location_person.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 212f1be2..5ee5c4c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -317,8 +317,8 @@ People (%1$d) People - Anon - %1$d more\u2026 + On location + Teleported in Grant location permission Location permission denied. Enable in settings to use location channels. diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt deleted file mode 100644 index c4a76fcf..00000000 --- a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt +++ /dev/null @@ -1,188 +0,0 @@ -package com.bitchat.android.ui - -import java.util.Date -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * A busy geohash is mostly anonymous drive-by participants. If they sort by recency alongside - * everyone else they bury the handful of people worth recognising, so they are pushed to the end of - * the list and capped. - */ -class GeohashAnonOrderingTest { - - private fun person(name: String, secondsAgo: Long = 0) = GeoPerson( - id = "id-$name", - displayName = name, - lastSeen = Date(1_000_000L - secondsAgo * 1000L) - ) - - @Test - fun `bare anon is anonymous`() { - assertTrue(person("anon").isAnonymous()) - assertTrue(person("anon#04af").isAnonymous()) - } - - @Test - fun `numbered anon is anonymous`() { - assertTrue(person("anon7674").isAnonymous()) - assertTrue(person("anon7674#df5b").isAnonymous()) - } - - @Test - fun `a chosen nickname is not anonymous`() { - assertFalse(person("alice").isAnonymous()) - assertFalse(person("alice#1234").isAnonymous()) - } - - @Test - fun `a nickname that merely starts with anon is not anonymous`() { - // "anonymous" and "anonracer" are deliberate names, not the generated placeholder. - assertFalse(person("anonymous").isAnonymous()) - assertFalse(person("anonracer#04af").isAnonymous()) - } - - @Test - fun `named participants sort ahead of anons regardless of recency`() { - val me = person("me") - val people = listOf( - person("anon1", secondsAgo = 0), // most recent of all - person("zoe", secondsAgo = 500), // stalest named user - person("anon2", secondsAgo = 10), - person("alice", secondsAgo = 200) - ) - - val ordered = (people + me).sortedWith( - compareByDescending { it.id == me.id } - .thenBy { it.isAnonymous() } - .thenByDescending { it.lastSeen } - ) - - assertEquals( - listOf("me", "alice", "zoe", "anon1", "anon2"), - ordered.map { it.displayName } - ) - } - - @Test - fun `recency still orders within each group`() { - val people = listOf( - person("bob", secondsAgo = 100), - person("alice", secondsAgo = 10), - person("anon9", secondsAgo = 300), - person("anon1", secondsAgo = 5) - ) - - val ordered = people.sortedWith( - compareBy { it.isAnonymous() }.thenByDescending { it.lastSeen } - ) - - assertEquals( - listOf("alice", "bob", "anon1", "anon9"), - ordered.map { it.displayName } - ) - } - - @Test - fun `named participants are never trimmed`() { - val named = (1..20).map { person("user$it") } - assertEquals(20, named.filterNot { it.isAnonymous() }.size) - } - - @Test - fun `anons beyond the cap are counted as hidden`() { - val people = (1..12).map { person("anon$it") } + person("alice") - - val anons = people.filter { it.isAnonymous() } - val visible = anons.take(MaxVisibleAnons) - - assertEquals(MaxVisibleAnons, visible.size) - assertEquals(12 - MaxVisibleAnons, anons.size - visible.size) - } - - // MARK: - Sectioning - - /** - * Mirrors the two-group split in GeohashPeopleList: peers who announced a nickname, then the - * anons. Self is never treated as an anon. - */ - private fun sections(people: List, myId: String?): Triple, List, List> { - val isSelf: (GeoPerson) -> Boolean = { myId != null && it.id == myId } - val named = people.filter { isSelf(it) || !it.isAnonymous() } - val anons = people.filter { !isSelf(it) && it.isAnonymous() } - return Triple( - named.map { it.displayName }, - anons.map { it.displayName }, - people.map { it.displayName } - ) - } - - @Test - fun `anons are grouped out of the people section entirely`() { - val people = listOf(person("alice"), person("anon1"), person("bob"), person("anon2")) - val (named, anons, _) = sections(people, myId = null) - - assertEquals(listOf("alice", "bob"), named) - assertEquals(listOf("anon1", "anon2"), anons) - } - - @Test - fun `self stays in the people section even when unnamed`() { - // You always want to find yourself where you actually are, not buried in the anon section. - val me = person("anon") - val people = listOf(me, person("alice"), person("anon2")) - val (named, anons, _) = sections(people, myId = me.id) - - assertTrue("self must not be grouped as an anon", named.contains("anon")) - assertFalse(anons.contains("anon")) - assertEquals(listOf("anon2"), anons) - } - - @Test - fun `a list of only anons yields no people section`() { - val people = (1..4).map { person("anon$it") } - val (named, anons, _) = sections(people, myId = null) - - assertTrue(named.isEmpty()) - assertEquals(4, anons.size) - } - - // MARK: - Stable length - - @Test - fun `a trimmed anon list always renders exactly the cap`() { - // The reserved height is MaxVisibleAnons rows whenever trimmed, so the card cannot resize - // as anons churn above the cap. - for (total in listOf(MaxVisibleAnons + 1, MaxVisibleAnons + 7, MaxVisibleAnons + 40)) { - val anons = (1..total).map { person("anon$it") } - val visible = anons.take(MaxVisibleAnons) - assertEquals( - "row count must not depend on how many anons are present beyond the cap", - MaxVisibleAnons, - visible.size - ) - assertEquals(total - MaxVisibleAnons, anons.size - visible.size) - } - } - - @Test - fun `reordering never changes how many rows are rendered`() { - val anons = (1..9).map { person("anon$it", secondsAgo = it.toLong()) } - val byRecency = anons.sortedByDescending { it.lastSeen }.take(MaxVisibleAnons) - val reversed = anons.sortedBy { it.lastSeen }.take(MaxVisibleAnons) - - assertEquals(byRecency.size, reversed.size) - assertEquals(MaxVisibleAnons, byRecency.size) - } - - @Test - fun `a short anon list is shown in full with nothing hidden`() { - val people = listOf(person("alice"), person("anon1"), person("anon2")) - - val anons = people.filter { it.isAnonymous() } - assertEquals(2, anons.take(MaxVisibleAnons).size) - assertEquals(0, anons.size - anons.take(MaxVisibleAnons).size) - } -} diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt new file mode 100644 index 00000000..3c655f44 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt @@ -0,0 +1,83 @@ +package com.bitchat.android.ui + +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GeohashPresenceGroupingTest { + + private fun person(id: String, name: String = id, lastSeen: Long = 0) = GeoPerson( + id = id, + displayName = name, + lastSeen = Date(lastSeen) + ) + + @Test + fun `heartbeat anon is hidden while announced anon name remains`() { + val people = listOf( + person("local-heartbeat", "anon"), + person("named", "alice"), + person("teleported-named", "anon7674"), + person("teleported-heartbeat", "anon#04af") + ) + + val sections = sectionGeohashPeople( + people = people, + myId = null, + selfIsTeleported = false, + teleportedIds = setOf("teleported-named", "teleported-heartbeat") + ) + + assertEquals(listOf("alice"), sections.onLocation.map { it.displayName }) + assertEquals(listOf("anon7674"), sections.teleportedIn.map { it.displayName }) + assertEquals(2, sections.onLocation.size + sections.teleportedIn.size) + } + + @Test + fun `self is first in on-location section when not teleported`() { + val sections = sectionGeohashPeople( + people = listOf( + person("recent", lastSeen = 3_000), + person("me", name = "anon", lastSeen = 0), + person("older", lastSeen = 1_000) + ), + myId = "me", + selfIsTeleported = false, + teleportedIds = emptySet() + ) + + assertEquals(listOf("me", "recent", "older"), sections.onLocation.map { it.id }) + assertTrue(sections.teleportedIn.isEmpty()) + } + + @Test + fun `self is first in teleported section when teleported`() { + val sections = sectionGeohashPeople( + people = listOf( + person("remote-teleport", lastSeen = 3_000), + person("me", lastSeen = 0), + person("local", lastSeen = 2_000) + ), + myId = "ME", + selfIsTeleported = true, + teleportedIds = setOf("remote-teleport") + ) + + assertEquals(listOf("local"), sections.onLocation.map { it.id }) + assertEquals(listOf("me", "remote-teleport"), sections.teleportedIn.map { it.id }) + } + + @Test + fun `remote teleport matching is case insensitive`() { + val sections = sectionGeohashPeople( + people = listOf(person("ABCDEF")), + myId = null, + selfIsTeleported = false, + teleportedIds = setOf("abcdef") + ) + + assertTrue(sections.onLocation.isEmpty()) + assertEquals(listOf("ABCDEF"), sections.teleportedIn.map { it.id }) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt index 75fb6c01..607aaf1e 100644 --- a/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt @@ -25,7 +25,7 @@ class MentionSuggestionsTest { listOf("alice#1234", "anon7674#df5b", "anonracer#04af", "anonymous"), suggestions ) - assertTrue(suggestions.none(::isUnannouncedMentionNickname)) + assertTrue(suggestions.none(::isUnannouncedNickname)) } @Test @@ -40,11 +40,11 @@ class MentionSuggestionsTest { @Test fun `announced names beginning with anon stay mentionable`() { - assertTrue(isUnannouncedMentionNickname("anon")) - assertTrue(isUnannouncedMentionNickname("anon#04af")) - assertFalse(isUnannouncedMentionNickname("anon1234#04af")) - assertFalse(isUnannouncedMentionNickname("anonymous#04af")) - assertFalse(isUnannouncedMentionNickname("anonracer")) + assertTrue(isUnannouncedNickname("anon")) + assertTrue(isUnannouncedNickname("anon#04af")) + assertFalse(isUnannouncedNickname("anon1234#04af")) + assertFalse(isUnannouncedNickname("anonymous#04af")) + assertFalse(isUnannouncedNickname("anonracer")) } @Test From 25e9779f7357edc7b2224fd92b4eafdbb3ed14d0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:06:22 +0200 Subject: [PATCH 33/70] colors --- .../core/ui/component/button/CloseButton.kt | 6 +- .../com/bitchat/android/ui/AboutSections.kt | 36 +++--- .../java/com/bitchat/android/ui/AboutSheet.kt | 48 ++++---- .../java/com/bitchat/android/ui/ChatHeader.kt | 24 ++-- .../java/com/bitchat/android/ui/ChatScreen.kt | 17 +-- .../com/bitchat/android/ui/ChatUIUtils.kt | 105 +++++------------- .../com/bitchat/android/ui/ChatUserSheet.kt | 14 +-- .../com/bitchat/android/ui/ChatViewModel.kt | 11 +- .../bitchat/android/ui/GeohashPeopleList.kt | 13 ++- .../android/ui/GeohashPickerActivity.kt | 8 +- .../bitchat/android/ui/GeohashViewModel.kt | 6 +- .../com/bitchat/android/ui/InputComponents.kt | 35 +++--- .../com/bitchat/android/ui/LinkPreviewPill.kt | 11 +- .../android/ui/LocationChannelsSheet.kt | 90 +++++++++------ .../bitchat/android/ui/LocationNotesButton.kt | 4 +- .../bitchat/android/ui/LocationNotesSheet.kt | 12 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 25 +++-- .../bitchat/android/ui/MessageComponents.kt | 31 ++++-- .../com/bitchat/android/ui/PeerColorSeed.kt | 33 ++++++ .../android/ui/SecurityVerificationSheet.kt | 7 +- .../bitchat/android/ui/VerificationSheet.kt | 4 +- .../android/ui/media/AudioMessageItem.kt | 3 +- .../android/ui/media/ImageMessageItem.kt | 3 +- .../android/ui/theme/BitchatPalette.kt | 81 +++----------- .../bitchat/android/ui/theme/PeerColors.kt | 30 +++++ .../com/bitchat/android/ui/theme/Theme.kt | 67 ++++++----- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 72 +++++++++--- 27 files changed, 415 insertions(+), 381 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt index 091f93fc..9c60123f 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt @@ -6,26 +6,26 @@ 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 -import com.bitchat.android.ui.theme.LocalBitchatPalette @Composable fun CloseButton( onClick: () -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme IconButton( onClick = onClick, // 44.dp to match every other tap target in the app's chrome. modifier = modifier.size(44.dp), colors = IconButtonDefaults.iconButtonColors( - contentColor = palette.accentGreen, + contentColor = colorScheme.primary, containerColor = Color.Transparent ) ) { diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index e6da1eee..e36ee7be 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -120,7 +120,6 @@ internal fun SheetIconSectionHeader( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current Column( modifier = modifier @@ -152,7 +151,7 @@ internal fun SheetIconSectionHeader( fontSize = 12.sp, lineHeight = 17.sp, fontFamily = BitchatFontFamily, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } } @@ -161,11 +160,11 @@ internal fun SheetIconSectionHeader( /** Inset divider used inside grouped sheet cards (aligns with text column after the leading slot). */ @Composable internal fun SheetCardDivider() { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme HorizontalDivider( modifier = Modifier.padding(start = SheetRowDividerInset), thickness = 1.dp, - color = palette.outlineVariant + color = colorScheme.outlineVariant ) } @@ -212,7 +211,7 @@ internal fun AboutHero( text = stringResource(R.string.about_tagline), fontFamily = BitchatFontFamily, fontSize = 16.sp, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(6.dp)) @@ -238,7 +237,6 @@ internal fun AboutTabBar( onSelect: (AboutTab) -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme val density = LocalDensity.current @@ -274,7 +272,7 @@ internal fun AboutTabBar( } Box(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) Box( modifier = Modifier // Lambda overload: the offset is animated every frame, and the non-lambda @@ -295,11 +293,10 @@ private fun AboutTabLabel( onClick: () -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme val color by animateColorAsState( - targetValue = if (isSelected) colorScheme.primary else palette.textSecondary, + targetValue = if (isSelected) colorScheme.primary else colorScheme.onSurfaceVariant, animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), label = "aboutTabLabelColor" ) @@ -328,7 +325,6 @@ private fun AboutInstructionRow( text: String ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current Row( modifier = Modifier @@ -350,7 +346,7 @@ private fun AboutInstructionRow( fontFamily = BitchatFontFamily, fontSize = 14.sp, lineHeight = 20.sp, - color = palette.textPrimary + color = colorScheme.onSurface ) } } @@ -453,7 +449,6 @@ private fun AboutFeatureRow( subtitle: String ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current Row( modifier = Modifier @@ -477,14 +472,14 @@ private fun AboutFeatureRow( fontSize = 14.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp, - color = palette.textPrimary + color = colorScheme.onSurface ) Text( text = subtitle, fontFamily = BitchatFontFamily, fontSize = 12.sp, lineHeight = 17.sp, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } } @@ -498,10 +493,10 @@ internal fun BitchatBadge( text: String, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme Box( modifier = modifier - .background(palette.accentGreen.copy(alpha = 0.15f), RoundedCornerShape(4.dp)) + .background(colorScheme.primary.copy(alpha = 0.15f), RoundedCornerShape(4.dp)) .padding(horizontal = 5.dp, vertical = 2.dp) ) { Text( @@ -510,7 +505,7 @@ internal fun BitchatBadge( fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.5.sp, - color = palette.accentGreen + color = colorScheme.primary ) } } @@ -556,11 +551,10 @@ internal fun SheetHeaderBadge( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current Box( modifier = modifier .size(44.dp) - .background(palette.surface, androidx.compose.foundation.shape.CircleShape), + .background(colorScheme.surface, androidx.compose.foundation.shape.CircleShape), contentAlignment = Alignment.Center ) { Icon( @@ -585,8 +579,8 @@ internal fun SheetDestructiveButton( modifier: Modifier = Modifier, isDestructive: Boolean = true ) { - val palette = LocalBitchatPalette.current - val accent = if (isDestructive) palette.accentRed else palette.accentGreen + val colorScheme = MaterialTheme.colorScheme + val accent = if (isDestructive) colorScheme.error else colorScheme.primary Surface( onClick = onClick, diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 907f7673..67065182 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -82,17 +82,17 @@ private fun ThemeChip( onClick: () -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme // 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) palette.accentGreen else palette.surfaceVariant, + 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 palette.textSecondary, + targetValue = if (selected) Color.White else colorScheme.onSurfaceVariant, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "themeChipLabel" ) @@ -146,12 +146,12 @@ private fun SettingsToggleRow( label = "settingsRowIcon" ) val titleColor by animateColorAsState( - targetValue = if (enabled) palette.textPrimary else palette.textTertiary, + targetValue = if (enabled) colorScheme.onSurface else palette.textTertiary, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "settingsRowTitle" ) val subtitleColor by animateColorAsState( - targetValue = if (enabled) palette.textSecondary else palette.textTertiary, + targetValue = if (enabled) colorScheme.onSurfaceVariant else palette.textTertiary, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "settingsRowSubtitle" ) @@ -213,9 +213,9 @@ private fun SettingsToggleRow( interactionSource = interactionSource, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, - checkedTrackColor = palette.accentGreen, + checkedTrackColor = colorScheme.primary, uncheckedThumbColor = Color.White, - uncheckedTrackColor = palette.surfaceVariant + uncheckedTrackColor = colorScheme.surfaceVariant ) ) } @@ -310,7 +310,7 @@ fun AboutSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Row( @@ -359,7 +359,7 @@ fun AboutSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column { @@ -383,7 +383,7 @@ fun AboutSheet( HorizontalDivider( modifier = Modifier.padding(start = 54.dp), thickness = 1.dp, - color = palette.outlineVariant + color = colorScheme.outlineVariant ) // Proof of Work Toggle @@ -398,7 +398,7 @@ fun AboutSheet( HorizontalDivider( modifier = Modifier.padding(start = 54.dp), thickness = 1.dp, - color = palette.outlineVariant + color = colorScheme.outlineVariant ) // Tor Toggle @@ -417,9 +417,9 @@ fun AboutSheet( statusIndicator = if (torMode.value == TorMode.ON) { { val statusColor = when { - torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen + torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary torStatus.running -> palette.accentOrange - else -> palette.accentRed + else -> colorScheme.error } Surface( color = statusColor, @@ -811,7 +811,7 @@ fun AboutSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column( @@ -828,7 +828,7 @@ fun AboutSheet( fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = palette.textPrimary + color = colorScheme.onSurface ) AnimatedCountLabel( count = powDifficulty, @@ -839,7 +839,7 @@ fun AboutSheet( ), fontFamily = BitchatFontFamily, fontSize = 12.sp, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } @@ -849,9 +849,9 @@ fun AboutSheet( valueRange = 0f..32f, steps = 31, colors = SliderDefaults.colors( - thumbColor = palette.accentGreen, - activeTrackColor = palette.accentGreen, - inactiveTrackColor = palette.surfaceVariant + thumbColor = colorScheme.primary, + activeTrackColor = colorScheme.primary, + inactiveTrackColor = colorScheme.surfaceVariant ) ) @@ -888,7 +888,7 @@ fun AboutSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column( @@ -900,9 +900,9 @@ fun AboutSheet( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { val statusColor = when { - torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen + torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary torStatus.running -> palette.accentOrange - else -> palette.accentRed + else -> colorScheme.error } Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {} Text( @@ -914,7 +914,7 @@ fun AboutSheet( fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = palette.textPrimary + color = colorScheme.onSurface ) } if (torStatus.lastLogLine.isNotEmpty()) { @@ -974,7 +974,7 @@ fun AboutSheet( .align(Alignment.TopCenter) .fillMaxWidth() .height(64.dp) - .background(palette.background.copy(alpha = topBarAlpha)) + .background(colorScheme.background.copy(alpha = topBarAlpha)) ) { val dismiss = LocalSheetDismiss.current CloseButton( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index f1b2fbe2..8993b3ea 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -119,11 +119,12 @@ internal data class TorConnectionVisual( @Composable 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, palette.accentRed, 0.30f) + val mutedFailed = lerp(normal, colorScheme.error, 0.30f) val target = when { torStatus.mode == TorMode.OFF -> TorConnectionVisual(normal, isProgress = false) @@ -268,18 +269,19 @@ fun NoiseSessionIcon( modifier: Modifier = Modifier ) { 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 (icon, color, contentDescription) = when (sessionState) { "uninitialized" -> Triple( Icons.Outlined.NoEncryption, - palette.textSecondary, + colorScheme.onSurfaceVariant, stringResource(R.string.cd_ready_for_handshake) ) "handshaking" -> Triple( Icons.Outlined.Sync, - palette.textSecondary, + colorScheme.onSurfaceVariant, stringResource(R.string.cd_handshake_in_progress) ) "established" -> Triple( @@ -290,7 +292,7 @@ fun NoiseSessionIcon( else -> { // "failed" or any other state Triple( Icons.Outlined.Warning, - palette.accentRed, + colorScheme.error, stringResource(R.string.cd_handshake_failed) ) } @@ -365,19 +367,20 @@ fun PeerCounter( 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 - Pair(count, if (count > 0) palette.accentGreen else palette.textTertiary) + 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 - Pair(count, if (isConnected && count > 0) palette.accentBlue else palette.textTertiary) + Pair(count, if (isConnected && count > 0) colorScheme.secondary else palette.textTertiary) } } @@ -424,7 +427,7 @@ fun PeerCounter( prefix = stringResource(R.string.channel_count_prefix), style = MaterialTheme.typography.bodyMedium, fontSize = HeaderTextSize, - color = if (isConnected) palette.accentGreen else palette.accentRed, + color = if (isConnected) colorScheme.primary else colorScheme.error, fontWeight = FontWeight.Medium ) } @@ -482,7 +485,6 @@ private fun ChannelHeader( onSidebarClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current Box(modifier = Modifier.fillMaxWidth()) { // Back: a chevron alone is unambiguous here and buys back ~40.dp of title space that @@ -520,7 +522,7 @@ private fun ChannelHeader( text = stringResource(R.string.chat_leave), style = MaterialTheme.typography.labelMedium, fontSize = 15.sp, - color = palette.accentRed, + color = colorScheme.error, modifier = Modifier .align(Alignment.CenterEnd) .clip(HeaderClusterShape) @@ -667,7 +669,7 @@ private fun LocationChannelsButton( viewModel: ChatViewModel, onClick: () -> Unit ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme // Get current channel selection from location manager val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() @@ -680,7 +682,7 @@ private fun LocationChannelsButton( // so it is plain "mesh". else -> stringResource(R.string.mesh_label) } - val channelColor = if (isLocation) palette.accentGreen else palette.accentBlue + 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) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index ec3c5d4f..60fd7d4a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -46,7 +46,6 @@ 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 -import com.bitchat.android.ui.theme.LocalBitchatPalette /** * Main ChatScreen - REFACTORED to use component-based architecture @@ -61,7 +60,6 @@ import com.bitchat.android.ui.theme.LocalBitchatPalette @Composable fun ChatScreen(viewModel: ChatViewModel) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current val messages by viewModel.messages.collectAsStateWithLifecycle() val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() val nickname by viewModel.nickname.collectAsStateWithLifecycle() @@ -415,17 +413,17 @@ fun ChatScreen(viewModel: ChatViewModel) { ) { Surface( shape = CircleShape, - color = palette.surface, + color = colorScheme.surface, tonalElevation = 3.dp, shadowElevation = 6.dp, - border = BorderStroke(1.dp, palette.accentGreen) + 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), modifier = Modifier.size(22.dp), - tint = palette.accentGreen + tint = colorScheme.primary ) } } @@ -569,8 +567,6 @@ fun ChatInputSection( showMediaButtons: Boolean, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current - 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 @@ -581,7 +577,7 @@ fun ChatInputSection( .background(colorScheme.background.copy(alpha = BarBackgroundAlpha)) ) { // Hairline marking where chrome begins. Faint on purpose — it is a hint, not a border. - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) // Command suggestions box if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { @@ -590,7 +586,7 @@ fun ChatInputSection( onSuggestionClick = onCommandSuggestionClick, modifier = Modifier.fillMaxWidth() ) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + 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 @@ -631,7 +627,7 @@ fun ChatInputSection( onSuggestionClick = onMentionSuggestionClick, modifier = Modifier.fillMaxWidth() ) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) } } MessageInput( @@ -682,7 +678,6 @@ private fun ChatFloatingHeader( ) { val context = androidx.compose.ui.platform.LocalContext.current val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) } - val palette = LocalBitchatPalette.current Box( modifier = Modifier diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 05425f60..14676d07 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -10,6 +10,7 @@ import com.bitchat.android.model.BitchatMessage 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.colorForPeerSeed import java.text.SimpleDateFormat import java.util.* @@ -57,7 +58,11 @@ fun formatTextMessageSender( ): AnnotatedString { val builder = AnnotatedString.Builder() val isSelf = message.isFromSelf(currentUserNickname, myPeerID) - val senderColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark) + val senderColor = if (isSelf) { + palette.accentOrange + } else { + colorForPeerSeed(peerColorSeedForMessage(message), palette) + } val senderWeight = FontWeight.SemiBold val (baseName, suffix) = splitSuffix(message.sender) @@ -161,12 +166,12 @@ private fun appendBodyTimestamp( private fun appendMutedTimestamp( builder: AnnotatedString.Builder, message: BitchatMessage, - palette: BitchatPalette, + contentColor: Color, timeFormatter: SimpleDateFormat, ) { builder.pushStyle( SpanStyle( - color = palette.textPrimary.copy(alpha = ChatVisualTokens.MutedTextAlpha), + color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha), fontSize = ChatVisualTokens.SystemTimeFontSize, fontWeight = FontWeight.Normal, ) @@ -186,6 +191,8 @@ fun formatTextMessageBody( message: BitchatMessage, currentUserNickname: String, palette: BitchatPalette, + contentColor: Color, + linkColor: Color, timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), includeTimestamp: Boolean = true ): AnnotatedString { @@ -196,6 +203,8 @@ fun formatTextMessageBody( content = message.content, currentUserNickname = currentUserNickname, palette = palette, + contentColor = contentColor, + linkColor = linkColor, ) if (includeTimestamp) { @@ -212,13 +221,13 @@ fun formatTextMessageBody( */ fun formatSystemMessage( message: BitchatMessage, - palette: BitchatPalette, + contentColor: Color, timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) ): AnnotatedString { val builder = AnnotatedString.Builder() builder.pushStyle( SpanStyle( - color = palette.textPrimary.copy(alpha = ChatVisualTokens.MutedTextAlpha), + color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha), fontSize = ChatVisualTokens.SystemActionFontSize, fontWeight = FontWeight.Medium, ) @@ -227,7 +236,7 @@ fun formatSystemMessage( builder.append(message.content) builder.pop() - appendMutedTimestamp(builder, message, palette, timeFormatter) + appendMutedTimestamp(builder, message, contentColor, timeFormatter) return builder.toAnnotatedString() } @@ -243,6 +252,7 @@ fun formatMessageHeaderAnnotatedString( currentUserNickname: String, myPeerID: String, palette: BitchatPalette, + contentColor: Color, timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), includeSender: Boolean = true ): AnnotatedString { @@ -250,11 +260,15 @@ fun formatMessageHeaderAnnotatedString( val isSelf = message.isFromSelf(currentUserNickname, myPeerID) if (message.sender == "system") { - return formatSystemMessage(message, palette, timeFormatter) + return formatSystemMessage(message, contentColor, timeFormatter) } if (includeSender) { - val baseColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark) + val baseColor = if (isSelf) { + palette.accentOrange + } else { + colorForPeerSeed(peerColorSeedForMessage(message), palette) + } val (baseName, suffix) = splitSuffix(message.sender) builder.pushStyle( @@ -291,71 +305,10 @@ fun formatMessageHeaderAnnotatedString( } } - appendMutedTimestamp(builder, message, palette, timeFormatter) + 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. - * - * The hash and hue derivation are byte-identical to the iOS implementation, so a given peer - * resolves to the same hue on both platforms. Saturation and value are deliberately higher - * than iOS: the redesigned chat surface renders message bodies in neutral near-white, so - * nicknames need more chroma to stay distinguishable at a glance. - */ -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) 1.0 else 0.85 - val brightness = if (isDark) 1.0 else 0.45 - - 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) */ @@ -387,7 +340,7 @@ internal fun isUnannouncedNickname(displayName: String): Boolean { * iOS-style content formatting with proper hashtag and mention handling. * * Redesign notes: - * - Plain text renders in [BitchatPalette.textPrimary]; colour is reserved for `@mentions`, + * - 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 @@ -401,10 +354,9 @@ private fun appendIOSFormattedContent( content: String, currentUserNickname: String, palette: BitchatPalette, + contentColor: Color, + linkColor: Color, ) { - val contentColor = palette.textPrimary - val linkColor = palette.accentBlue - // 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() @@ -511,7 +463,10 @@ private fun appendIOSFormattedContent( palette.accentOrange } else { // Tint by the *mentioned* peer so a given name looks identical everywhere. - colorForPeerSeed(mentionWithoutAt.lowercase(), palette.isDark) + colorForPeerSeed( + PeerColorSeed(mentionWithoutAt.lowercase(Locale.ROOT)), + palette + ) } val chipAlpha = if (isMentionToMe) MENTION_CHIP_ALPHA_SELF else MENTION_CHIP_ALPHA val mentionWeight = if (isMentionToMe) FontWeight.Bold else FontWeight.SemiBold diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt index b6bf7606..f0647e6c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt @@ -11,6 +11,7 @@ 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( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 4eb32739..4c6d5cdc 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1195,17 +1195,12 @@ class ChatViewModel( /** * Get consistent color for a mesh peer by ID (iOS-compatible) */ - 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 peerColorSeedForMeshPeer(peerID: String): PeerColorSeed = meshPeerColorSeed(peerID) /** * Get consistent color for a Nostr pubkey (iOS-compatible) */ - fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark) -} + fun peerColorSeedForNostrPubkey(pubkeyHex: String): PeerColorSeed = + geohashViewModel.peerColorSeedForNostrPubkey(pubkeyHex) } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 7610fedf..8f65bbb1 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -17,6 +17,7 @@ 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.colorForPeerSeed import com.bitchat.android.R import com.bitchat.android.ui.theme.LocalBitchatPalette import java.util.* @@ -45,6 +46,7 @@ fun GeohashPeopleList( val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme val myHex = remember(selectedLocationChannel) { when (val channel = selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> { @@ -110,7 +112,7 @@ fun GeohashPeopleList( .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) .padding(top = 10.dp), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Text( @@ -212,14 +214,14 @@ private fun PeopleCard( people: List, row: @Composable (GeoPerson) -> Unit ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme Surface( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) .padding(top = 10.dp), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { AnimatedRowColumn(items = people, key = { it.id }) { index, person -> @@ -250,7 +252,10 @@ private fun GeohashPersonItem( val (baseNameRaw, suffixRaw) = splitSuffix(person.displayName) val baseName = truncateNickname(baseNameRaw) val suffix = if (showHashSuffix) suffixRaw else "" - val assignedColor = viewModel.colorForNostrPubkey(person.id, palette.isDark) + val assignedColor = colorForPeerSeed( + viewModel.peerColorSeedForNostrPubkey(person.id), + palette + ) val baseColor = if (isMe) palette.accentOrange else assignedColor Row( diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt index bece684a..9565c384 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -30,10 +30,10 @@ 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.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 @@ -87,15 +87,13 @@ class GeohashPickerActivity : OrientationAwareActivity() { val initialPrecision = geohashToFocus?.length ?: 5 setContent { - MaterialTheme { + BitchatTheme { var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") } var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) } var webViewRef by remember { mutableStateOf(null) } - // iOS system-like colors used across app val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + val standardGreen = colorScheme.primary Scaffold { padding -> Box(Modifier.fillMaxSize()) { diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 4cae30dd..d94e5fff 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -332,10 +332,8 @@ class GeohashViewModel( 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 peerColorSeedForNostrPubkey(pubkeyHex: String): PeerColorSeed = + nostrPeerColorSeed(pubkeyHex) private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { geoTimer?.cancel(); geoTimer = null diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 07f633dc..a732b8e8 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -257,7 +257,8 @@ internal fun ComposerActionSurface( content: @Composable (tint: Color) -> Unit ) { val palette = LocalBitchatPalette.current - val accent = if (activeColor == Color.Unspecified) palette.accentGreen else activeColor + val colorScheme = MaterialTheme.colorScheme + val accent = if (activeColor == Color.Unspecified) colorScheme.primary else activeColor val container by animateColorAsState( // A tint rather than a fill. A solid accent disc next to the text you are typing was the @@ -267,7 +268,7 @@ internal fun ComposerActionSurface( label = "composerButtonContainer" ) val tint by animateColorAsState( - targetValue = if (isActive) accent else palette.textSecondary, + targetValue = if (isActive) accent else colorScheme.onSurfaceVariant, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "composerButtonTint" ) @@ -314,6 +315,7 @@ fun MessageInput( modifier: Modifier = Modifier ) { val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme val isFocused = remember { mutableStateOf(false) } val hasText = value.text.isNotBlank() val focusRequester = remember { FocusRequester() } @@ -324,7 +326,7 @@ fun MessageInput( // Recording is the one state worth shouting about, so it overrides focus. val borderColor by animateColorAsState( targetValue = when { - isRecording -> palette.accentRed.copy(alpha = 0.7f) + isRecording -> colorScheme.error.copy(alpha = 0.7f) isFocused.value -> palette.inputOutlineFocused else -> palette.inputOutline }, @@ -371,11 +373,11 @@ fun MessageInput( // user is composing rather than reading, and green-on-black is tiring to // type into. textStyle = MaterialTheme.typography.bodyMedium.copy( - color = palette.textPrimary, + color = colorScheme.onSurface, fontFamily = BitchatFontFamily ), cursorBrush = SolidColor( - if (isRecording) Color.Transparent else palette.textPrimary + if (isRecording) Color.Transparent else colorScheme.onSurface ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), keyboardActions = KeyboardActions(onSend = { @@ -387,8 +389,8 @@ fun MessageInput( CombinedVisualTransformation( listOf( SlashCommandVisualTransformation( - commandColor = palette.accentGreen, - commandBackground = palette.accentGreen.copy(alpha = 0.14f), + commandColor = colorScheme.primary, + commandBackground = colorScheme.primary.copy(alpha = 0.14f), ), MentionVisualTransformation( mentionColor = palette.accentOrange, @@ -452,7 +454,7 @@ fun MessageInput( secs / 60, secs % 60, maxSecs / 60, maxSecs % 60 ), fontFamily = BitchatFontFamily, - color = palette.accentRed, + color = colorScheme.error, fontSize = (BASE_FONT_SIZE - 4).sp ) } @@ -595,6 +597,7 @@ private fun SendButton( enabled: Boolean = true ) { val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() @@ -602,7 +605,7 @@ private fun SendButton( isActive = enabled, isPressed = isPressed, // Private chats and channels keep their orange identity, disc and glyph together. - activeColor = if (isAccented) palette.accentOrange else palette.accentGreen, + activeColor = if (isAccented) palette.accentOrange else colorScheme.primary, modifier = modifier.clickable( interactionSource = interactionSource, indication = null, @@ -624,13 +627,13 @@ fun CommandSuggestionsBox( onSuggestionClick: (CommandSuggestion) -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme Column( modifier = modifier .verticalScroll(rememberScrollState()) - .background(palette.surface) - .border(1.dp, palette.outlineVariant, RoundedCornerShape(8.dp)) + .background(colorScheme.surface) + .border(1.dp, colorScheme.outlineVariant, RoundedCornerShape(8.dp)) .padding(vertical = 6.dp) ) { suggestions.forEach { suggestion: CommandSuggestion -> @@ -683,7 +686,7 @@ fun CommandSuggestionItem( style = MaterialTheme.typography.bodySmall.copy( fontFamily = BitchatFontFamily ), - color = palette.textSecondary, + color = colorScheme.onSurfaceVariant, fontSize = (BASE_FONT_SIZE - 4).sp ) } @@ -709,7 +712,7 @@ fun MentionSuggestionsBox( onSuggestionClick: (String) -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme LazyColumn( modifier = modifier @@ -721,8 +724,8 @@ fun MentionSuggestionsBox( ) ) .clip(MentionSuggestionsShape) - .background(palette.surface) - .border(1.dp, palette.outlineVariant, MentionSuggestionsShape), + .background(colorScheme.surface) + .border(1.dp, colorScheme.outlineVariant, MentionSuggestionsShape), contentPadding = PaddingValues(vertical = MentionSuggestionsVerticalPadding) ) { items( diff --git a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt index 9b34285c..57710171 100644 --- a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt +++ b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt @@ -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 ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 7581dd46..ca5fd26e 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -40,6 +42,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -71,7 +74,9 @@ import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.wifiaware.WifiAwareController import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull /** * Leading column width matching settings rows: 22.dp glyph + 16.dp gutter before title text. @@ -152,8 +157,8 @@ fun LocationChannelsSheet( val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current - val standardGreen = palette.accentGreen - val standardBlue = palette.accentBlue + val standardGreen = colorScheme.primary + val standardBlue = colorScheme.secondary val nearbyChannels = remember(availableChannels) { availableChannels.filter { it.level != GeohashChannelLevel.BUILDING } @@ -185,7 +190,12 @@ fun LocationChannelsSheet( Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( state = listState, - modifier = Modifier.fillMaxSize(), + // Edge-to-edge + adjustResize reports the keyboard as WindowInsets.ime — + // without consuming it here the list stays full-height and the teleport + // field can't scroll above the keyboard. + modifier = Modifier + .fillMaxSize() + .imePadding(), contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(0.dp) ) { @@ -203,7 +213,7 @@ fun LocationChannelsSheet( .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) .padding(top = 10.dp), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { ChannelOptionRow( @@ -253,7 +263,7 @@ fun LocationChannelsSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { ChannelOptionRow( @@ -292,7 +302,7 @@ fun LocationChannelsSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column { @@ -353,7 +363,7 @@ fun LocationChannelsSheet( text = stringResource(R.string.location_permission_denied), fontSize = 12.sp, fontFamily = BitchatFontFamily, - color = palette.accentRed + color = colorScheme.error ) TextButton( onClick = { @@ -389,7 +399,7 @@ fun LocationChannelsSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column { @@ -443,12 +453,7 @@ fun LocationChannelsSheet( customError = null }, onFocusGained = { - coroutineScope.launch { - sheetState.expand() - listState.animateScrollToItem( - index = listState.layoutInfo.totalItemsCount - 1 - ) - } + coroutineScope.launch { sheetState.expand() } }, onOpenMap = { val normalized = customGeohash.trim().lowercase().replace("#", "") @@ -497,7 +502,7 @@ fun LocationChannelsSheet( text = shownError, fontSize = 12.sp, fontFamily = BitchatFontFamily, - color = palette.accentRed, + color = colorScheme.error, modifier = Modifier.padding( start = AboutHorizontalPadding + ChannelRowHorizontal, top = 8.dp @@ -518,7 +523,7 @@ fun LocationChannelsSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { ChannelSettingsToggleRow( @@ -638,7 +643,6 @@ private fun ChannelOptionRow( onClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current val (baseTitle, countSuffix) = splitTitleAndCount(title) Row( @@ -657,7 +661,7 @@ private fun ChannelOptionRow( Box( modifier = Modifier .size(ChannelSelectedDot) - .background(palette.accentGreen, CircleShape) + .background(colorScheme.primary, CircleShape) ) } leadingIcon != null -> { @@ -686,7 +690,7 @@ private fun ChannelOptionRow( fontSize = 14.sp, fontFamily = BitchatFontFamily, fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium, - color = titleColor ?: palette.textPrimary + color = titleColor ?: colorScheme.onSurface ) countSuffix?.let { count -> AnimatedCountLabel( @@ -694,7 +698,7 @@ private fun ChannelOptionRow( text = count, fontSize = 11.sp, fontFamily = BitchatFontFamily, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } } @@ -703,7 +707,7 @@ private fun ChannelOptionRow( fontSize = 12.sp, fontFamily = BitchatFontFamily, lineHeight = 17.sp, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } @@ -719,7 +723,7 @@ private fun ChannelBookmarkButton( bookmarked: Boolean, onClick: () -> Unit ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme Box( modifier = Modifier .size(36.dp) @@ -737,7 +741,7 @@ private fun ChannelBookmarkButton( contentDescription = stringResource( if (bookmarked) R.string.cd_remove_bookmark else R.string.cd_add_bookmark ), - tint = if (bookmarked) palette.accentGreen else palette.textSecondary, + tint = if (bookmarked) colorScheme.primary else colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp) ) } @@ -745,7 +749,7 @@ private fun ChannelBookmarkButton( @Composable private fun ChannelLoadingRow() { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme Row( modifier = Modifier .fillMaxWidth() @@ -760,14 +764,14 @@ private fun ChannelLoadingRow() { CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } Text( text = stringResource(R.string.finding_nearby_channels), fontSize = 12.sp, fontFamily = BitchatFontFamily, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } } @@ -785,6 +789,10 @@ private fun CustomGeohashRow( ) { val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current + val density = LocalDensity.current + val imeInsets = WindowInsets.ime + val coroutineScope = rememberCoroutineScope() + val bringIntoViewRequester = remember { BringIntoViewRequester() } val normalized = customGeohash.trim().lowercase().replace("#", "") val isValid = validateGeohash(normalized) // Typing the last character of a valid geohash arms the button; cross-fading both the label @@ -798,7 +806,7 @@ private fun CustomGeohashRow( targetValue = if (isValid) { colorScheme.primary.copy(alpha = 0.16f) } else { - palette.surfaceVariant + colorScheme.surfaceVariant }, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "teleportContainer" @@ -809,6 +817,7 @@ private fun CustomGeohashRow( Row( modifier = Modifier .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester) .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), verticalAlignment = Alignment.CenterVertically ) { @@ -847,7 +856,22 @@ private fun CustomGeohashRow( singleLine = true, modifier = Modifier .weight(1f) - .onFocusChanged { if (it.isFocused) onFocusGained() }, + .onFocusChanged { focusState -> + if (!focusState.isFocused) return@onFocusChanged + onFocusGained() + // Wait until IME insets have landed so the LazyColumn has shrunk; bringIntoView + // against the full-height viewport leaves the field tucked under the keyboard. + // Re-request once the IME animation settles — early insets are still growing. + coroutineScope.launch { + withTimeoutOrNull(750) { + snapshotFlow { imeInsets.getBottom(density) } + .first { it > 0 } + } + bringIntoViewRequester.bringIntoView() + delay(BitchatMotion.STANDARD_MS.toLong()) + bringIntoViewRequester.bringIntoView() + } + }, decorationBox = { inner -> if (customGeohash.isEmpty()) { Text( @@ -871,7 +895,7 @@ private fun CustomGeohashRow( Icon( imageVector = Icons.Filled.Map, contentDescription = stringResource(R.string.cd_open_map), - tint = palette.textSecondary, + tint = colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp) ) } @@ -937,7 +961,7 @@ private fun ChannelSettingsToggleRow( fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = if (enabled) palette.textPrimary else palette.textTertiary + color = if (enabled) colorScheme.onSurface else palette.textTertiary ) statusIndicator?.invoke() } @@ -946,7 +970,7 @@ private fun ChannelSettingsToggleRow( fontFamily = BitchatFontFamily, fontSize = 12.sp, lineHeight = 17.sp, - color = if (enabled) palette.textSecondary else palette.textTertiary + color = if (enabled) colorScheme.onSurfaceVariant else palette.textTertiary ) } Spacer(modifier = Modifier.width(16.dp)) @@ -956,9 +980,9 @@ private fun ChannelSettingsToggleRow( enabled = enabled, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, - checkedTrackColor = palette.accentGreen, + checkedTrackColor = colorScheme.primary, uncheckedThumbColor = Color.White, - uncheckedTrackColor = palette.surfaceVariant + uncheckedTrackColor = colorScheme.surfaceVariant ) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 70f98d5a..489ddaf4 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -21,7 +21,6 @@ import com.bitchat.android.R import com.bitchat.android.geohash.ChannelID import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.nostr.LocationNotesManager -import com.bitchat.android.ui.theme.LocalBitchatPalette /** * Location Notes button for MainHeader. @@ -35,7 +34,6 @@ fun LocationNotesButton( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current val context = LocalContext.current val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() @@ -54,7 +52,7 @@ fun LocationNotesButton( if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) { val hasNotes = notesCount > 0 val contentDescription = stringResource(R.string.cd_location_notes) - val normalTint = if (hasNotes) colorScheme.primary else palette.textSecondary + val normalTint = if (hasNotes) colorScheme.primary else colorScheme.onSurfaceVariant val torVisual = rememberTorConnectionVisual(normal = normalTint) Box( diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index c250f6a1..f3c49480 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -5,7 +5,6 @@ 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 @@ -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() } @@ -468,7 +465,6 @@ private fun LocationNotesInputSection( nickname: String?, onSend: () -> Unit ) { - val isDark = isSystemInDarkTheme() val colorScheme = MaterialTheme.colorScheme Column( @@ -556,10 +552,8 @@ private fun LocationNotesInputSection( modifier = Modifier.size(20.dp), tint = if (!sendButtonEnabled) { colorScheme.onSurface.copy(alpha = 0.5f) - } else if (isDark) { - Color.Black // Black arrow on green in dark theme } else { - Color.White // White arrow on green in light theme + colorScheme.onPrimary } ) } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 9e1639b0..4bb36ecc 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -47,6 +47,7 @@ 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.colorForPeerSeed import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.services.ContactDirectory @@ -132,7 +133,7 @@ fun MeshPeerListSheet( .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) .padding(top = 10.dp), - color = LocalBitchatPalette.current.surface, + color = MaterialTheme.colorScheme.surface, shape = AboutCardShape ) { Column { @@ -222,7 +223,7 @@ fun MeshPeerListSheet( Icon( imageVector = Icons.Outlined.QrCode, contentDescription = stringResource(R.string.verify_title), - tint = LocalBitchatPalette.current.textSecondary, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp) ) } @@ -265,7 +266,7 @@ private fun ChannelRow( Box( modifier = Modifier .size(SheetRowSelectedDot) - .background(palette.accentGreen, CircleShape) + .background(colorScheme.primary, CircleShape) ) } else if (unreadCount > 0) { UnreadBadge(count = unreadCount, colorScheme = colorScheme) @@ -286,7 +287,7 @@ private fun ChannelRow( text = channel, fontFamily = BitchatFontFamily, fontSize = 14.sp, - color = if (isSelected) colorScheme.primary else palette.textPrimary, + color = if (isSelected) colorScheme.primary else colorScheme.onSurface, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, modifier = Modifier.weight(1f), maxLines = 1, @@ -331,7 +332,7 @@ fun PeopleSection( .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) .padding(top = 10.dp), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column { @@ -588,7 +589,10 @@ private fun PeerItem( // Get consistent peer color (iOS-compatible) val palette = LocalBitchatPalette.current - val assignedColor = viewModel.colorForMeshPeer(peerID, palette.isDark) + val assignedColor = colorForPeerSeed( + viewModel.peerColorSeedForMeshPeer(peerID), + palette + ) val baseColor = if (isMe) palette.accentOrange else assignedColor Row( @@ -606,7 +610,7 @@ private fun PeerItem( Box( modifier = Modifier .size(SheetRowSelectedDot) - .background(palette.accentGreen, CircleShape) + .background(colorScheme.primary, CircleShape) ) } else if (hasUnreadDM) { Icon( @@ -642,7 +646,7 @@ private fun PeerItem( else -> "Routed" }, modifier = Modifier.size(PeerRowIconSize), - tint = palette.textSecondary + tint = colorScheme.onSurfaceVariant ) } } @@ -679,7 +683,7 @@ private fun PeerItem( imageVector = Icons.Filled.Verified, contentDescription = stringResource(R.string.verify_title), modifier = Modifier.size(16.dp), - tint = palette.accentGreen + tint = colorScheme.primary ) } } @@ -866,7 +870,6 @@ fun PrivateChatSheet( val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true ) - val palette = LocalBitchatPalette.current if (isPresented) { BitchatBottomSheet( @@ -879,7 +882,7 @@ fun PrivateChatSheet( ) { Spacer(modifier = Modifier.height(64.dp)) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) // Messages list var forceScrollToBottom by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index a0581a51..c8e05af0 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -498,6 +498,7 @@ fun MessageItem( currentUserNickname = currentUserNickname, myPeerID = meshService.myPeerID, palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, includeSender = showSender ) @@ -516,7 +517,7 @@ fun MessageItem( }, onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = BitchatFontFamily, - color = palette.textPrimary, + color = colorScheme.onSurface, ) // Try to load the file packet from the path @@ -564,14 +565,14 @@ fun MessageItem( .align(Alignment.TopEnd) .padding(4.dp) .size(22.dp) - .background(palette.surfaceVariant.copy(alpha = 0.85f), 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 = palette.textPrimary, + tint = colorScheme.onSurface, modifier = Modifier.size(14.dp) ) } @@ -591,10 +592,10 @@ fun MessageItem( if (message.sender == "system") { // Background narration: `// Tor started. Routing all chats…` - val annotatedText = remember(message, palette) { + val annotatedText = remember(message, colorScheme.onSurface) { formatSystemMessage( message = message, - palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter ) } @@ -614,7 +615,7 @@ fun MessageItem( softWrap = true, overflow = TextOverflow.Visible, style = ChatVisualTokens.SystemActionStyle.copy( - color = palette.textPrimary.copy(alpha = ChatVisualTokens.MutedTextAlpha), + color = colorScheme.onSurface.copy(alpha = ChatVisualTokens.MutedTextAlpha), ) ) } else { @@ -660,11 +661,20 @@ internal fun TextMessageLayout( } // 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, timeFormatter) { + val bodyText = remember( + displayMessage, + currentUserNickname, + palette, + colorScheme.onSurface, + colorScheme.secondary, + timeFormatter + ) { formatTextMessageBody( message = displayMessage, currentUserNickname = currentUserNickname, palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, ) } @@ -728,7 +738,7 @@ internal fun TextMessageLayout( fontFamily = BitchatFontFamily, softWrap = true, overflow = TextOverflow.Visible, - style = MessageBodyTextStyle.copy(color = palette.textPrimary), + style = MessageBodyTextStyle.copy(color = colorScheme.onSurface), ) } } @@ -736,7 +746,6 @@ internal fun TextMessageLayout( @Composable fun DeliveryStatusIcon(status: DeliveryStatus) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current // 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 @@ -758,9 +767,9 @@ fun DeliveryStatusIcon(status: DeliveryStatus) { DeliveryStatus.Delivered::class -> Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.8f), FontWeight.Normal) DeliveryStatus.Read::class -> - Triple(R.string.status_delivered, palette.accentBlue, FontWeight.Bold) + Triple(R.string.status_delivered, colorScheme.secondary, FontWeight.Bold) DeliveryStatus.Failed::class -> - Triple(R.string.status_failed, palette.accentRed, FontWeight.Normal) + 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) diff --git a/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt b/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt new file mode 100644 index 00000000..b6eac7cd --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt @@ -0,0 +1,33 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import java.util.Locale + +/** + * Stable, presentation-neutral identity used to derive a peer hue. + * + * ViewModels may expose this value, but only the UI theme resolves it to a rendered color. + */ +@JvmInline +value class PeerColorSeed(val value: String) + +fun meshPeerColorSeed(peerID: String): PeerColorSeed = + PeerColorSeed("noise:${peerID.lowercase(Locale.ROOT)}") + +fun nostrPeerColorSeed(pubkeyHex: String): PeerColorSeed = + PeerColorSeed("nostr:${pubkeyHex.lowercase(Locale.ROOT)}") + +fun peerColorSeedForMessage(message: BitchatMessage): PeerColorSeed { + val value = when { + message.senderPeerID?.startsWith("nostr:") == true || + message.senderPeerID?.startsWith("nostr_") == true -> { + "nostr:${message.senderPeerID.lowercase(Locale.ROOT)}" + } + message.senderPeerID?.length == 16 || message.senderPeerID?.length == 64 -> { + "noise:${message.senderPeerID.lowercase(Locale.ROOT)}" + } + else -> message.sender.lowercase(Locale.ROOT) + } + + return PeerColorSeed(value) +} diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index d4ba21fe..40da30be 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -10,7 +10,6 @@ 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 @@ -70,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( diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index 4fc5bc34..a84c3c94 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -19,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 @@ -99,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() diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 5627111d..c646d570 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -55,6 +55,7 @@ fun AudioMessageItem( currentUserNickname = currentUserNickname, myPeerID = meshService.myPeerID, palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, includeSender = showSender ) @@ -73,7 +74,7 @@ fun AudioMessageItem( }, onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = BitchatFontFamily, - color = palette.textPrimary, + color = colorScheme.onSurface, ) Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 30387944..5d9908fc 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -53,6 +53,7 @@ fun ImageMessageItem( currentUserNickname = currentUserNickname, myPeerID = meshService.myPeerID, palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, includeSender = showSender ) @@ -71,7 +72,7 @@ fun ImageMessageItem( }, onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = BitchatFontFamily, - color = palette.textPrimary, + color = colorScheme.onSurface, ) val context = LocalContext.current diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt index dab45ff5..96ef57d4 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -5,40 +5,18 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color /** - * Semantic design tokens for the bitchat redesign. + * Bitchat-specific color tokens that do not have a faithful Material 3 semantic role. * - * Material3's [androidx.compose.material3.ColorScheme] cannot express what this app needs: - * `onSurface` is the terminal green used for chrome, while message bodies must render in a - * neutral near-white. Rather than repurposing M3 slots (which would repaint ~40 unrelated - * files unpredictably), the redesign exposes an explicit palette through - * [LocalBitchatPalette]. - * - * Consumers should prefer these tokens over ad-hoc hex literals so accents stay consistent - * and light/dark pairs are defined in exactly one place. + * 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( - /** Authoritative dark-mode flag. Prefer this over summing background channels. */ - val isDark: Boolean, - - // MARK: - Surfaces - /** Screen, header and composer background. True black in dark mode. */ - val background: Color, - /** Sheets, cards and list rows: one step above [background]. */ - val surface: Color, - /** Composer pill fill, chips, selected rows: one step above [surface]. */ - val surfaceVariant: Color, - /** Borders, drawn at full opacity (no `.copy(alpha = ...)` needed). */ - val outline: Color, - /** Hairline dividers. */ - val outlineVariant: Color, - // MARK: - Form controls /** * Resting border for text inputs. Deliberately a neutral grey rather than the green-tinted - * [outline]: the composer is the one surface the user stares at while typing, and a green - * cast there made the whole bottom of the screen read as "terminal chrome" instead of a - * place to write. + * 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. */ @@ -54,72 +32,45 @@ data class BitchatPalette( /** Resting disc behind the composer's action glyphs. Neutral grey. */ val inputButton: Color, - // MARK: - Text - /** Message bodies and row titles. Neutral, not green. */ - val textPrimary: Color, - /** Subtitles, secondary metadata, system/action messages. */ - val textSecondary: Color, + // MARK: - Extra semantics /** Timestamps, placeholders, section labels, disabled states. */ val textTertiary: Color, - - // MARK: - Accents - /** Geohash channels, bookmark-on, connected/OK states. */ - val accentGreen: Color, - /** Mesh channel, links, geohash references. */ - val accentBlue: Color, /** Self, mentions targeting you, unread DMs. */ val accentOrange: Color, - /** Errors, warning card, destructive actions. */ - val accentRed: 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( - isDark = true, - // True black. OLED panels switch these pixels off entirely, which is both the terminal - // aesthetic the app is going for and a real battery win on a screen that is mostly - // background. - background = Color(0xFF000000), - surface = Color(0xFF0E150E), - surfaceVariant = Color(0xFF182118), - outline = Color(0xFF2A3A2A), - outlineVariant = Color(0xFF1C271C), inputOutline = Color(0xFF333635), inputOutlineFocused = Color(0xFF5A605D), inputSurface = Color(0xFF0B0B0B), inputSurfaceFocused = Color(0xFF151515), inputButton = Color(0xFF1E1E1E), - textPrimary = Color(0xFFF5F5F5), - textSecondary = Color(0xFF9AA69A), textTertiary = Color(0xFF6B776B), - accentGreen = Color(0xFF32D74B), - accentBlue = Color(0xFF0A84FF), accentOrange = Color(0xFFFF9F0A), - accentRed = Color(0xFFFF453A), accentPurple = Color(0xFFBF5AF2), + peerColorSaturation = 1f, + peerColorValue = 1f, ) val LightBitchatPalette = BitchatPalette( - isDark = false, - background = Color(0xFFFFFFFF), - surface = Color(0xFFF2F6F2), - surfaceVariant = Color(0xFFE7EDE7), - outline = Color(0xFFCBD6CB), - outlineVariant = Color(0xFFDEE6DE), inputOutline = Color(0xFFCFD3D1), inputOutlineFocused = Color(0xFF8E9490), inputSurface = Color(0xFFFAFAFA), inputSurfaceFocused = Color(0xFFF2F2F2), inputButton = Color(0xFFE8E8E8), - textPrimary = Color(0xFF131A13), - textSecondary = Color(0xFF4C574C), textTertiary = Color(0xFF757F75), - accentGreen = Color(0xFF248A3D), - accentBlue = Color(0xFF007AFF), accentOrange = Color(0xFFFF9500), - accentRed = Color(0xFFD70015), accentPurple = Color(0xFFAF52DE), + peerColorSaturation = 0.85f, + peerColorValue = 0.45f, ) val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt new file mode 100644 index 00000000..2a0e0a38 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt @@ -0,0 +1,30 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.ui.graphics.Color +import com.bitchat.android.ui.PeerColorSeed +import kotlin.math.abs + +/** + * Resolve a peer's stable seed using the active Bitchat theme's explicit chroma tokens. + * + * 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 colorForPeerSeed(seed: PeerColorSeed, palette: BitchatPalette): Color { + var hash = 5381UL + for (byte in seed.value.toByteArray()) { + hash = ((hash shl 5) + hash) + byte.toUByte().toULong() + } + + var hue = (hash % 360UL).toDouble() / 360.0 + val orange = 30.0 / 360.0 + if (abs(hue - orange) < 0.05) { + hue = (hue + 0.12) % 1.0 + } + + return Color.hsv( + hue = (hue * 360).toFloat(), + saturation = palette.peerColorSaturation, + value = palette.peerColorValue + ) +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt index 09d03959..f2c4b0b7 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt @@ -17,40 +17,51 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalView -// Colors that match the iOS bitchat theme. -// Surface/outline/error slots are derived from BitchatPalette so that screens which have not -// yet migrated to the explicit palette still pick up the redesigned tinted surfaces. -private val DarkColorScheme = darkColorScheme( - primary = Color(0xFF39FF14), // Bright green (terminal-like) +// Standard UI semantics live in Material so stock components and custom Bitchat composables +// share one source of truth. LocalBitchatPalette below only supplies app-specific extra colors. +internal val DarkBitchatColorScheme = darkColorScheme( + primary = Color(0xFF32D74B), onPrimary = Color.Black, - secondary = Color(0xFF2ECB10), // Darker green + primaryContainer = Color(0xFF163D1D), + onPrimaryContainer = Color(0xFFB8F5C1), + secondary = Color(0xFF0A84FF), onSecondary = Color.Black, - background = DarkBitchatPalette.background, - onBackground = Color(0xFF39FF14), // Green on near-black - surface = DarkBitchatPalette.surface, - onSurface = Color(0xFF39FF14), // Green text - surfaceVariant = DarkBitchatPalette.surfaceVariant, - onSurfaceVariant = DarkBitchatPalette.textSecondary, - outline = DarkBitchatPalette.outline, - outlineVariant = DarkBitchatPalette.outlineVariant, - error = DarkBitchatPalette.accentRed, + secondaryContainer = Color(0xFF082E54), + onSecondaryContainer = Color(0xFFC2E0FF), + tertiary = DarkBitchatPalette.accentOrange, + onTertiary = Color.Black, + background = Color(0xFF000000), + onBackground = Color(0xFFF5F5F5), + surface = Color(0xFF0E150E), + onSurface = Color(0xFFF5F5F5), + surfaceVariant = Color(0xFF182118), + onSurfaceVariant = Color(0xFF9AA69A), + outline = Color(0xFF2A3A2A), + outlineVariant = Color(0xFF1C271C), + error = Color(0xFFFF453A), onError = Color.Black ) -private val LightColorScheme = lightColorScheme( - primary = Color(0xFF008000), // Dark green +internal val LightBitchatColorScheme = lightColorScheme( + primary = Color(0xFF248A3D), onPrimary = Color.White, - secondary = Color(0xFF006600), // Even darker green + primaryContainer = Color(0xFFD5F1D8), + onPrimaryContainer = Color(0xFF0A3212), + secondary = Color(0xFF007AFF), onSecondary = Color.White, - background = LightBitchatPalette.background, - onBackground = Color(0xFF008000), // Dark green on off-white - surface = LightBitchatPalette.surface, - onSurface = Color(0xFF008000), // Dark green text - surfaceVariant = LightBitchatPalette.surfaceVariant, - onSurfaceVariant = LightBitchatPalette.textSecondary, - outline = LightBitchatPalette.outline, - outlineVariant = LightBitchatPalette.outlineVariant, - error = LightBitchatPalette.accentRed, + secondaryContainer = Color(0xFFD6E9FF), + onSecondaryContainer = Color(0xFF002C5C), + tertiary = LightBitchatPalette.accentOrange, + onTertiary = Color.Black, + background = Color(0xFFFFFFFF), + onBackground = Color(0xFF131A13), + surface = Color(0xFFF2F6F2), + onSurface = Color(0xFF131A13), + surfaceVariant = Color(0xFFE7EDE7), + onSurfaceVariant = Color(0xFF4C574C), + outline = Color(0xFFCBD6CB), + outlineVariant = Color(0xFFDEE6DE), + error = Color(0xFFD70015), onError = Color.White ) @@ -71,7 +82,7 @@ fun BitchatTheme( } } - val colorScheme = if (shouldUseDark) DarkColorScheme else LightColorScheme + val colorScheme = if (shouldUseDark) DarkBitchatColorScheme else LightBitchatColorScheme val palette = if (shouldUseDark) DarkBitchatPalette else LightBitchatPalette val view = LocalView.current diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index d58d3b4a..59f0eb73 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -7,16 +7,18 @@ import androidx.compose.ui.unit.sp import com.bitchat.android.model.BitchatMessage import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.ChatVisualTokens +import com.bitchat.android.ui.theme.DarkBitchatColorScheme import com.bitchat.android.ui.theme.DarkBitchatPalette +import com.bitchat.android.ui.theme.LightBitchatColorScheme import com.bitchat.android.ui.theme.LightBitchatPalette import com.bitchat.android.ui.theme.MessageBodyTextStyle import com.bitchat.android.ui.theme.MessageSenderTextStyle +import com.bitchat.android.ui.theme.colorForPeerSeed import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -33,6 +35,7 @@ class ChatUIUtilsTest { } private val palette = DarkBitchatPalette + private val colorScheme = DarkBitchatColorScheme private fun message( content: String, @@ -71,6 +74,8 @@ class ChatUIUtilsTest { message = message("hello there"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, ) @@ -89,6 +94,8 @@ class ChatUIUtilsTest { message = message("mined", powDifficulty = 8), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, ) @@ -106,6 +113,8 @@ class ChatUIUtilsTest { message = message("hello there"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) @@ -119,12 +128,14 @@ class ChatUIUtilsTest { message = message("plain words"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) val textStyle = body.spanStyles.first { it.start == 0 } - assertEquals(palette.textPrimary, textStyle.item.color) + assertEquals(colorScheme.onSurface, textStyle.item.color) } @Test @@ -146,6 +157,8 @@ class ChatUIUtilsTest { message = message("my own words", sender = "bob"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) @@ -170,6 +183,8 @@ class ChatUIUtilsTest { message = message("hey @carol#04af what's up"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) @@ -188,6 +203,8 @@ class ChatUIUtilsTest { message = message("ping @bob now"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) @@ -208,11 +225,13 @@ class ChatUIUtilsTest { message = message("cc @carol#04af"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) - val expected = colorForPeerSeed("carol#04af", palette.isDark) + val expected = colorForPeerSeed(PeerColorSeed("carol#04af"), palette) val chip = mentionChipSpans(body).single() assertEquals(expected.copy(alpha = MENTION_CHIP_ALPHA), chip.item.background) } @@ -223,6 +242,8 @@ class ChatUIUtilsTest { message = message("no mentions in here"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) @@ -236,7 +257,7 @@ class ChatUIUtilsTest { fun `system message uses a double-slash prefix and no brackets`() { val text = formatSystemMessage( message = message("Tor started. Routing all chats via Tor", sender = "system"), - palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, ).text @@ -249,7 +270,7 @@ class ChatUIUtilsTest { // messages despite being lower-priority narration. val annotated = formatSystemMessage( message = message("tor restarting", sender = "system"), - palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, ) @@ -260,7 +281,7 @@ class ChatUIUtilsTest { fun `system action and timestamp use their exported weights sizes and opacity`() { val annotated = formatSystemMessage( message = message("tor restarting", sender = "system"), - palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, ) @@ -273,10 +294,10 @@ class ChatUIUtilsTest { assertEquals(12.sp, action.fontSize) assertEquals(FontWeight.Medium, action.fontWeight) - assertEquals(palette.textPrimary.copy(alpha = 0.5f), action.color) + assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), action.color) assertEquals(10.sp, time.fontSize) assertEquals(FontWeight.Normal, time.fontWeight) - assertEquals(palette.textPrimary.copy(alpha = 0.5f), time.color) + assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), time.color) } // MARK: - Sender label @@ -327,12 +348,25 @@ class ChatUIUtilsTest { // MARK: - Peer colors + @Test + fun `peer seed factories normalize identities without resolving UI colors`() { + assertEquals( + PeerColorSeed("noise:abcdef"), + meshPeerColorSeed("ABCDEF") + ) + assertEquals( + PeerColorSeed("nostr:abcdef"), + nostrPeerColorSeed("ABCDEF") + ) + } + @Test fun `peer color hue is stable across light and dark, only chroma differs`() { // Hue derivation must stay byte-identical to iOS; only saturation/value are tuned for // the redesigned neutral message body. - val dark = colorForPeerSeed("noise:abc", isDark = true) - val light = colorForPeerSeed("noise:abc", isDark = false) + val seed = PeerColorSeed("noise:abc") + val dark = colorForPeerSeed(seed, DarkBitchatPalette) + val light = colorForPeerSeed(seed, LightBitchatPalette) val darkHsv = FloatArray(3) val lightHsv = FloatArray(3) @@ -342,13 +376,18 @@ class ChatUIUtilsTest { assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0) assertEquals(1.0, darkHsv[1].toDouble(), 0.01) assertEquals(1.0, darkHsv[2].toDouble(), 0.01) + assertEquals(0.85, lightHsv[1].toDouble(), 0.01) + assertEquals(0.45, lightHsv[2].toDouble(), 0.01) } @Test fun `peer color avoids the orange hue reserved for self`() { // Sweep a range of seeds; none may land within the reserved orange band. repeat(500) { i -> - val color = colorForPeerSeed("noise:seed$i", isDark = true) + val color = colorForPeerSeed( + PeerColorSeed("noise:seed$i"), + DarkBitchatPalette + ) val hsv = FloatArray(3) rgbToHsv(color.red, color.green, color.blue, hsv) val distanceFromOrange = kotlin.math.abs(hsv[0] - 30f) @@ -360,11 +399,12 @@ class ChatUIUtilsTest { } @Test - fun `light palette is not the dark palette`() { - assertNull(null) - assertEquals(Color(0xFFF5F5F5), DarkBitchatPalette.textPrimary) - assertTrue(LightBitchatPalette.textPrimary != DarkBitchatPalette.textPrimary) - assertTrue(LightBitchatPalette.isDark != DarkBitchatPalette.isDark) + fun `material owns standard text while Bitchat palette owns peer chroma`() { + assertEquals(Color(0xFFF5F5F5), DarkBitchatColorScheme.onSurface) + assertTrue(LightBitchatColorScheme.onSurface != DarkBitchatColorScheme.onSurface) + assertTrue( + LightBitchatPalette.peerColorValue != DarkBitchatPalette.peerColorValue + ) } private fun rgbToHsv(r: Float, g: Float, b: Float, out: FloatArray) { From adba24b5defece41120a0a600472d40f6d10ad31 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:05:43 +0200 Subject: [PATCH 34/70] test: add client rewrite contract suite (#779) --- .gitignore | 6 + .../ClientRewriteWireContractTest.kt | 301 +++++++ .../android/noise/NoiseExternalVectorTest.kt | 261 ++++++ .../NoiseSessionManagerIdentityBindingTest.kt | 60 ++ .../NostrRelayManagerLifecycleSmokeTest.kt | 51 ++ .../SystemStateManagerContractTest.kt | 87 ++ .../MeshDelegateHandlerStateContractTest.kt | 141 ++++ .../wifi-aware/SyncedSocketContractTest.kt | 151 ++++ build.gradle.kts | 6 + docs/client-rewrite-contracts.md | 63 ++ docs/device-transport-test-matrix.md | 113 +++ docs/release-gate-runbook.md | 244 ++++++ docs/test-implementation-plan.md | 779 ++++++++++++++++++ docs/testing-conventions.md | 90 ++ tools/__init__.py | 1 + tools/coverage/__init__.py | 1 + tools/coverage/check_changed_coverage.py | 154 ++++ tools/coverage/test_check_changed_coverage.py | 85 ++ tools/release_gate/__init__.py | 1 + tools/release_gate/android_lab.py | 209 +++++ tools/release_gate/device-matrix.example.json | 72 ++ tools/release_gate/release_gate.py | 744 +++++++++++++++++ tools/release_gate/scenarios.json | 251 ++++++ tools/release_gate/test_release_gate.py | 367 +++++++++ 24 files changed, 4238 insertions(+) create mode 100644 app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/noise/NoiseExternalVectorTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/onboarding/SystemStateManagerContractTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/wifi-aware/SyncedSocketContractTest.kt create mode 100644 docs/client-rewrite-contracts.md create mode 100644 docs/device-transport-test-matrix.md create mode 100644 docs/release-gate-runbook.md create mode 100644 docs/test-implementation-plan.md create mode 100644 docs/testing-conventions.md create mode 100644 tools/__init__.py create mode 100644 tools/coverage/__init__.py create mode 100644 tools/coverage/check_changed_coverage.py create mode 100644 tools/coverage/test_check_changed_coverage.py create mode 100644 tools/release_gate/__init__.py create mode 100644 tools/release_gate/android_lab.py create mode 100644 tools/release_gate/device-matrix.example.json create mode 100644 tools/release_gate/release_gate.py create mode 100644 tools/release_gate/scenarios.json create mode 100644 tools/release_gate/test_release_gate.py diff --git a/.gitignore b/.gitignore index 64ac199e..42117323 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ build/ !*/build/intermediates/ local.properties .gradle/ +.kotlin/ captures/ .externalNativeBuild/ debug_keystore/ @@ -40,6 +41,11 @@ dependency-reduced-pom.xml # Linters .lint/ +# Python test tooling +**/__pycache__/ +*.py[cod] +release-gate-results/ + # Other *.log .cxx/ diff --git a/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt b/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt new file mode 100644 index 00000000..d1381170 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt @@ -0,0 +1,301 @@ +package com.bitchat.android.contracts + +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.FragmentPayload +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.model.PrivateMessagePacket +import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.model.UnknownAnnouncementTLV +import com.bitchat.android.protocol.BinaryProtocol +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Date + +/** + * Golden wire vectors for formats that a from-scratch client must reproduce. + * + * These assertions deliberately compare literal bytes rather than relying only + * on encode/decode round trips, which can hide matching bugs in both methods. + */ +class ClientRewriteWireContractTest { + + @Test + fun `v1 packet matches canonical unpadded bytes`() { + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = hex("1011121314151617"), + recipientID = null, + timestamp = 0x0102030405060708uL, + payload = hex("aabbcc"), + signature = null, + ttl = 7u + ) + + val encoded = BinaryProtocol.encode(packet, padding = false) + + assertArrayEquals( + hex("01020701020304050607080000031011121314151617aabbcc"), + encoded + ) + assertEquals(packet, BinaryProtocol.decode(encoded!!)) + } + + @Test + fun `v2 routed signed packet matches canonical section order`() { + val signature = ByteArray(64) { 0x5a } + val packet = BitchatPacket( + version = 2u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hex("0102030405060708"), + recipientID = hex("1112131415161718"), + timestamp = 42uL, + payload = hex("dead"), + signature = signature, + ttl = 5u, + route = listOf( + hex("2122232425262728"), + hex("3132333435363738") + ) + ) + + val encoded = BinaryProtocol.encode(packet, padding = false)!! + val expectedPrefix = hex( + "021105000000000000002a0b00000002" + + "0102030405060708" + + "1112131415161718" + + "02" + + "2122232425262728" + + "3132333435363738" + + "dead" + ) + + assertArrayEquals(expectedPrefix + signature, encoded) + assertEquals(packet, BinaryProtocol.decode(encoded)) + } + + @Test + fun `minimal chat message matches canonical binary payload`() { + val message = BitchatMessage( + id = "id", + sender = "bob", + content = "hi", + timestamp = Date(0x0102030405060708L) + ) + + val encoded = message.toBinaryPayload() + + assertArrayEquals( + hex("00010203040506070802696403626f6200026869"), + encoded + ) + assertEquals(message, BitchatMessage.fromBinaryPayload(encoded!!)) + } + + @Test + fun `chat message optional fields use flags and UTF-8 byte lengths`() { + val message = BitchatMessage( + id = "m", + sender = "é", + content = "hello", + timestamp = Date(42L), + isRelay = true, + originalSender = "o", + isPrivate = true, + recipientNickname = "r", + senderPeerID = "p", + mentions = listOf("a", "β"), + channel = "c" + ) + + val encoded = message.toBinaryPayload()!! + + assertArrayEquals( + hex( + "7f000000000000002a" + + "016d" + + "02c3a9" + + "000568656c6c6f" + + "016f" + + "0172" + + "0170" + + "02" + + "0161" + + "02ceb2" + + "0163" + ), + encoded + ) + assertEquals(message, BitchatMessage.fromBinaryPayload(encoded)) + } + + @Test + fun `encrypted chat payload carries ciphertext instead of placeholder content`() { + val message = BitchatMessage( + id = "e", + sender = "alice", + content = "must-not-be-on-wire", + timestamp = Date(1L), + encryptedContent = hex("000102ff"), + isEncrypted = true, + isPrivate = true + ) + + val decoded = BitchatMessage.fromBinaryPayload(message.toBinaryPayload()!!)!! + + assertEquals("", decoded.content) + assertArrayEquals(hex("000102ff"), decoded.encryptedContent) + assertTrue(decoded.isEncrypted) + assertTrue(decoded.isPrivate) + assertFalse(message.toBinaryPayload()!!.toString(Charsets.ISO_8859_1).contains("must-not-be-on-wire")) + } + + @Test + fun `private message and Noise envelopes match deployed type bytes`() { + val privateMessage = PrivateMessagePacket(messageID = "m1", content = "hi") + val privateMessageBytes = hex("00026d3101026869") + + assertArrayEquals(privateMessageBytes, privateMessage.encode()) + assertEquals(privateMessage, PrivateMessagePacket.decode(privateMessageBytes)) + assertArrayEquals( + hex("0100026d3101026869"), + NoisePayload(NoisePayloadType.PRIVATE_MESSAGE, privateMessageBytes).encode() + ) + assertEquals( + NoisePayloadType.FILE_TRANSFER, + NoisePayload.decode(hex("09cafe"))?.type + ) + assertArrayEquals( + hex("20cafe"), + NoisePayload.decode(hex("09cafe"))!!.encode() + ) + } + + @Test + fun `fragment payload matches the thirteen byte iOS header`() { + val fragment = FragmentPayload( + fragmentID = hex("0001020304050607"), + index = 1, + total = 3, + originalType = MessageType.MESSAGE.value, + data = hex("aabb") + ) + val wire = hex("00010203040506070001000302aabb") + + assertArrayEquals(wire, fragment.encode()) + assertEquals(fragment, FragmentPayload.decode(wire)) + assertTrue(fragment.isValid()) + } + + @Test + fun `sync request matches canonical TLV bytes and skips extensions`() { + val request = RequestSyncPacket( + p = 19, + m = 0x01020304L, + data = hex("aabb") + ) + val wire = hex("0100011302000401020304030002aabb") + + assertArrayEquals(wire, request.encode()) + assertSyncRequestEquals(request, RequestSyncPacket.decode(wire)) + + val withExtension = hex("7f0002cafe") + wire + assertSyncRequestEquals(request, RequestSyncPacket.decode(withExtension)) + } + + @Test + fun `identity announcement matches canonical TLV order and preserves extensions`() { + val announcement = IdentityAnnouncement( + nickname = "bob", + noisePublicKey = ByteArray(32) { 0x11 }, + signingPublicKey = ByteArray(32) { 0x22 }, + capabilities = PeerCapabilities.PRIVATE_MEDIA, + unknownTLVs = listOf(UnknownAnnouncementTLV(0x7f, hex("cafe"))) + ) + val expected = + hex("0103626f620220") + + ByteArray(32) { 0x11 } + + hex("0320") + + ByteArray(32) { 0x22 } + + hex("050200017f02cafe") + + val encoded = announcement.encode() + + assertArrayEquals(expected, encoded) + assertEquals(announcement, IdentityAnnouncement.decode(encoded!!)) + } + + @Test + fun `file transfer matches deployed mixed-width TLV vector`() { + val packet = BitchatFilePacket( + fileName = "a", + fileSize = 2, + mimeType = "m", + content = hex("dead") + ) + val wire = hex("01000161020004000000020300016d0400000002dead") + + assertArrayEquals(wire, packet.encode()) + + val decoded = BitchatFilePacket.decode(wire) + assertNotNull(decoded) + assertEquals(packet.fileName, decoded!!.fileName) + assertEquals(packet.fileSize, decoded.fileSize) + assertEquals(packet.mimeType, decoded.mimeType) + assertArrayEquals(packet.content, decoded.content) + } + + @Test + fun `required message prefixes reject every truncation`() { + val wire = BitchatMessage( + id = "id", + sender = "bob", + content = "hello", + timestamp = Date(1L) + ).toBinaryPayload()!! + + for (length in 0 until wire.size) { + assertNull( + "Accepted required message prefix of $length/${wire.size} bytes", + BitchatMessage.fromBinaryPayload(wire.copyOf(length)) + ) + } + assertNotNull(BitchatMessage.fromBinaryPayload(wire)) + } + + @Test + fun `TLV decoders reject missing required fields and truncated values`() { + assertNull(PrivateMessagePacket.decode(hex("00026d31"))) + assertNull(PrivateMessagePacket.decode(hex("00026d3101036869"))) + assertNull(RequestSyncPacket.decode(hex("0100011302000401020304"))) + assertNull(IdentityAnnouncement.decode(hex("0103626f62022011"))) + assertNull(BitchatFilePacket.decode(hex("010001610400000002de"))) + assertNull(FragmentPayload.decode(ByteArray(FragmentPayload.HEADER_SIZE - 1))) + } + + private fun hex(value: String): ByteArray { + require(value.length % 2 == 0) + return value.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + + private fun assertSyncRequestEquals( + expected: RequestSyncPacket, + actual: RequestSyncPacket? + ) { + assertNotNull(actual) + assertEquals(expected.p, actual!!.p) + assertEquals(expected.m, actual.m) + assertArrayEquals(expected.data, actual.data) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/noise/NoiseExternalVectorTest.kt b/app/src/test/kotlin/com/bitchat/android/noise/NoiseExternalVectorTest.kt new file mode 100644 index 00000000..70badb96 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/noise/NoiseExternalVectorTest.kt @@ -0,0 +1,261 @@ +package com.bitchat.android.noise + +import com.bitchat.android.noise.southernstorm.protocol.CipherState +import com.bitchat.android.noise.southernstorm.protocol.HandshakeState +import com.bitchat.android.noise.southernstorm.protocol.Noise +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * Cacophony/Noise-C vector for Noise_XX_25519_ChaChaPoly_SHA256. + * + * This exercises the vendored Noise state machine directly, independent of managers, Android + * storage, and generated keys. + */ +class NoiseExternalVectorTest { + private val messages = listOf( + VectorMessage( + "4c756477696720766f6e204d69736573", + "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c7944" + + "4c756477696720766f6e204d69736573" + ), + VectorMessage( + "4d757272617920526f746862617264", + "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f144808843" + + "81cbad1f276e038c48378ffce2b65285e08d6b68aaa3629a5a8639392490e5b9" + + "bd5269c2f1e4f488ed8831161f19b7815528f8982ffe09be9b5c412f8a0db50f" + + "8814c7194e83f23dbd8d162c9326ad" + ), + VectorMessage( + "462e20412e20486179656b", + "c7195ffacac1307ff99046f219750fc47693e23c3cb08b89c2af808b444850a8" + + "0ae475b9df0f169ae80a89be0865b57f58c9fea0d4ec82a286427402f113e4b6" + + "ae769a1d95941d49b25030" + ), + VectorMessage( + "4361726c204d656e676572", + "96763ed773f8e47bb3712f0e29b3060ffc956ffc146cee53d5e1df" + ), + VectorMessage( + "4a65616e2d426170746973746520536179", + "3e40f15f6f3a46ae446b253bf8b1d9ffb6ed9b174d272328ff91a7e2e5c79c07f5" + ), + VectorMessage( + "457567656e2042f6686d20766f6e2042617765726b", + "eb3f3515110702e047a6c9da4478b6ead94873c11c0f2d710ddb3f09fce024b3" + + "a58502ae3f" + ) + ) + + @Test + fun `Noise-C XX transcript matches every handshake and transport byte`() { + val initiator = vectorState(HandshakeState.INITIATOR) + val responder = vectorState(HandshakeState.RESPONDER) + try { + val states = listOf( + initiator to responder, + responder to initiator, + initiator to responder + ) + messages.take(3).zip(states).forEach { (message, peers) -> + assertHandshakeMessage(peers.first, peers.second, message) + } + + assertEquals(HandshakeState.SPLIT, initiator.action) + assertEquals(HandshakeState.SPLIT, responder.action) + assertArrayEquals(initiator.handshakeHash, responder.handshakeHash) + + val initiatorCiphers = initiator.split() + val responderCiphers = responder.split() + assertTransportMessage( + responderCiphers.sender, + initiatorCiphers.receiver, + messages[3] + ) + assertTransportMessage( + initiatorCiphers.sender, + responderCiphers.receiver, + messages[4] + ) + assertTransportMessage( + responderCiphers.sender, + initiatorCiphers.receiver, + messages[5] + ) + initiatorCiphers.sender.destroy() + initiatorCiphers.receiver.destroy() + responderCiphers.sender.destroy() + responderCiphers.receiver.destroy() + } finally { + initiator.destroy() + responder.destroy() + } + } + + @Test + fun `Noise state machine rejects invalid actions and a tampered handshake tag`() { + val initiator = vectorState(HandshakeState.INITIATOR) + val responder = vectorState(HandshakeState.RESPONDER) + try { + assertThrows(IllegalStateException::class.java) { initiator.start() } + assertThrows(IllegalStateException::class.java) { + responder.writeMessage(ByteArray(256), 0, null, 0, 0) + } + + assertHandshakeMessage(initiator, responder, messages[0]) + val message2 = write(responder, messages[1].payload) + val tampered = message2.copyOf() + tampered[tampered.lastIndex] = (tampered.last().toInt() xor 1).toByte() + + assertThrows(Exception::class.java) { + initiator.readMessage(tampered, 0, tampered.size, ByteArray(256), 0) + } + assertEquals(HandshakeState.FAILED, initiator.action) + } finally { + initiator.destroy() + responder.destroy() + } + } + + @Test + fun `ChaChaPoly authentication binds nonce ciphertext tag and associated data`() { + val key = ByteArray(32) { it.toByte() } + val plaintext = "associated".toByteArray() + val associatedData = "header".toByteArray() + val sender = Noise.createCipher("ChaChaPoly") + val receiver = Noise.createCipher("ChaChaPoly") + val wrongAdReceiver = Noise.createCipher("ChaChaPoly") + try { + sender.initializeKey(key, 0) + receiver.initializeKey(key, 0) + wrongAdReceiver.initializeKey(key, 0) + sender.setNonce(7) + receiver.setNonce(7) + wrongAdReceiver.setNonce(7) + val ciphertext = ByteArray(plaintext.size + sender.macLength) + val length = sender.encryptWithAd( + associatedData, + plaintext, + 0, + ciphertext, + 0, + plaintext.size + ) + + assertThrows(Exception::class.java) { + wrongAdReceiver.decryptWithAd( + "wrong".toByteArray(), + ciphertext, + 0, + ByteArray(length), + 0, + length + ) + } + val output = ByteArray(length) + val outputLength = receiver.decryptWithAd( + associatedData, + ciphertext, + 0, + output, + 0, + length + ) + assertArrayEquals(plaintext, output.copyOf(outputLength)) + } finally { + sender.destroy() + receiver.destroy() + wrongAdReceiver.destroy() + } + } + + private fun vectorState(role: Int): HandshakeState { + val state = HandshakeState(PROTOCOL, role) + val prologue = hex("4a6f686e2047616c74") + state.setPrologue(prologue, 0, prologue.size) + val staticPrivate = if (role == HandshakeState.INITIATOR) { + hex("e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1") + } else { + hex("4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893") + } + val ephemeralPrivate = if (role == HandshakeState.INITIATOR) { + hex("893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a") + } else { + hex("bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b") + } + state.localKeyPair.setPrivateKey(staticPrivate, 0) + state.fixedEphemeralKey.setPrivateKey(ephemeralPrivate, 0) + state.start() + return state + } + + private fun assertHandshakeMessage( + writer: HandshakeState, + reader: HandshakeState, + message: VectorMessage + ) { + val actualCiphertext = write(writer, message.payload) + assertArrayEquals(message.ciphertext, actualCiphertext) + + val plaintext = ByteArray(256) + val length = reader.readMessage( + actualCiphertext, + 0, + actualCiphertext.size, + plaintext, + 0 + ) + assertArrayEquals(message.payload, plaintext.copyOf(length)) + } + + private fun write(state: HandshakeState, payload: ByteArray): ByteArray { + val output = ByteArray(512) + val length = state.writeMessage(output, 0, payload, 0, payload.size) + return output.copyOf(length) + } + + private fun assertTransportMessage( + sender: CipherState, + receiver: CipherState, + message: VectorMessage + ) { + val encrypted = ByteArray(message.payload.size + sender.macLength) + val encryptedLength = sender.encryptWithAd( + null, + message.payload, + 0, + encrypted, + 0, + message.payload.size + ) + assertArrayEquals(message.ciphertext, encrypted.copyOf(encryptedLength)) + + val decrypted = ByteArray(encryptedLength) + val decryptedLength = receiver.decryptWithAd( + null, + encrypted, + 0, + decrypted, + 0, + encryptedLength + ) + assertArrayEquals(message.payload, decrypted.copyOf(decryptedLength)) + } + + private data class VectorMessage( + private val payloadHex: String, + private val ciphertextHex: String + ) { + val payload: ByteArray get() = hex(payloadHex) + val ciphertext: ByteArray get() = hex(ciphertextHex) + } + + companion object { + private const val PROTOCOL = "Noise_XX_25519_ChaChaPoly_SHA256" + + private fun hex(value: String): ByteArray = + value.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt index 63f5e3c1..1e18fd6a 100644 --- a/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt @@ -330,6 +330,51 @@ class NoiseSessionManagerIdentityBindingTest { assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID)) } + @Test + fun `simultaneous handshake collision matrix has one deterministic winner`() { + val identities = listOf( + identity("e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1"), + identity("4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893"), + identity("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"), + identity("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb") + ) + + identities.indices.forEach { leftIndex -> + ((leftIndex + 1) until identities.size).forEach { rightIndex -> + val left = identities[leftIndex] + val right = identities[rightIndex] + val leftManager = manager(left) + val rightManager = manager(right) + val leftMessage1 = leftManager.initiateHandshake(right.peerID)!! + val rightMessage1 = rightManager.initiateHandshake(left.peerID)!! + + val leftResponse = leftManager.processHandshakeMessage(right.peerID, rightMessage1) + val rightResponse = rightManager.processHandshakeMessage(left.peerID, leftMessage1) + + if (left.peerID < right.peerID) { + assertNull(leftResponse) + val message3 = leftManager.processHandshakeMessage(right.peerID, rightResponse!!)!! + assertNull(rightManager.processHandshakeMessage(left.peerID, message3)) + } else { + assertNull(rightResponse) + val message3 = rightManager.processHandshakeMessage(left.peerID, leftResponse!!)!! + assertNull(leftManager.processHandshakeMessage(right.peerID, message3)) + } + + assertTrue(leftManager.hasEstablishedSession(right.peerID)) + assertTrue(rightManager.hasEstablishedSession(left.peerID)) + val payload = "matrix-$leftIndex-$rightIndex".toByteArray() + assertArrayEquals( + payload, + rightManager.decrypt( + leftManager.encrypt(payload, right.peerID), + left.peerID + ) + ) + } + } + } + @Test fun `peer ID derivation rejects malformed keys and non-wire claims`() { val peer = identity() @@ -381,4 +426,19 @@ class NoiseSessionManagerIdentityBindingTest { dh.destroy() } } + + private fun identity(privateKeyHex: String): TestIdentity { + val privateKey = privateKeyHex.chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + val dh = Noise.createDH("25519") + return try { + dh.setPrivateKey(privateKey, 0) + val publicKey = ByteArray(32) + dh.getPublicKey(publicKey, 0) + TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!) + } finally { + dh.destroy() + } + } } diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt new file mode 100644 index 00000000..7bb4aa30 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt @@ -0,0 +1,51 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class NostrRelayManagerLifecycleSmokeTest { + @Test + fun `disconnected manager maintains subscription and empty publish invariants locally`() { + val manager = NostrRelayManager.shared + manager.disconnect() + manager.clearAllSubscriptions() + + val id = manager.subscribe( + filter = NostrFilter(kinds = listOf(NostrKind.TEXT_NOTE)), + id = "local-contract", + handler = {}, + targetRelayUrls = emptyList() + ) + + assertEquals("local-contract", id) + assertEquals(1, manager.getActiveSubscriptionCount()) + assertTrue(manager.getActiveSubscriptions().containsKey(id)) + assertTrue(manager.validateSubscriptionConsistency().isConsistent) + manager.sendEvent(signedEvent(), relayUrls = emptyList()) + manager.retryConnection("wss://not-configured.example") + + manager.unsubscribe(id) + assertEquals(0, manager.getActiveSubscriptionCount()) + assertFalse(manager.isConnected.value) + assertTrue(manager.getRelayStatuses().none { it.isConnected }) + + manager.disconnect() + assertFalse(manager.isConnected.value) + } + + private fun signedEvent(): NostrEvent { + val privateKey = "0".repeat(63) + "1" + return NostrEvent( + pubkey = NostrCrypto.derivePublicKey(privateKey), + createdAt = 1, + kind = NostrKind.TEXT_NOTE, + tags = emptyList(), + content = "local" + ).sign(privateKey) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/onboarding/SystemStateManagerContractTest.kt b/app/src/test/kotlin/com/bitchat/android/onboarding/SystemStateManagerContractTest.kt new file mode 100644 index 00000000..a3f0341f --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/onboarding/SystemStateManagerContractTest.kt @@ -0,0 +1,87 @@ +package com.bitchat.android.onboarding + +import android.app.Application +import android.bluetooth.BluetoothManager +import android.content.Context +import android.location.LocationManager +import androidx.activity.ComponentActivity +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class SystemStateManagerContractTest { + @Test + fun `Bluetooth disabled and enabled states are observable without throwing`() { + val app = ApplicationProvider.getApplicationContext() + val controller = Robolectric.buildActivity(ComponentActivity::class.java).create() + val adapter = app.getSystemService(BluetoothManager::class.java).adapter + val manager = BluetoothStatusManager( + activity = controller.get(), + context = app, + onBluetoothEnabled = {}, + onBluetoothDisabled = {} + ) + + shadowOf(adapter).setEnabled(false) + assertEquals(BluetoothStatus.DISABLED, manager.checkBluetoothStatus()) + shadowOf(adapter).setEnabled(true) + assertEquals(BluetoothStatus.ENABLED, manager.checkBluetoothStatus()) + controller.destroy() + } + + @Test + fun `location disabled and enabled states are observable and receiver is cleaned up`() { + val app = ApplicationProvider.getApplicationContext() + val controller = Robolectric.buildActivity(ComponentActivity::class.java).create() + val locationManager = app.getSystemService(Context.LOCATION_SERVICE) as LocationManager + val manager = LocationStatusManager( + activity = controller.get(), + context = app, + onLocationEnabled = {}, + onLocationDisabled = {} + ) + + shadowOf(locationManager).setLocationEnabled(false) + assertEquals(LocationStatus.DISABLED, manager.checkLocationStatus()) + shadowOf(locationManager).setLocationEnabled(true) + assertEquals(LocationStatus.ENABLED, manager.checkLocationStatus()) + + manager.cleanup() + manager.cleanup() + controller.destroy() + } + + @Test + fun `location status routing and recovery messages remain exact`() { + val app = ApplicationProvider.getApplicationContext() + val controller = Robolectric.buildActivity(ComponentActivity::class.java).create() + var enabled = 0 + val disabled = mutableListOf() + val manager = LocationStatusManager( + activity = controller.get(), + context = app, + onLocationEnabled = { enabled++ }, + onLocationDisabled = disabled::add + ) + + manager.handleLocationStatus(LocationStatus.ENABLED) + manager.handleLocationStatus(LocationStatus.NOT_AVAILABLE) + + assertEquals(1, enabled) + assertEquals( + listOf("Location services are not available on this device."), + disabled + ) + assertTrue(manager.getStatusMessage(LocationStatus.DISABLED).contains("Bluetooth scanning")) + manager.cleanup() + controller.destroy() + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt new file mode 100644 index 00000000..63ba28ef --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt @@ -0,0 +1,141 @@ +package com.bitchat.android.ui + +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import java.util.Date +import java.util.concurrent.atomic.AtomicInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class MeshDelegateHandlerStateContractTest { + private lateinit var state: ChatState + private lateinit var messages: MessageManager + private lateinit var channels: ChannelManager + private lateinit var privateChats: PrivateChatManager + private lateinit var notifications: NotificationManager + private lateinit var mesh: MeshService + private lateinit var handler: MeshDelegateHandler + private lateinit var haptics: AtomicInteger + + @Before + fun setUp() { + val scope = TestScope(UnconfinedTestDispatcher()) + state = ChatState(scope) + state.setNickname("Résumé") + messages = MessageManager(state) + channels = mock() + privateChats = mock() + notifications = mock() + mesh = mock() + haptics = AtomicInteger() + handler = MeshDelegateHandler( + state = state, + messageManager = messages, + channelManager = channels, + privateChatManager = privateChats, + notificationManager = notifications, + coroutineScope = scope, + onHapticFeedback = { haptics.incrementAndGet() }, + getMyPeerID = { "self" }, + getMeshService = { mesh } + ) + } + + @Test + fun `peer arrival deduplicates list and final departure restores disconnected state`() { + handler.didUpdatePeerList(listOf("peer-a", "peer-a", "peer-b")) + + assertEquals(listOf("peer-a", "peer-b"), state.connectedPeers.value) + assertTrue(state.isConnected.value) + verify(notifications).showActiveUserNotification(listOf("peer-a", "peer-b")) + verify(channels).cleanupDisconnectedMembers(listOf("peer-a", "peer-b"), "self") + + handler.didUpdatePeerList(emptyList()) + + assertTrue(state.connectedPeers.value.isEmpty()) + assertFalse(state.isConnected.value) + verify(notifications).showActiveUserNotification(emptyList()) + } + + @Test + fun `delivery and read callbacks advance visible status monotonically`() { + val outgoing = message( + id = "outgoing", + sender = "me", + deliveryStatus = DeliveryStatus.Sending + ) + state.setMessages(listOf(outgoing)) + + handler.didReceiveDeliveryAck("outgoing", "peer-a") + assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Delivered) + + handler.didReceiveReadReceipt("outgoing", "peer-a") + assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Read) + + handler.didReceiveDeliveryAck("outgoing", "peer-a") + assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Read) + } + + @Test + fun `unicode mention notifies once and duplicate transport delivery is suppressed`() { + val incoming = message( + id = "incoming", + sender = "alice", + content = "hello @résumé", + senderPeerID = "peer-a" + ) + + handler.didReceiveMessage(incoming) + handler.didReceiveMessage(incoming) + + assertEquals(1, haptics.get()) + verify(notifications, times(1)).showMeshMentionNotification( + senderNickname = eq("alice"), + messageContent = eq("hello @résumé"), + senderPeerID = eq("peer-a") + ) + } + + @Test + fun `channel inbound increments unread only when conversation is not focused`() { + state.setJoinedChannels(setOf("#room")) + val incoming = message(id = "channel-1", channel = "#room") + + handler.didReceiveMessage(incoming) + assertEquals(1, state.unreadChannelMessages.value["#room"]) + + state.setCurrentChannel("#room") + handler.didReceiveMessage(incoming.copy(id = "channel-2")) + assertEquals(1, state.unreadChannelMessages.value["#room"]) + } + + private fun message( + id: String, + sender: String = "alice", + content: String = id, + senderPeerID: String? = null, + channel: String? = null, + deliveryStatus: DeliveryStatus? = null + ) = BitchatMessage( + id = id, + sender = sender, + content = content, + timestamp = Date(1), + senderPeerID = senderPeerID, + channel = channel, + deliveryStatus = deliveryStatus + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/SyncedSocketContractTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/SyncedSocketContractTest.kt new file mode 100644 index 00000000..ed5cec9f --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/SyncedSocketContractTest.kt @@ -0,0 +1,151 @@ +package com.bitchat.android.wifiaware + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.FilterInputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.Socket +import java.util.Collections + +class SyncedSocketContractTest { + @Test + fun `write emits big-endian length payload and empty keepalive frames`() { + val output = ByteArrayOutputStream() + val raw = socket(input = ByteArrayInputStream(byteArrayOf()), output = output) + val synced = SyncedSocket(raw, readTimeoutMs = 1_234) + + synced.write(byteArrayOf(1, 2, 3)) + synced.write(ByteArray(0)) + + assertArrayEquals( + byteArrayOf(0, 0, 0, 3, 1, 2, 3, 0, 0, 0, 0), + output.toByteArray() + ) + verify(raw).soTimeout = 1_234 + } + + @Test + fun `readFully reconstructs one-byte partial reads and keepalives`() { + val wire = framed(byteArrayOf(1, 2, 3, 4)) + framed(ByteArray(0)) + val partial = object : FilterInputStream(ByteArrayInputStream(wire)) { + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = + super.read(buffer, offset, minOf(1, length)) + } + val synced = SyncedSocket(socket(partial, ByteArrayOutputStream())) + + assertArrayEquals(byteArrayOf(1, 2, 3, 4), synced.read()) + assertArrayEquals(ByteArray(0), synced.read()) + assertNull(synced.read()) + } + + @Test + fun `EOF truncated invalid and oversized frames fail closed`() { + val cases = listOf( + ByteArray(0), + byteArrayOf(0, 0), + byteArrayOf(0, 0, 0, 4, 1, 2), + intPrefix(-1), + intPrefix(65_537) + ) + + cases.forEach { wire -> + val synced = SyncedSocket( + socket(ByteArrayInputStream(wire), ByteArrayOutputStream()) + ) + assertNull(synced.read()) + } + } + + @Test + fun `write exceptions propagate and do not create a partial success`() { + val failingOutput = object : OutputStream() { + override fun write(value: Int) { + throw IOException("scripted write failure") + } + } + val synced = SyncedSocket(socket(ByteArrayInputStream(byteArrayOf()), failingOutput)) + + assertThrows(IOException::class.java) { + synced.write(byteArrayOf(1)) + } + } + + @Test + fun `concurrent writers produce complete non-interleaved frames`() { + val output = ByteArrayOutputStream() + val synced = SyncedSocket(socket(ByteArrayInputStream(byteArrayOf()), output)) + val payloads = (0 until 16).map { index -> + ByteArray(index + 1) { index.toByte() } + } + val failures = Collections.synchronizedList(mutableListOf()) + val threads = payloads.map { payload -> + Thread { + runCatching { synced.write(payload) } + .exceptionOrNull() + ?.let(failures::add) + }.also(Thread::start) + } + threads.forEach { thread -> + thread.join(2_000) + assertFalse("Writer thread did not complete", thread.isAlive) + } + assertTrue(failures.isEmpty()) + + val input = DataInputStream(ByteArrayInputStream(output.toByteArray())) + val decoded = mutableListOf() + while (input.available() > 0) { + val length = input.readInt() + decoded += ByteArray(length).also(input::readFully) + } + assertEquals( + payloads.map(ByteArray::toList).toSet(), + decoded.map(ByteArray::toList).toSet() + ) + } + + @Test + fun `close and raw socket status are exposed`() { + val raw = socket(ByteArrayInputStream(byteArrayOf()), ByteArrayOutputStream()) + org.mockito.kotlin.whenever(raw.isClosed).thenReturn(false, true) + org.mockito.kotlin.whenever(raw.isConnected).thenReturn(true) + val synced = SyncedSocket(raw) + + assertFalse(synced.isClosed()) + assertTrue(synced.isConnected()) + synced.close() + verify(raw).close() + assertTrue(synced.isClosed()) + } + + private fun socket(input: InputStream, output: OutputStream): Socket = mock { + on { getInputStream() } doReturn input + on { getOutputStream() } doReturn output + } + + private fun framed(payload: ByteArray): ByteArray = + ByteArrayOutputStream().also { output -> + DataOutputStream(output).use { data -> + data.writeInt(payload.size) + data.write(payload) + } + }.toByteArray() + + private fun intPrefix(value: Int): ByteArray = + ByteArrayOutputStream().also { output -> + DataOutputStream(output).use { it.writeInt(value) } + }.toByteArray() +} diff --git a/build.gradle.kts b/build.gradle.kts index 44c5199e..5aac5518 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,3 +10,9 @@ tasks.whenTaskAdded { enabled = false } } + +tasks.register("clientRewriteContractTest") { + group = "verification" + description = "Runs the complete compatibility gate for a from-scratch client rewrite." + dependsOn(":app:testDebugUnitTest") +} diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md new file mode 100644 index 00000000..0337ccf9 --- /dev/null +++ b/docs/client-rewrite-contracts.md @@ -0,0 +1,63 @@ +# Client rewrite compatibility contracts + +This document defines the behavior a from-scratch BitChat client must preserve. +The executable source of truth is the JVM test suite under +`app/src/test/**/contracts`, together with the pre-existing protocol, security, +mesh, and state tests. + +The remaining implementation work and milestone progress are tracked in +[test-implementation-plan.md](test-implementation-plan.md). + +## Required contract layers + +| Layer | Compatibility promise | Primary tests | +|---|---|---| +| Outer mesh packet | v1/v2 header widths, big-endian fields, flags, section order, route placement, signature placement, padding, compression, signing bytes | `BinaryProtocolTest`, `ClientRewriteWireContractTest` | +| Chat payload | Flag bits, millisecond timestamp, UTF-8 byte lengths, encrypted-content substitution, optional-field order | `ClientRewriteWireContractTest` | +| Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `FragmentManagerTest` | +| Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest`, `ClientRewritePrimitiveContractTest` | +| Sync/routing | Stable packet IDs, GCS bitstream, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` | +| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals | `ClientRewriteNostrContractTest`, `NostrProtocolTest` | +| Application state | Peer unions, canonical private conversations, chronological history, delivery/read behavior, media migration policy | `AppStateStoreTest`, `PrivateChatManagerTest`, `MediaSendingManagerMigrationTest` | + +## Golden-vector policy + +Golden vectors compare literal externally visible bytes or hashes. Do not update +them merely because an implementation changed. Update a vector only when the +wire protocol is intentionally versioned and interoperating clients are updated +together. + +Round-trip tests remain useful but are not sufficient on their own: an encoder +and decoder can share the same defect. Each critical wire format therefore has +at least one literal vector. + +## Rewrite acceptance gate + +From a configured Android development environment, run: + +```sh +./gradlew clientRewriteContractTest +``` + +The task runs the new golden vectors and the complete existing unit suite. A +rewrite is compatible only when this gate passes. Tests should be ported +unchanged when package boundaries change; adapter façades are preferable to +weakening assertions. + +## Device-only acceptance + +Local JVM tests cannot prove Android radio and lifecycle behavior. Before +shipping a rewrite, run the following on at least two physical devices: + +1. BLE discovery, connection, disconnect, reconnect, and multi-hop relay. +2. Runtime permission denial/retry for Bluetooth, location, notifications, and + microphone. +3. Foreground-service survival with the screen off and after process recreation. +4. Cross-client Android/iOS exchange for announce, public/private text, delivery + and read receipts, image/audio/file transfer, sync replay, and Nostr fallback. +5. Corrupt, duplicated, reordered, delayed, and partially delivered fragments. +6. Identity rotation, verification continuity, downgrade rejection, and recovery + after stale Noise sessions. + +Those scenarios belong in instrumented tests or a two-device interoperability +harness; they must not be represented as passing JVM mocks. diff --git a/docs/device-transport-test-matrix.md b/docs/device-transport-test-matrix.md new file mode 100644 index 00000000..8ada4018 --- /dev/null +++ b/docs/device-transport-test-matrix.md @@ -0,0 +1,113 @@ +# Physical transport validation matrix + +This is the device-only companion to the deterministic Milestone 4 transport +suite. It validates that the fake adapters, Robolectric behavior, and pure +state machines match Android framework behavior. It is also consumed by the +Milestone 10 release gate. + +## Required device set + +- Two physical Android devices from different manufacturers. +- At least one Android 13+ device for `NEARBY_WIFI_DEVICES`. +- At least one device that supports Wi-Fi Aware. +- Bluetooth LE central and peripheral support on both devices. +- A build from the exact commit under test installed on both devices. +- Clean app data before the first run; retain a second run for restart tests. + +Record device models, API levels, build commit, negotiated MTUs, and timestamps +in the release artifact. Do not record user names, device serials, Bluetooth +addresses, IP addresses, peer IDs, message contents, or other identifying +values. + +## BLE discovery and recovery + +- [ ] Start both clients and confirm each begins scanning and advertising. +- [ ] Stop and restart the foreground service; confirm exactly one scanner and + advertiser generation remains active. +- [ ] Toggle Bluetooth off during scanning, then on; confirm scanning, + advertising, announcements, and peer discovery recover without process + restart. +- [ ] Disable and re-enable the BLE debug transport; confirm the same service + instance can recover without duplicate callbacks. +- [ ] Rotate the observed BLE address by restarting advertising; confirm the + canonical peer remains singular. +- [ ] Trigger a transient scan failure or Android Bluetooth process restart; + confirm bounded retry and watchdog recovery. +- [ ] Confirm permission denial reports unavailable state without a crash, + prompt loop, or active radio work. + +## GATT setup and teardown + +- [ ] Connect in both directions simultaneously and confirm one canonical link + survives. +- [ ] Record the negotiated MTU and repeat at 23, 247, and 517 where the device + or test peripheral allows it. +- [ ] Remove the service, characteristic, or CCCD in a test peripheral and + confirm setup fails closed. +- [ ] Reject notification registration and descriptor writes; confirm the peer + is never published ready. +- [ ] Disconnect during MTU negotiation, service discovery, subscription, + client write, and server notification; confirm no stale ready callback. +- [ ] Leave setup incomplete for more than 30 seconds; confirm timeout and + resource closure. +- [ ] Connect beyond configured client, server, and total limits; confirm + deterministic oldest-link eviction. + +## Packet delivery and fragmentation + +- [ ] Send directed and broadcast packets over client and server roles. +- [ ] Saturate each link faster than radio completion callbacks; confirm one + outstanding operation, bounded backpressure, and no reordered frames. +- [ ] Inject a failed `onCharacteristicWrite` and `onNotificationSent`; confirm + queued work is discarded and the failed generation is cleaned up. +- [ ] Transfer payloads immediately below and above the fragmentation boundary. +- [ ] Transfer a maximum admitted private-media payload at negotiated MTU 517. +- [ ] At MTU 247 and 23, confirm oversized frames are rejected rather than + partially sent. Adaptive per-link fragmentation remains tracked as + `TDB-023`. +- [ ] Disconnect and reconnect halfway through a fragmented transfer; confirm + incomplete state expires and a fresh transfer can finish. +- [ ] Cancel a queued transfer and stop the service during another; confirm no + later fragments or progress callbacks. + +## Wi-Fi Aware + +- [ ] Confirm unsupported hardware and temporarily unavailable radio states are + distinct. +- [ ] Deny and grant `NEARBY_WIFI_DEVICES`; confirm publish/subscribe work only + after grant. +- [ ] Start and stop publish and subscribe sessions repeatedly; confirm no + duplicate discovery callbacks. +- [ ] Authenticate a provisional socket and promote it to the canonical peer. +- [ ] Replace a socket while authentication is in flight; confirm the stale + socket cannot promote or deliver. +- [ ] Toggle Wi-Fi, location, and airplane mode; confirm rediscovery and bounded + reconnect after availability returns. +- [ ] Stop the service with active sockets, server sockets, and network + callbacks; confirm all are closed or unregistered. + +## Unified transport and failover + +- [ ] Connect the same peer over BLE and Wi-Fi Aware; confirm one peer-list row. +- [ ] Send with both transports active; confirm the preferred transport is used. +- [ ] Drop the preferred transport during a transfer and confirm defined + failover behavior without duplicate application delivery. +- [ ] Relay between transports and confirm TTL decreases once per hop. +- [ ] Reflect a bridged packet back over the other transport; confirm loop and + duplicate suppression. +- [ ] Stop the foreground service; confirm scans, advertisements, sessions, + sockets, operation queues, transfer jobs, and callbacks all terminate. + +## Evidence template + +| Field | Value | +|---|---| +| Commit | | +| Device/API classes | | +| BLE central/peripheral | Pass / Fail | +| MTU cases | Pass / Fail / Unsupported | +| BLE recovery | Pass / Fail | +| Wi-Fi Aware lifecycle | Pass / Fail / Unsupported | +| Cross-transport failover | Pass / Fail | +| Shutdown leak check | Pass / Fail | +| Bugs filed | | diff --git a/docs/release-gate-runbook.md b/docs/release-gate-runbook.md new file mode 100644 index 00000000..414bfbae --- /dev/null +++ b/docs/release-gate-runbook.md @@ -0,0 +1,244 @@ +# Physical-device and cross-client release gate + +This runbook turns Milestone 10 into a repeatable release procedure. The gate +uses a host-side CLI and USB/ADB as its control channel, so control traffic +never shares BLE, Wi-Fi Aware, Nostr, or Tor with the system under test. + +The gate cannot pass without the required physical devices and counterpart +clients. A pending or blocked result is useful diagnostic evidence, but it is +not release approval. + +## Safety and privacy rules + +- Use only disposable lab app data, identities, nicknames, messages, and files. +- Never use a personal Nostr account or a production relay. +- Do not put device serials, UDIDs, Bluetooth/MAC/IP addresses, peer IDs, + usernames, email addresses, local home paths, or message contents in a + result, trace, filename, issue, commit, or release artifact. +- Device selectors may be supplied to ADB commands as ephemeral inputs. The + tooling emits only logical aliases such as `android-current`. +- Models, manufacturer classes, Android API levels, negotiated MTU classes, + client versions, commit hashes, aggregate counts, durations, and stable + failure reason codes are allowed. +- Do not archive raw logcat. Convert observations to the structured, + privacy-checked trace format, and keep any raw diagnostic capture local until + it has been reviewed and sanitized. + +The validator rejects known identifying fields and values before a passing +bundle can be created. + +## Required lab + +Prepare: + +- At least three physical Android devices for three-hop relay testing. +- At least two Android API levels and two manufacturer classes. +- BLE central and peripheral support on every Android device. +- At least one Android 13+ device with Wi-Fi Aware. +- One physical device running the current iOS client. +- The last supported Android client. +- The release-candidate APK built from one exact full Git commit. +- A local, disposable Nostr relay/Tor fixture with production network access + blocked. + +One physical handset may be reused for the legacy-client phase after the +current-client evidence for that slot is complete, but the matrix must keep the +logical aliases and installed client versions unambiguous. + +## 1. Verify the deterministic gate + +From the repository root: + +```sh +./gradlew clientRewriteContractTest checkChangedLineCoverage lintDebug +python3 tools/release_gate/release_gate.py validate-manifest +``` + +Do not begin device work from a dirty tree or a build whose deterministic gate +does not pass. + +## 2. Create the device matrix + +Copy `tools/release_gate/device-matrix.example.json` to an ignored working +directory under `release-gate-results/`. Replace every template value and set +both current-Android commit fields to the exact full commit under test. + +Probe Android capabilities without storing the ADB selector: + +```sh +python3 tools/release_gate/android_lab.py probe \ + --serial "$BITCHAT_ADB_SELECTOR" \ + --alias android-current +``` + +Copy only the returned logical metadata into the matrix. Validate it: + +```sh +python3 tools/release_gate/release_gate.py validate-matrix \ + --matrix release-gate-results/device-matrix.json \ + --commit "$BITCHAT_RELEASE_COMMIT" +``` + +The matrix validator enforces physical devices, three Android participants, two +API levels, two manufacturer classes, Wi-Fi Aware, BLE roles, iOS, and explicit +current/legacy client versions. + +## 3. Initialize disposable fixtures + +```sh +python3 tools/release_gate/release_gate.py init \ + --matrix release-gate-results/device-matrix.json \ + --commit "$BITCHAT_RELEASE_COMMIT" \ + --run-id rc-lab-01 \ + --output release-gate-results/rc-lab-01 +``` + +Initialization pins the scenario and fixture manifests, creates every scenario +as `pending`, and generates deterministic: + +- zero-byte and small files; +- a Unicode-named medium file; +- sparse exact-maximum and oversized boundary files. + +The fixture manifest records size and SHA-256. The final archive contains the +manifest, not the large fixture bodies. + +Clear only the disposable app data on each selected lab device: + +```sh +python3 tools/release_gate/android_lab.py prepare \ + --serial "$BITCHAT_ADB_SELECTOR" \ + --confirm-disposable-app-data +``` + +This stops the app and runs package-data cleanup. The explicit confirmation is +required because the operation is destructive to that app's local data. + +## 4. Execute scenarios + +The canonical scenario list is +`tools/release_gate/scenarios.json`. It contains 27 mandatory scenarios: + +- the complete physical transport matrix; +- Android API/manufacturer/permission/background coverage; +- 11 Android-to-Android workflows; +- 8 cross-client/backward-compatibility workflows; +- 6 background and endurance workflows. + +For each scenario: + +1. Confirm the listed participants and capabilities. +2. Perform the corresponding steps in + [device-transport-test-matrix.md](device-transport-test-matrix.md) and the + Milestone 10 checklist. +3. Record connection, lifecycle, transport, receipt, resource, and terminal + state as aggregate evidence. +4. Append at least one structured trace event. +5. Mark the scenario `pass`, `fail`, `blocked`, or `unsupported`. + +Record evidence with the exact keys declared by the scenario: + +```sh +python3 tools/release_gate/release_gate.py record \ + --run release-gate-results/rc-lab-01 \ + --scenario A2A-001 \ + --status pass \ + --evidence connection-transitions=4 \ + --evidence packet-correlation-count=6 \ + --evidence failure-reasons=none +``` + +Append a privacy-safe trace event: + +```sh +python3 tools/release_gate/release_gate.py trace \ + --run release-gate-results/rc-lab-01 \ + --scenario A2A-001 \ + --source android-current \ + --event reconnect-terminal \ + --outcome pass \ + --metric reconnect-count=1 \ + --metric duplicate-delivery-count=0 +``` + +Capture resource snapshots during endurance work: + +```sh +python3 tools/release_gate/android_lab.py snapshot \ + --serial "$BITCHAT_ADB_SELECTOR" \ + --alias android-current \ + --run release-gate-results/rc-lab-01 \ + --scenario END-003 +``` + +Use run-local sequential correlation labels while observing packets; archive +only aggregate correlation counts. Record failures with a stable reason code, +file a regression issue, and preserve the incomplete artifact. + +## 5. Endurance requirements + +- `END-001` requires at least 240 minutes. +- `END-002` requires at least 50 large-transfer/cancellation cycles. +- Sample memory, threads, file descriptors, wake locks, connection counts, and + late callbacks at consistent intervals. +- A passing result requires bounded resource behavior and a clean terminal + state; merely completing the time window is insufficient. + +The validator rejects shorter durations and cycle counts. + +## 6. Inspect progress and validate + +During a run: + +```sh +python3 tools/release_gate/release_gate.py validate \ + --run release-gate-results/rc-lab-01 \ + --allow-incomplete + +python3 tools/release_gate/release_gate.py summary \ + --run release-gate-results/rc-lab-01 +``` + +The release validator, without `--allow-incomplete`, requires: + +- every scenario to be `pass`; +- every declared evidence field; +- at least one structured trace per scenario; +- the pinned scenario and fixture manifests; +- the exact client commit and complete device matrix; +- endurance minimums; +- a completion timestamp; +- no detected identifying fields or values. + +`unsupported`, `blocked`, and `pending` never satisfy release approval. + +## 7. Archive release approval + +After the complete validator passes: + +```sh +python3 tools/release_gate/release_gate.py bundle \ + --run release-gate-results/rc-lab-01 \ + --output release-gate-results/rc-lab-01.zip +``` + +The deterministic archive contains the scenario manifest, device/client matrix, +results, structured trace, fixture manifest, Markdown summary, and +`SHA256SUMS`. Attach it to the release approval record without renaming fields +or adding raw diagnostics. + +Finally, clean the disposable app data with the same confirmed `cleanup` +command and stop the local relay/Tor fixture. + +## Failure handling + +- `fail`: behavior violated a contract. Record a stable reason code, file a bug, + add a deterministic regression where possible, fix it, and rerun the affected + scenario plus dependent scenarios. +- `blocked`: required lab infrastructure or counterpart client was unavailable. + Preserve the artifact and do not approve release. +- `unsupported`: the selected device lacks a capability. Because the defined + matrix requires Wi-Fi Aware, replace the device or matrix; unsupported does + not waive a mandatory scenario. +- A flaky result is a failure until its cause is understood. Never average + retries into a pass. diff --git a/docs/test-implementation-plan.md b/docs/test-implementation-plan.md new file mode 100644 index 00000000..e64b633b --- /dev/null +++ b/docs/test-implementation-plan.md @@ -0,0 +1,779 @@ +# Test implementation plan + +## Objective + +Build enough deterministic, adversarial, integration, and device-level coverage +that the Android client can be rewritten from scratch without silently changing +its wire behavior, security properties, delivery semantics, lifecycle behavior, +or user-visible workflows. + +The canonical compatibility requirements are documented in +[client-rewrite-contracts.md](client-rewrite-contracts.md). This plan describes +how to turn those requirements into a complete, continuously enforced test +program. + +## Status legend + +- **Complete**: acceptance criteria are met and the tests run in the rewrite gate. +- **In progress**: implementation has started but acceptance criteria are not met. +- **Not started**: no implementation work has been completed. +- **Blocked**: progress requires an external dependency, device, or decision. + +## Current progress + +| Milestone | Status | Progress | Depends on | +|---|---|---:|---| +| 0. Compatibility baseline | Complete | 100% | — | +| 1. Coverage and deterministic test infrastructure | Not started | 0% | 0 | +| 2. Adversarial protocol and parser testing | Not started | 0% | 1 | +| 3. Noise, cryptography, and identity testing | Not started | 0% | 1 | +| 4. BLE, Wi-Fi Aware, and transport lifecycle testing | Not started | 0% | 1, 3 | +| 5. Sync, routing, and store-and-forward testing | Not started | 0% | 1, 4 | +| 6. Nostr and Tor integration testing | Not started | 0% | 1, 3 | +| 7. Android lifecycle and permission testing | Not started | 0% | 1, 4 | +| 8. Persistence, migration, and recovery testing | Not started | 0% | 1, 3 | +| 9. UI, media, and accessibility testing | Not started | 0% | 1, 7, 8 | +| 10. Physical-device and cross-client release gate | Not started | 0% | 2–9 | + +Milestone completion is currently **1 of 11 milestones (9%)**. This is +milestone-based progress, not line or branch coverage. Milestone 1 will establish +measured coverage baselines and trends. + +## Test levels and execution policy + +| Level | Purpose | Expected execution | +|---|---|---| +| Pure JVM unit tests | Protocols, state machines, crypto vectors, parsing, routing, and deterministic utilities | Every pull request | +| Property and fuzz tests | Malformed inputs, boundary exploration, invariants, and crash resistance | Bounded set on every pull request; extended corpus nightly | +| Robolectric tests | Android services, lifecycle, broadcasts, permissions, persistence, and process recreation | Every pull request where stable | +| Instrumented emulator tests | Compose semantics, navigation, database/filesystem integration, and permission flows | Main branch and release candidates | +| Physical-device tests | BLE, Wi-Fi Aware, radios, background execution, and manufacturer-specific behavior | Nightly where devices are available; mandatory release gate | +| Cross-client interoperability | Android/iOS and old/new client wire compatibility | Mandatory release gate | + +## Global rules + +- [x] Keep literal golden vectors for externally visible bytes and hashes. +- [x] Run all compatibility and regression tests through + `./gradlew clientRewriteContractTest`. +- [ ] Prefer public behavior and stable adapter interfaces over implementation + details. +- [ ] Require deterministic clocks, randomness, dispatchers, storage, and + transports in tests. +- [ ] Never use production relay or internet availability as a test dependency. +- [ ] Every fixed protocol or security defect must receive a regression test. +- [ ] Every decoder must have positive, boundary, malformed, and fuzz coverage. +- [ ] Every asynchronous test must have bounded completion and must not use + arbitrary sleeps. +- [ ] Test failures must preserve seeds, inputs, and traces needed to reproduce + the failure. +- [ ] Golden vectors may change only with an intentional protocol version change + and coordinated interoperability review. + +--- + +## Milestone 0: Compatibility baseline + +**Status:** Complete +**Progress:** 100% + +### Scope + +Establish executable rewrite contracts for the most important deterministic +wire formats and reuse the existing regression suite as a single acceptance +gate. + +### Completed checklist + +- [x] Create an isolated workspace and + `codex/client-rewrite-contract-tests` branch. +- [x] Add literal v1 and v2 outer packet vectors. +- [x] Add public, private, optional-field, and encrypted message vectors. +- [x] Add private-message, Noise envelope, fragment, sync, identity, and file + transfer vectors. +- [x] Add padding, binary encoding, geohash, gossip, packet ID, GCS, and Noise + peer-ID contracts. +- [x] Add Bech32, secp256k1, NIP-01, NIP-44, and NIP-13 contracts. +- [x] Add required-prefix and representative truncated-input rejection tests. +- [x] Add explicit validation for malformed fragment IDs. +- [x] Add `clientRewriteContractTest` as the complete rewrite acceptance task. +- [x] Verify 32 new tests pass without skips. +- [x] Verify the full gate discovers 254 tests with zero failures or errors. +- [x] Document the remaining device-only acceptance requirements. + +### Acceptance criteria + +- [x] The original `main` workspace remains unchanged. +- [x] All new golden-vector tests pass. +- [x] The complete unit suite passes through one documented command. + +--- + +## Milestone 1: Coverage and deterministic test infrastructure + +**Status:** Not started +**Progress:** 0% + +### Goal + +Make coverage measurable and provide reusable deterministic seams so later +milestones test behavior without real time, radios, network access, or flaky +scheduling. + +### TODO checklist + +#### Coverage reporting + +- [ ] Add JaCoCo or Kover for JVM unit-test line and branch coverage. +- [ ] Generate XML and HTML reports from the rewrite acceptance task. +- [ ] Record the initial project-wide line and branch coverage baseline. +- [ ] Record package-level baselines for `mesh`, `noise`, `nostr`, `service`, + `services`, `sync`, `model`, `protocol`, `identity`, and `ui`. +- [ ] Publish coverage artifacts in CI. +- [ ] Add a changed-lines coverage check for new production code. +- [ ] Add non-regression thresholds without forcing low-value tests for trivial + generated or platform glue. +- [ ] Exclude generated code, Compose compiler output, Android resource classes, + and vendored cryptographic code from first-party coverage metrics. + +#### Deterministic seams + +- [ ] Introduce an injectable monotonic clock and wall clock. +- [ ] Introduce injectable secure and non-secure random-byte sources where + deterministic vectors are required. +- [ ] Introduce injectable coroutine dispatchers and test scopes. +- [ ] Introduce an in-memory key/value storage adapter for preferences. +- [ ] Introduce an in-memory file store with controllable I/O failures. +- [ ] Define a fake mesh transport that can connect, disconnect, delay, drop, + duplicate, corrupt, reorder, and fragment packets. +- [ ] Define fake BLE scanner, advertiser, GATT client, and GATT server adapters. +- [ ] Define a fake Wi-Fi Aware session/socket adapter. +- [ ] Define a fake Nostr relay transport or MockWebServer fixture. +- [ ] Provide reusable packet, identity, peer, graph, and message fixture + builders. +- [ ] Provide seed capture and reproduction helpers for randomized tests. +- [ ] Add test naming and directory conventions for unit, property, Robolectric, + instrumented, and interoperability suites. + +### Acceptance criteria + +- [ ] One command generates a repeatable coverage report. +- [ ] Two consecutive clean runs produce identical deterministic test results. +- [ ] Fake time and transport behavior require no wall-clock sleeps. +- [ ] CI publishes coverage and test-result artifacts. +- [ ] The plan's progress table is updated with measured baseline numbers. + +--- + +## Milestone 2: Adversarial protocol and parser testing + +**Status:** Not started +**Progress:** 0% + +### Goal + +Prove that all wire decoders preserve canonical behavior, reject unsafe input, +and never crash or allocate unreasonable memory for attacker-controlled data. + +### TODO checklist + +#### Outer packet protocol + +- [ ] Test every valid flag combination for v1 and v2. +- [ ] Test exact minimum and maximum payload sizes. +- [ ] Test sender and recipient IDs at 0, 1, 7, 8, 9, and oversized lengths. +- [ ] Test signatures at 0, 1, 63, 64, 65, and oversized lengths. +- [ ] Test route counts at 0, 1, 254, 255, and truncated route entries. +- [ ] Test unknown message type values remain safely representable or are + rejected according to the protocol contract. +- [ ] Test invalid versions, reserved flags, integer overflow, and unsigned + length conversion. +- [ ] Test trailing bytes and concatenated frames explicitly. +- [ ] Test padding boundaries around 256, 512, 1024, and 2048 bytes. +- [ ] Test malformed PKCS#7 tails and ambiguous unpadded frames. +- [ ] Test raw DEFLATE and zlib-header compatibility vectors. +- [ ] Test forged original-size fields, compression bombs, and truncated + compressed streams. +- [ ] Add encode/decode property tests for all valid packet shapes. +- [ ] Add a mutation corpus derived from every golden packet. + +#### Inner payloads and TLVs + +- [ ] Fuzz `BitchatMessage.fromBinaryPayload`. +- [ ] Fuzz `IdentityAnnouncement.decode`. +- [ ] Fuzz `AuthenticatedPeerState.decode`. +- [ ] Fuzz `PrivateMessagePacket.decode`. +- [ ] Fuzz `NoisePayload.decode`. +- [ ] Fuzz `BitchatFilePacket.decode`. +- [ ] Fuzz `FragmentPayload.decode`. +- [ ] Fuzz `RequestSyncPacket.decode`. +- [ ] Test missing, duplicated, reordered, unknown, and zero-length TLVs. +- [ ] Test truncated headers and values at every byte offset. +- [ ] Test UTF-8 ASCII, multi-byte, combining-mark, emoji, invalid-byte, and + maximum-byte-length cases. +- [ ] Test 255-byte one-byte-length boundaries. +- [ ] Test 65,535-byte two-byte-length boundaries. +- [ ] Test four-byte file content lengths and impossible content declarations. +- [ ] Test fragmented file content using one and multiple content TLVs. +- [ ] Define and test whether non-canonical but tolerated inputs re-encode + canonically. + +#### Fuzzing operations + +- [ ] Select a JVM-compatible property/fuzz framework. +- [ ] Add bounded pull-request fuzz runs with fixed seeds. +- [ ] Add extended randomized nightly runs. +- [ ] Store minimized failing inputs as regression fixtures. +- [ ] Assert no decoder throws for arbitrary byte arrays. +- [ ] Assert decoder runtime and allocations stay within configured bounds. + +### Acceptance criteria + +- [ ] Every externally reachable decoder has boundary and malformed-input tests. +- [ ] Every decoder has a bounded arbitrary-byte no-crash property. +- [ ] All discovered crashes or ambiguous contracts have regression fixtures. +- [ ] Extended fuzzing completes nightly and preserves reproduction seeds. + +--- + +## Milestone 3: Noise, cryptography, and identity testing + +**Status:** Not started +**Progress:** 0% + +### Goal + +Prove confidentiality, authenticity, identity binding, replay behavior, session +replacement, rekeying, and recovery across the complete secure-channel +lifecycle. + +### TODO checklist + +#### Known vectors and primitives + +- [ ] Add known Curve25519 key agreement vectors. +- [ ] Add known Ed25519 signing and verification vectors. +- [ ] Add known BIP-340 verification vectors. +- [ ] Add known HKDF and channel-key derivation vectors. +- [ ] Add external Noise XX handshake transcript vectors where compatible. +- [ ] Add deterministic channel encryption vectors with injected nonces. +- [ ] Test constant-time verification APIs where the underlying library exposes + an appropriate contract. + +#### Noise session lifecycle + +- [ ] Test initiator and responder handshakes without manager wrappers. +- [ ] Test all valid handshake state transitions. +- [ ] Test every invalid message for every handshake state. +- [ ] Test tampered handshake messages and remote static-key substitution. +- [ ] Test post-handshake encryption in both directions. +- [ ] Test empty, small, maximum, and fragmented plaintext. +- [ ] Test tampered ciphertext, nonce, tag, and associated data. +- [ ] Test replayed ciphertext. +- [ ] Test skipped, duplicated, and out-of-order transport messages. +- [ ] Test send and receive nonce progression. +- [ ] Test nonce exhaustion and counter-overflow behavior. +- [ ] Test rekey thresholds, successful rekey, failed rekey, and simultaneous + rekey. +- [ ] Test session reset and destruction zeroize or discard sensitive state as + designed. +- [ ] Test handshake timeouts and stale generation leases with fake time. +- [ ] Test simultaneous initiator tie-breaking across a larger peer matrix. +- [ ] Test process restart with and without persisted identity. + +#### Identity and downgrade protection + +- [ ] Test peer-ID derivation for valid and malformed static keys. +- [ ] Test signing-key rotation with authorized and unauthorized announcements. +- [ ] Test private-media capability pinning across restart. +- [ ] Test downgrade attempts after a capability has been pinned. +- [ ] Test corrupted, missing, partially written, and legacy identity storage. +- [ ] Test atomic clearing of identity, capability, and peer mappings. +- [ ] Test verification fingerprints remain stable for unchanged identities. +- [ ] Test identity replacement does not expose an established session before + authentication completes. + +### Acceptance criteria + +- [ ] Known vectors pass independently of Android storage and services. +- [ ] Replay, tampering, downgrade, and identity-substitution tests all fail + closed. +- [ ] All timeouts and rekey tests use fake time. +- [ ] No sensitive test fixtures contain production keys or user data. + +--- + +## Milestone 4: BLE, Wi-Fi Aware, and transport lifecycle testing + +**Status:** Not started +**Progress:** 0% + +### Goal + +Verify connection state machines and packet delivery across unreliable Android +transports without requiring real radios for the majority of cases. + +### TODO checklist + +#### BLE discovery and connection + +- [ ] Test scan start, stop, restart, and failure callbacks. +- [ ] Test advertising start, stop, restart, and failure callbacks. +- [ ] Test Bluetooth-off and Bluetooth-on recovery. +- [ ] Test duplicate scan results and rapidly changing peer addresses. +- [ ] Test connection success, rejection, timeout, and cancellation. +- [ ] Test simultaneous inbound and outbound connection races. +- [ ] Test canonical connection selection and duplicate-link teardown. +- [ ] Test service discovery failure and missing characteristics. +- [ ] Test GATT disconnect during discovery, negotiation, read, and write. +- [ ] Test reconnect backoff with fake time. +- [ ] Test maximum-connection enforcement and eviction policy. +- [ ] Test RSSI thresholds and power-mode transitions. + +#### Packet transfer + +- [ ] Test MTU negotiation at minimum, normal, and maximum values. +- [ ] Test partial writes and write callbacks delivered out of order. +- [ ] Test notification subscription and notification failure. +- [ ] Test queue backpressure and bounded memory use. +- [ ] Test fragmentation and reassembly across disconnect/reconnect. +- [ ] Test duplicate, missing, reordered, and corrupted fragments. +- [ ] Test cancellation cleans pending queues and transfer state. +- [ ] Test large file/media transfers under constrained MTU. +- [ ] Test broadcast and directed packet delivery. +- [ ] Test packet relay while one link disconnects. + +#### Wi-Fi Aware + +- [ ] Test feature unavailable and permission-denied behavior. +- [ ] Test publish/subscribe session creation and teardown. +- [ ] Test provisional link authentication and canonical promotion. +- [ ] Test socket replacement and stale-socket rejection. +- [ ] Test partial reads, writes, EOF, exceptions, and cancellation. +- [ ] Test reconnect and rediscovery. +- [ ] Test coexistence with BLE for the same peer. + +#### Unified transport behavior + +- [ ] Test peer-list union and removal across transports. +- [ ] Test preferred-transport selection. +- [ ] Test transparent failover between BLE and Wi-Fi Aware. +- [ ] Test duplicate packet suppression across transports. +- [ ] Test transport bridge TTL decrement and loop prevention. +- [ ] Test shutdown cancels all jobs, scans, advertisements, sockets, and queues. + +### Acceptance criteria + +- [ ] Transport state-machine tests run deterministically on the JVM or + Robolectric. +- [ ] Disconnect and cancellation tests leave no queued work or active jobs. +- [ ] Cross-transport duplicate delivery and loops are prevented. +- [ ] A smaller physical-device suite confirms the fake adapters match Android + behavior. + +--- + +## Milestone 5: Sync, routing, and store-and-forward testing + +**Status:** Not started +**Progress:** 0% + +### Goal + +Verify eventual delivery, bounded resource usage, correct routing, and duplicate +suppression during partitions, topology changes, and reconnects. + +### TODO checklist + +#### Packet identity and sync filters + +- [ ] Add more packet-ID vectors for every message type. +- [ ] Prove TTL, route, recipient, and signature mutations do not change sync + identity. +- [ ] Prove payload, sender, timestamp, and type mutations do change identity. +- [ ] Property-test GCS encode/decode membership. +- [ ] Test empty, singleton, maximum-capacity, duplicate, and collision-heavy + filters. +- [ ] Test false-positive behavior statistically against configured tolerances. +- [ ] Test maximum accepted filter bytes and malicious bitstreams. +- [ ] Test sync requests with unknown TLVs and future capability extensions. + +#### Store and forward + +- [ ] Test caching decisions for public, private, favorite, and offline peers. +- [ ] Test cache capacity and deterministic eviction. +- [ ] Test cache expiry with fake time. +- [ ] Test delivery acknowledgement removal. +- [ ] Test retransmission after reconnect. +- [ ] Test duplicate acknowledgements and late acknowledgements. +- [ ] Test process restart persistence policy. +- [ ] Test shutdown and cleanup under active delivery. +- [ ] Test memory bounds under repeated undeliverable messages. + +#### Routing and topology + +- [ ] Test shortest paths for disconnected, cyclic, diamond, and changing graphs. +- [ ] Test deterministic tie-breaking for equal-length routes. +- [ ] Test only confirmed edges are used. +- [ ] Test edge expiry and peer disappearance with fake time. +- [ ] Test a route invalidated between planning and send. +- [ ] Test relay TTL exhaustion at every hop. +- [ ] Test source-route loop rejection. +- [ ] Test broadcast storm suppression. +- [ ] Test delivery across mixed BLE and Wi-Fi Aware paths. +- [ ] Test graph updates while sync and relay operations run concurrently. + +### Acceptance criteria + +- [ ] Partition/reconnect scenarios eventually deliver exactly once at the + application layer. +- [ ] Cache, graph, and filter resource bounds are enforced. +- [ ] No topology or bridge scenario produces an infinite relay loop. +- [ ] All expiry behavior uses fake time. + +--- + +## Milestone 6: Nostr and Tor integration testing + +**Status:** Not started +**Progress:** 0% + +### Goal + +Verify relay communication, subscriptions, event validation, NIP-17 delivery, +and Tor-mode behavior under realistic network failures. + +### TODO checklist + +#### Relay protocol + +- [ ] Add a scripted local WebSocket relay fixture. +- [ ] Test initial connection and clean disconnect. +- [ ] Test DNS, TCP, TLS, WebSocket, and protocol failures. +- [ ] Test reconnect backoff and cancellation with fake time. +- [ ] Test relay notices, acknowledgements, end-of-stored-events, and malformed + messages. +- [ ] Test subscription creation, replacement, unsubscribe, and reconnect + restoration. +- [ ] Test duplicate, delayed, reordered, and conflicting events. +- [ ] Test multi-relay publish success, partial success, and total failure. +- [ ] Test event deduplication across relays. +- [ ] Test relay-list selection and invalid relay URLs. + +#### Event security and messaging + +- [ ] Add external NIP-01, NIP-13, NIP-17, and NIP-44 vectors. +- [ ] Test invalid event IDs and signatures are rejected before dispatch. +- [ ] Test future timestamps, stale timestamps, and integer boundaries. +- [ ] Test NIP-17 gift-wrap signer/rumor identity mismatches. +- [ ] Test malformed seals, wrong recipients, and tampered ciphertext. +- [ ] Test private-message and acknowledgement embedding/extraction. +- [ ] Test geohash note, presence, and ephemeral-event filters. +- [ ] Test nickname and teleport tags. +- [ ] Test proof-of-work policy at exact difficulty boundaries. +- [ ] Test cancellation and bounded mining iterations. + +#### Tor behavior + +- [ ] Add a fake Tor-state provider and proxy-selection tests. +- [ ] Test direct, Tor-only, and fallback modes. +- [ ] Test bootstrap delay, bootstrap failure, proxy failure, and shutdown. +- [ ] Verify Tor-only mode never silently uses a direct connection. +- [ ] Verify mode changes rebuild clients and close old connections. + +### Acceptance criteria + +- [ ] Nostr integration tests require no public relay or internet connection. +- [ ] Invalid or unauthenticated events never reach application state. +- [ ] Reconnect restores intended subscriptions without duplicate delivery. +- [ ] Tor-only policy fails closed. + +--- + +## Milestone 7: Android lifecycle and permission testing + +**Status:** Not started +**Progress:** 0% + +### Goal + +Verify the app behaves correctly under Android process, service, permission, +Bluetooth, battery, and background-execution rules. + +### TODO checklist + +#### Foreground service + +- [ ] Add Robolectric tests for service create, start, bind, unbind, and destroy. +- [ ] Test repeated start commands are idempotent. +- [ ] Test foreground notification creation and channel configuration. +- [ ] Test explicit shutdown clears transport and application state correctly. +- [ ] Test unexpected process/service recreation restores required state. +- [ ] Test task removal behavior. +- [ ] Test boot-completed handling. +- [ ] Test service start restrictions and failure reporting. +- [ ] Test all coroutines and resources are cancelled on destroy. + +#### Permissions and system state + +- [ ] Test first-run permission explanations. +- [ ] Test denial, permanent denial, and later grant. +- [ ] Test partial Bluetooth permission grants by Android version. +- [ ] Test location-disabled and Bluetooth-disabled states. +- [ ] Test notification permission denial. +- [ ] Test microphone permission denial during voice recording. +- [ ] Test background-location preferences where applicable. +- [ ] Test battery-optimization accepted, declined, and unavailable paths. +- [ ] Test configuration changes during onboarding. +- [ ] Test onboarding restoration after process recreation. + +#### Android-version matrix + +- [ ] Define minimum, target, and newest-supported API test matrix. +- [ ] Add emulator coverage for behavior changes in permissions and foreground + services. +- [ ] Add at least one low-memory/process-death scenario. +- [ ] Add manufacturer-device coverage for known BLE/background differences. + +### Acceptance criteria + +- [ ] Critical service and permission flows have Robolectric or instrumented + coverage. +- [ ] No permission denial crashes or leaves onboarding irrecoverable. +- [ ] Service recreation does not duplicate transports or lose required state. +- [ ] Required API-level matrix passes before release. + +--- + +## Milestone 8: Persistence, migration, and recovery testing + +**Status:** Not started +**Progress:** 0% + +### Goal + +Ensure identities, settings, aliases, favorites, bookmarks, messages, and +capability pins survive upgrades and fail safely when storage is incomplete or +corrupt. + +### TODO checklist + +- [ ] Inventory every persisted key, file, schema, and version marker. +- [ ] Create legacy fixtures for every supported application version. +- [ ] Test clean first launch with no persisted state. +- [ ] Test upgrade from each retained legacy fixture. +- [ ] Test unknown future fields are preserved or ignored safely. +- [ ] Test truncated, malformed, empty, and type-mismatched preference values. +- [ ] Test partial multi-key identity writes. +- [ ] Test storage write failure and rollback. +- [ ] Test concurrent readers and writers. +- [ ] Test alias merging and canonical conversation migration. +- [ ] Test chronological ordering after migration. +- [ ] Test favorite and bookmark preservation. +- [ ] Test message-retention expiry with fake time. +- [ ] Test secure identity clearing removes all linked mappings and pins. +- [ ] Test signing-key and capability rotation is atomic. +- [ ] Test backup/restore policy does not duplicate or expose sensitive identity + material. +- [ ] Test migration idempotence by running each migration twice. +- [ ] Test downgrade behavior when a newer schema has already been written. + +### Acceptance criteria + +- [ ] Every supported legacy fixture migrates deterministically. +- [ ] Failed migrations leave either the old valid state or the new valid state, + never a partial mixture. +- [ ] Security-sensitive corruption fails closed with a recoverable user path. +- [ ] Migration and retention behavior uses deterministic storage and time. + +--- + +## Milestone 9: UI, media, and accessibility testing + +**Status:** Not started +**Progress:** 0% + +### Goal + +Protect user-visible behavior and media workflows while keeping most assertions +at ViewModel/state boundaries and reserving Compose instrumentation for genuine +interaction and rendering contracts. + +### TODO checklist + +#### ViewModels and application state + +- [ ] Restore or replace the currently skipped command-processor tests. +- [ ] Restore or replace the currently skipped notification tests. +- [ ] Test public/private/channel conversation switching. +- [ ] Test optimistic send, success, failure, retry, and cancellation. +- [ ] Test delivery and read-receipt transitions. +- [ ] Test peer arrival, departure, alias change, and identity rotation. +- [ ] Test state restoration after configuration change and process recreation. +- [ ] Test concurrent inbound messages while changing conversations. +- [ ] Test error messages for permission, transport, storage, and crypto failures. + +#### Compose UI + +- [ ] Add semantics tests for critical chat actions. +- [ ] Test onboarding navigation and recoverability. +- [ ] Test empty, loading, connected, disconnected, and error states. +- [ ] Test long nicknames, messages, channels, and localized text. +- [ ] Test dynamic font sizes and display scaling. +- [ ] Test light, dark, and supported theme variants. +- [ ] Add screenshot tests only for stable high-value layouts. +- [ ] Test keyboard, focus, back navigation, and bottom-sheet behavior. +- [ ] Test screen-reader labels, traversal order, and minimum touch targets. +- [ ] Test reduced-motion behavior where animations are nonessential. + +#### Files, images, and voice + +- [ ] Test zero-byte, small, maximum-size, and oversized files. +- [ ] Test unsupported and misleading MIME types. +- [ ] Test missing filenames and Unicode filenames. +- [ ] Test file read/write failures and insufficient storage. +- [ ] Test image decode failures, orientation metadata, and large-image memory + limits. +- [ ] Test voice-recording start, pause/stop, cancellation, and microphone loss. +- [ ] Test corrupt and unsupported audio playback. +- [ ] Test waveform generation boundaries. +- [ ] Test interrupted private-media preparation and commit rollback. +- [ ] Test cleanup of temporary files after success, failure, and cancellation. + +### Acceptance criteria + +- [ ] Critical user journeys pass through state-level tests. +- [ ] A focused Compose suite protects navigation, semantics, and accessibility. +- [ ] Media failures are visible, recoverable, and leak no temporary resources. +- [ ] Previously skipped UI-related tests are either active or replaced with + equivalent coverage. + +--- + +## Milestone 10: Physical-device and cross-client release gate + +**Status:** Not started +**Progress:** 0% + +### Goal + +Validate the behavior that JVM, Robolectric, and emulator tests cannot prove: +real radios, background limits, device interoperability, and compatibility with +released clients. + +### TODO checklist + +#### Device matrix and harness + +- [ ] Define a minimum physical-device matrix covering at least two Android API + levels and two manufacturers. +- [ ] Include devices supporting BLE only and BLE plus Wi-Fi Aware where + available. +- [ ] Build a test control channel that does not interfere with mesh transport. +- [ ] Capture structured traces, packet IDs, connection transitions, and failure + reasons. +- [ ] Make test accounts, identities, and files disposable and non-personal. +- [ ] Provide deterministic scenario setup and cleanup. + +#### Android-to-Android scenarios + +- [ ] Discover, connect, exchange announcements, disconnect, and reconnect. +- [ ] Send public and private messages in both directions. +- [ ] Verify delivery and read receipts. +- [ ] Transfer image, audio, and generic files at multiple sizes. +- [ ] Relay across at least three devices. +- [ ] Partition the mesh and verify store-and-forward delivery after reconnect. +- [ ] Disable and re-enable Bluetooth during active transfers. +- [ ] Lock screens and background both apps during active mesh operation. +- [ ] Kill and recreate one process. +- [ ] Exercise simultaneous connections and duplicate-link resolution. +- [ ] Exercise Wi-Fi Aware failover where supported. + +#### Cross-client and backward compatibility + +- [ ] Test current Android against the current iOS client. +- [ ] Test the rewrite against the last supported Android release. +- [ ] Test legacy announcements without capabilities. +- [ ] Test current capability announcements with an older client. +- [ ] Test canonical private-media type and decode-only prerelease alias. +- [ ] Compare packet, message, identity, fragment, sync, and file golden vectors + across implementations. +- [ ] Verify malformed and unauthenticated inputs are rejected consistently. +- [ ] Verify Nostr fallback messages and receipts across clients. + +#### Background and endurance + +- [ ] Run a multi-hour discovery/connect/disconnect soak test. +- [ ] Run repeated large-transfer and cancellation cycles. +- [ ] Monitor memory, threads, file descriptors, wake locks, and battery impact. +- [ ] Test foreground-service survival with screens off. +- [ ] Test network and Tor availability changes during Nostr operation. +- [ ] Confirm shutdown releases radios, sockets, jobs, and wake locks. + +### Acceptance criteria + +- [ ] All mandatory scenarios pass on the defined device matrix. +- [ ] Android/iOS and old/new clients exchange every supported critical payload. +- [ ] No endurance run shows unbounded growth or leaked resources. +- [ ] Failures produce sufficient traces for deterministic reproduction where + possible. +- [ ] Release approval records the client versions, device matrix, and results. + +--- + +## CI rollout + +### Pull-request gate + +- [ ] Run formatting and static analysis. +- [ ] Run deterministic JVM unit tests. +- [ ] Run bounded property/fuzz tests. +- [ ] Run stable Robolectric tests. +- [ ] Run `clientRewriteContractTest`. +- [ ] Upload JUnit and coverage reports. +- [ ] Reject new failures, errors, or unexpected skips. +- [ ] Reject golden-vector changes without the protocol-change review label. + +### Main and nightly gate + +- [ ] Run the extended fuzz corpus. +- [ ] Run emulator instrumented tests. +- [ ] Run local relay/Tor integration tests. +- [ ] Run physical-device smoke tests when the lab is available. +- [ ] Track runtime, flakes, coverage, and quarantined tests. + +### Release-candidate gate + +- [ ] Run the complete device matrix. +- [ ] Run Android/iOS and old/new interoperability. +- [ ] Run endurance and background scenarios. +- [ ] Review all skipped or quarantined tests. +- [ ] Archive coverage, test, trace, and version metadata with the release. + +## Progress update procedure + +When work lands: + +1. Check completed TODOs in the relevant milestone. +2. Update the milestone percentage based on completed checklist items. +3. Change status to **In progress** when its first TODO is complete. +4. Change status to **Complete** only when all acceptance criteria are met. +5. Update the top-level progress table and milestone completion count. +6. Link the implementing pull request or commit next to material completed work + without including personal information. +7. Record intentionally deferred items and their justification; do not mark them + complete. + +## Final definition of done + +The test program is complete when: + +- [ ] Milestones 0–10 meet every acceptance criterion. +- [ ] Project and package-level line/branch coverage no longer regress. +- [ ] All critical parsers have adversarial and fuzz coverage. +- [ ] All security-sensitive state transitions fail closed under tampering, + replay, downgrade, and corruption. +- [ ] Transport, sync, and lifecycle tests cover disconnection, cancellation, + timeout, and restart. +- [ ] Physical-device and cross-client scenarios pass for every release. +- [ ] The full rewrite can replace the existing implementation while preserving + the unchanged compatibility and acceptance tests. diff --git a/docs/testing-conventions.md b/docs/testing-conventions.md new file mode 100644 index 00000000..b2da4e7b --- /dev/null +++ b/docs/testing-conventions.md @@ -0,0 +1,90 @@ +# Testing conventions + +## Purpose + +These conventions keep the client-rewrite suite deterministic, reproducible, +and portable across implementations. + +## Test locations + +| Test type | Location | Naming | +|---|---|---| +| JVM unit and contract tests | `app/src/test/` | `*Test.kt` | +| Shared deterministic fakes and fixtures | `app/src/test/**/testsupport/` | Descriptive fixture name | +| Robolectric tests | `app/src/test/` | `*RobolectricTest.kt` | +| Android instrumented tests | `app/src/androidTest/` | `*InstrumentedTest.kt` | +| Coverage-tool tests | `tools/coverage/` | `test_*.py` | +| Interoperability fixtures | `app/src/test/resources/contracts/` | Protocol and version in filename | + +## Required behavior + +- Tests must not use arbitrary sleeps. Advance a fake clock or coroutine test + scheduler instead. +- Tests must not require public relays, internet access, Bluetooth hardware, or a + user's persisted data. +- Time, randomness, dispatchers, storage, and transports must be injectable in + code exercised by state-machine tests. +- Randomized failures must print a reproduction seed. Use `TEST_SEED` for a + specific replay. +- Mutable byte arrays returned by fixtures and fakes must be defensively copied. +- Negative security tests must assert fail-closed behavior. +- Protocol round trips must be paired with literal golden vectors for critical + externally visible formats. +- Asynchronous tests must have a deterministic completion condition and a + bounded timeout. +- A fixed bug must retain its smallest reproducing input as a regression test. + +## Naming + +Test names should describe observable behavior: + +```kotlin +@Test +fun `replayed ciphertext is rejected without advancing receive state`() { + // ... +} +``` + +Avoid names tied to private methods or temporary implementation structure. + +## Fixtures and seeds + +Reusable Kotlin fixtures live under +`com.bitchat.android.testsupport`. `ReproducibleTestSeed` resolves +`TEST_SEED` and provides a reproduction hint: + +```sh +TEST_SEED=12345 ./gradlew clientRewriteContractTest +``` + +Never use production keys, contact information, messages, or other user data in +fixtures. + +## Coverage + +Run the full report and non-regression floor: + +```sh +./gradlew clientRewriteContractTest +``` + +Reports are written to: + +- `app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml` +- `app/build/reports/jacoco/jacocoTestReport/html/` + +Check executable production lines changed from the base branch: + +```sh +COVERAGE_BASE_REF=origin/main ./gradlew checkChangedLineCoverage +``` + +Generated resource classes, Compose-generated singleton classes, platform +bridges, and vendored Noise code are excluded from first-party coverage metrics. + +## Quarantine and skips + +- A flaky test must be fixed, not silently retried. +- A temporary quarantine must include an issue and removal condition. +- Unexpected skips fail review. Existing skips must be restored or replaced by + equivalent coverage. diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 00000000..b48e1209 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +"""Repository-local verification tooling.""" diff --git a/tools/coverage/__init__.py b/tools/coverage/__init__.py new file mode 100644 index 00000000..8aec01cc --- /dev/null +++ b/tools/coverage/__init__.py @@ -0,0 +1 @@ +"""Coverage verification helpers.""" diff --git a/tools/coverage/check_changed_coverage.py b/tools/coverage/check_changed_coverage.py new file mode 100644 index 00000000..fde7b1bc --- /dev/null +++ b/tools/coverage/check_changed_coverage.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Enforce JaCoCo coverage for executable Kotlin/Java lines changed from a Git base.""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import subprocess +import sys +import xml.etree.ElementTree as ET +from dataclasses import dataclass + + +SOURCE_ROOTS = ( + "app/src/main/java/", + "app/src/main/kotlin/", +) + + +@dataclass(frozen=True) +class CoverageLine: + missed_instructions: int + covered_instructions: int + + @property + def covered(self) -> bool: + return self.covered_instructions > 0 + + +def parse_jacoco(xml_path: pathlib.Path) -> dict[tuple[str, int], CoverageLine]: + root = ET.parse(xml_path).getroot() + result: dict[tuple[str, int], CoverageLine] = {} + for package in root.findall("package"): + package_name = package.attrib["name"] + for source_file in package.findall("sourcefile"): + relative_path = f"{package_name}/{source_file.attrib['name']}" + for line in source_file.findall("line"): + result[(relative_path, int(line.attrib["nr"]))] = CoverageLine( + missed_instructions=int(line.attrib.get("mi", "0")), + covered_instructions=int(line.attrib.get("ci", "0")), + ) + return result + + +def parse_changed_lines(diff_text: str) -> dict[str, set[int]]: + changed: dict[str, set[int]] = {} + current_path: str | None = None + for raw_line in diff_text.splitlines(): + if raw_line.startswith("+++ b/"): + current_path = raw_line[6:] + continue + if raw_line.startswith("+++ /dev/null"): + current_path = None + continue + if not raw_line.startswith("@@") or current_path is None: + continue + match = re.search(r"\+(\d+)(?:,(\d+))?", raw_line) + if match is None: + continue + start = int(match.group(1)) + count = int(match.group(2) or "1") + if count > 0: + changed.setdefault(current_path, set()).update(range(start, start + count)) + return changed + + +def jacoco_relative_path(repository_path: str) -> str | None: + for root in SOURCE_ROOTS: + if repository_path.startswith(root): + return repository_path[len(root) :] + return None + + +def git_diff_command(base: str) -> list[str]: + return [ + "git", + "diff", + # Reformatting an executable line without changing its tokens must not turn an otherwise + # covered change set into a coverage failure. + "--ignore-all-space", + "--unified=0", + "--diff-filter=AM", + base, + "--", + *SOURCE_ROOTS, + ] + + +def git_diff(base: str) -> str: + command = git_diff_command(base) + result = subprocess.run(command, check=True, capture_output=True, text=True) + return result.stdout + + +def evaluate( + changed: dict[str, set[int]], + coverage: dict[tuple[str, int], CoverageLine], +) -> tuple[int, int, list[str]]: + executable = 0 + covered = 0 + missed: list[str] = [] + for repository_path, line_numbers in sorted(changed.items()): + source_path = jacoco_relative_path(repository_path) + if source_path is None: + continue + for line_number in sorted(line_numbers): + line = coverage.get((source_path, line_number)) + if line is None: + continue + executable += 1 + if line.covered: + covered += 1 + else: + missed.append(f"{repository_path}:{line_number}") + return covered, executable, missed + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--xml", type=pathlib.Path, required=True) + parser.add_argument("--base", required=True) + parser.add_argument("--threshold", type=float, default=0.80) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if not 0.0 <= args.threshold <= 1.0: + raise SystemExit("--threshold must be between 0 and 1") + if not args.xml.is_file(): + raise SystemExit(f"JaCoCo XML report not found: {args.xml}") + + coverage = parse_jacoco(args.xml) + changed = parse_changed_lines(git_diff(args.base)) + covered, executable, missed = evaluate(changed, coverage) + ratio = 1.0 if executable == 0 else covered / executable + + print( + f"Changed executable line coverage: {covered}/{executable} " + f"({ratio:.1%}), required {args.threshold:.1%}" + ) + if ratio >= args.threshold: + return 0 + + for location in missed[:50]: + print(f"UNCOVERED {location}") + if len(missed) > 50: + print(f"... and {len(missed) - 50} more uncovered executable lines") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/coverage/test_check_changed_coverage.py b/tools/coverage/test_check_changed_coverage.py new file mode 100644 index 00000000..7c452449 --- /dev/null +++ b/tools/coverage/test_check_changed_coverage.py @@ -0,0 +1,85 @@ +import pathlib +import tempfile +import unittest + +from tools.coverage.check_changed_coverage import ( + CoverageLine, + evaluate, + git_diff_command, + jacoco_relative_path, + parse_changed_lines, + parse_jacoco, +) + + +class ChangedCoverageToolTest(unittest.TestCase): + def test_parses_added_and_modified_hunks(self) -> None: + diff = """\ +diff --git a/app/src/main/java/example/Thing.kt b/app/src/main/java/example/Thing.kt +--- a/app/src/main/java/example/Thing.kt ++++ b/app/src/main/java/example/Thing.kt +@@ -1,0 +2,3 @@ ++a ++b ++c +@@ -9 +12 @@ +-old ++new +""" + self.assertEqual( + {"app/src/main/java/example/Thing.kt": {2, 3, 4, 12}}, + parse_changed_lines(diff), + ) + + def test_parses_jacoco_source_lines(self) -> None: + xml = """\ + + + + + + + + +""" + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "report.xml" + path.write_text(xml, encoding="utf-8") + parsed = parse_jacoco(path) + + self.assertTrue(parsed[("example/Thing.kt", 2)].covered) + self.assertFalse(parsed[("example/Thing.kt", 3)].covered) + + def test_evaluates_only_executable_changed_lines(self) -> None: + changed = {"app/src/main/java/example/Thing.kt": {1, 2, 3}} + coverage = { + ("example/Thing.kt", 2): CoverageLine(0, 1), + ("example/Thing.kt", 3): CoverageLine(1, 0), + } + + self.assertEqual( + (1, 2, ["app/src/main/java/example/Thing.kt:3"]), + evaluate(changed, coverage), + ) + + def test_maps_both_supported_source_roots(self) -> None: + self.assertEqual( + "example/Thing.kt", + jacoco_relative_path("app/src/main/java/example/Thing.kt"), + ) + self.assertEqual( + "example/Thing.kt", + jacoco_relative_path("app/src/main/kotlin/example/Thing.kt"), + ) + self.assertIsNone(jacoco_relative_path("app/src/test/example/Thing.kt")) + + def test_changed_line_diff_ignores_formatting_only_edits(self) -> None: + command = git_diff_command("origin/main") + + self.assertIn("--ignore-all-space", command) + self.assertIn("--unified=0", command) + self.assertEqual("origin/main", command[command.index("--diff-filter=AM") + 1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/release_gate/__init__.py b/tools/release_gate/__init__.py new file mode 100644 index 00000000..e9ab0855 --- /dev/null +++ b/tools/release_gate/__init__.py @@ -0,0 +1 @@ +"""Physical-device and cross-client release-gate tooling.""" diff --git a/tools/release_gate/android_lab.py b/tools/release_gate/android_lab.py new file mode 100644 index 00000000..165bb469 --- /dev/null +++ b/tools/release_gate/android_lab.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""USB/ADB control helpers for the physical release gate. + +Device selectors are accepted only as ephemeral command inputs. They are never +printed or written to artifacts; all output uses the operator-assigned alias. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Callable + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +if str(REPOSITORY_ROOT) not in sys.path: + sys.path.insert(0, str(REPOSITORY_ROOT)) + +from tools.release_gate.release_gate import ( + GateError, + SAFE_ID_RE, + append_trace_event, +) + + +APPLICATION_ID = "com.bitchat.droid" + + +def find_adb() -> str: + direct = shutil.which("adb") + if direct: + return direct + android_home = os.environ.get("ANDROID_HOME") + if android_home: + candidate = Path(android_home) / "platform-tools" / "adb" + if candidate.is_file(): + return str(candidate) + raise GateError("adb was not found; set ANDROID_HOME or add adb to PATH") + + +def run_adb( + serial: str, + arguments: list[str], + *, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, +) -> str: + result = runner( + [find_adb(), "-s", serial, *arguments], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise GateError("ADB command failed for the selected logical device") + return result.stdout.strip() + + +def count_connected_devices( + *, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, +) -> int: + result = runner( + [find_adb(), "devices"], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise GateError("could not enumerate ADB devices") + return sum( + 1 + for line in result.stdout.splitlines()[1:] + if line.strip().endswith("\tdevice") + ) + + +def probe_device(serial: str, alias: str) -> dict[str, object]: + if not SAFE_ID_RE.fullmatch(alias): + raise GateError("device alias must be a lowercase logical identifier") + api_text = run_adb(serial, ["shell", "getprop", "ro.build.version.sdk"]) + if not api_text.isdigit(): + raise GateError("selected device returned an invalid API level") + features = run_adb(serial, ["shell", "pm", "list", "features"]) + manufacturer = run_adb( + serial, ["shell", "getprop", "ro.product.manufacturer"] + ).strip().lower() + model = run_adb(serial, ["shell", "getprop", "ro.product.model"]).strip() + capabilities = ["ble-central", "ble-peripheral"] + if "android.hardware.wifi.aware" in features: + capabilities.append("wifi-aware") + return { + "alias": alias, + "platform": "android", + "model": model, + "manufacturer_class": re.sub(r"[^a-z0-9._-]", "-", manufacturer)[:64], + "api_level": int(api_text), + "physical": True, + "capabilities": capabilities, + } + + +def prepare_disposable_device(serial: str, confirmed: bool) -> None: + if not confirmed: + raise GateError("prepare requires --confirm-disposable-app-data") + run_adb(serial, ["shell", "am", "force-stop", APPLICATION_ID]) + output = run_adb(serial, ["shell", "pm", "clear", APPLICATION_ID]) + if "Success" not in output: + raise GateError("could not clear disposable app data") + + +def collect_resource_snapshot(serial: str) -> dict[str, int | bool]: + pid_text = run_adb(serial, ["shell", "pidof", APPLICATION_ID]) + pid = pid_text.split()[0] if pid_text else "" + metrics: dict[str, int | bool] = {"process-running": bool(pid)} + if not pid.isdigit(): + return metrics + meminfo = run_adb(serial, ["shell", "dumpsys", "meminfo", APPLICATION_ID]) + total_match = re.search(r"TOTAL\s+(\d+)", meminfo) + metrics["total-pss-kb"] = int(total_match.group(1)) if total_match else -1 + thread_text = run_adb( + serial, + ["shell", "sh", "-c", f"find /proc/{pid}/task -mindepth 1 -maxdepth 1 | wc -l"], + ) + fd_text = run_adb( + serial, + ["shell", "sh", "-c", f"find /proc/{pid}/fd -mindepth 1 -maxdepth 1 | wc -l"], + ) + metrics["thread-count"] = int(thread_text) if thread_text.isdigit() else -1 + metrics["fd-count"] = int(fd_text) if fd_text.isdigit() else -1 + power = run_adb(serial, ["shell", "dumpsys", "power"]) + metrics["app-wakelock-count"] = sum( + 1 + for line in power.splitlines() + if APPLICATION_ID in line and "WakeLock" in line + ) + battery = run_adb(serial, ["shell", "dumpsys", "battery"]) + battery_level = re.search(r"^\s*level:\s*(\d+)", battery, re.MULTILINE) + metrics["battery-level-percent"] = ( + int(battery_level.group(1)) if battery_level else -1 + ) + return metrics + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + + commands.add_parser("count") + + probe = commands.add_parser("probe") + probe.add_argument("--serial", required=True, help=argparse.SUPPRESS) + probe.add_argument("--alias", required=True) + + prepare = commands.add_parser("prepare") + prepare.add_argument("--serial", required=True, help=argparse.SUPPRESS) + prepare.add_argument("--confirm-disposable-app-data", action="store_true") + + cleanup = commands.add_parser("cleanup") + cleanup.add_argument("--serial", required=True, help=argparse.SUPPRESS) + cleanup.add_argument("--confirm-disposable-app-data", action="store_true") + + snapshot = commands.add_parser("snapshot") + snapshot.add_argument("--serial", required=True, help=argparse.SUPPRESS) + snapshot.add_argument("--alias", required=True) + snapshot.add_argument("--run", type=Path, required=True) + snapshot.add_argument("--scenario", required=True) + snapshot.add_argument("--event", default="resource-snapshot") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.command == "count": + print(json.dumps({"authorized-device-count": count_connected_devices()})) + elif args.command == "probe": + print(json.dumps(probe_device(args.serial, args.alias), sort_keys=True)) + elif args.command in {"prepare", "cleanup"}: + prepare_disposable_device( + args.serial, args.confirm_disposable_app_data + ) + print(json.dumps({"status": "clean", "application": APPLICATION_ID})) + elif args.command == "snapshot": + metrics = collect_resource_snapshot(args.serial) + append_trace_event( + args.run, + args.scenario, + args.alias, + args.event, + "observed", + None, + metrics, + ) + print(json.dumps({"source_alias": args.alias, "metrics": metrics}, sort_keys=True)) + return 0 + except (GateError, OSError, subprocess.SubprocessError) as error: + print(f"android lab error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release_gate/device-matrix.example.json b/tools/release_gate/device-matrix.example.json new file mode 100644 index 00000000..01b8f629 --- /dev/null +++ b/tools/release_gate/device-matrix.example.json @@ -0,0 +1,72 @@ +{ + "schema_version": 1, + "commit": "0000000000000000000000000000000000000000", + "clients": { + "android-current": { + "version": "replace-with-release-candidate", + "commit": "0000000000000000000000000000000000000000" + }, + "android-legacy": { + "version": "replace-with-last-supported-release" + }, + "ios-current": { + "version": "replace-with-current-ios-release" + } + }, + "lab_capabilities": ["local-relay", "tor"], + "devices": [ + { + "alias": "android-low", + "platform": "android", + "model": "replace-with-model", + "manufacturer_class": "manufacturer-a", + "api_level": 28, + "physical": true, + "capabilities": ["ble-central", "ble-peripheral"] + }, + { + "alias": "android-current", + "platform": "android", + "model": "replace-with-model", + "manufacturer_class": "manufacturer-b", + "api_level": 35, + "physical": true, + "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"] + }, + { + "alias": "android-relay", + "platform": "android", + "model": "replace-with-model", + "manufacturer_class": "manufacturer-c", + "api_level": 33, + "physical": true, + "capabilities": ["ble-central", "ble-peripheral"] + }, + { + "alias": "android-aware", + "platform": "android", + "model": "replace-with-model", + "manufacturer_class": "manufacturer-b", + "api_level": 35, + "physical": true, + "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"] + }, + { + "alias": "android-legacy", + "platform": "android", + "model": "replace-with-model", + "manufacturer_class": "manufacturer-a", + "api_level": 28, + "physical": true, + "capabilities": ["ble-central", "ble-peripheral"] + }, + { + "alias": "ios-current", + "platform": "ios", + "model": "replace-with-model", + "manufacturer_class": "apple", + "physical": true, + "capabilities": ["ble-central", "ble-peripheral"] + } + ] +} diff --git a/tools/release_gate/release_gate.py b/tools/release_gate/release_gate.py new file mode 100644 index 00000000..fbec7e32 --- /dev/null +++ b/tools/release_gate/release_gate.py @@ -0,0 +1,744 @@ +#!/usr/bin/env python3 +"""Create, record, validate, and archive the physical release gate. + +The host-side CLI is the control channel. It coordinates operators over USB or +local files and never sends control traffic through the mesh under test. +Artifacts intentionally contain logical device aliases and aggregate evidence, +not device identifiers, addresses, peer IDs, or message contents. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import re +import sys +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + + +SCHEMA_VERSION = 1 +RESULT_STATUSES = {"pending", "pass", "fail", "blocked", "unsupported"} +COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") +DISALLOWED_KEYS = { + "serial", + "serial_number", + "udid", + "imei", + "bluetooth_address", + "mac_address", + "ip_address", + "peer_id", + "username", + "user_name", + "email", + "account", + "device_name", +} +HASH_VALUE_KEYS = { + "commit", + "sha256", + "digest", + "scenario_manifest_sha256", + "fixture_sha256", + "vector_manifest_digest", + "corpus_digest", +} +SENSITIVE_PATTERNS = ( + re.compile(r"(?:^|[\s/])Users/[^/\s]+"), + re.compile(r"(?:^|[\s/])home/[^/\s]+"), + re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b"), + re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), + re.compile(r"\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b"), + re.compile(r"\b[0-9A-Fa-f]{16,}\b"), +) +MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 + + +class GateError(ValueError): + """A release-gate artifact violated an executable contract.""" + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def load_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise GateError(f"could not read JSON {path.name}: {error}") from error + if not isinstance(value, dict): + raise GateError(f"{path.name} must contain a JSON object") + return value + + +def write_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text( + json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + temporary.replace(path) + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def canonical_json_digest(value: dict[str, Any]) -> str: + encoded = json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + return sha256_bytes(encoded) + + +def validate_privacy(value: Any, path: tuple[str, ...] = ()) -> None: + if isinstance(value, dict): + for key, child in value.items(): + normalized = str(key).lower() + if normalized in DISALLOWED_KEYS: + raise GateError(f"disallowed identifying field: {'.'.join(path + (str(key),))}") + validate_privacy(child, path + (str(key),)) + return + if isinstance(value, list): + for index, child in enumerate(value): + validate_privacy(child, path + (str(index),)) + return + if not isinstance(value, str): + return + final_key = path[-1].lower().replace("-", "_") if path else "" + for index, pattern in enumerate(SENSITIVE_PATTERNS): + if index == len(SENSITIVE_PATTERNS) - 1 and ( + final_key in HASH_VALUE_KEYS + or final_key.endswith("_digest") + or final_key.endswith("_sha256") + ): + continue + if pattern.search(value): + raise GateError(f"potential identifying value at {'.'.join(path)}") + + +def validate_manifest(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]: + if manifest.get("schema_version") != SCHEMA_VERSION: + raise GateError("unsupported scenario schema_version") + scenarios = manifest.get("scenarios") + if not isinstance(scenarios, list) or not scenarios: + raise GateError("scenario manifest must contain scenarios") + by_id: dict[str, dict[str, Any]] = {} + for scenario in scenarios: + if not isinstance(scenario, dict): + raise GateError("each scenario must be an object") + scenario_id = scenario.get("id") + if not isinstance(scenario_id, str) or not re.fullmatch( + r"[A-Z0-9]{3}-\d{3}", scenario_id + ): + raise GateError(f"invalid scenario id: {scenario_id!r}") + if scenario_id in by_id: + raise GateError(f"duplicate scenario id: {scenario_id}") + if scenario.get("category") not in { + "transport", + "android-platform", + "android-to-android", + "cross-client", + "endurance", + }: + raise GateError(f"invalid category for {scenario_id}") + if scenario.get("required") is not True: + raise GateError(f"release scenario {scenario_id} must be required") + if not scenario.get("participants") or not scenario.get("evidence"): + raise GateError(f"scenario {scenario_id} lacks participants or evidence") + by_id[scenario_id] = scenario + validate_privacy(manifest) + return by_id + + +def validate_matrix( + matrix: dict[str, Any], + expected_commit: str | None = None, + *, + allow_placeholders: bool = False, +) -> dict[str, dict[str, Any]]: + if matrix.get("schema_version") != SCHEMA_VERSION: + raise GateError("unsupported matrix schema_version") + commit = matrix.get("commit") + if not isinstance(commit, str) or not COMMIT_RE.fullmatch(commit): + raise GateError("matrix commit must be a lowercase full Git commit") + if expected_commit is not None and commit != expected_commit: + raise GateError("matrix commit does not match the release candidate") + clients = matrix.get("clients") + required_clients = {"android-current", "android-legacy", "ios-current"} + if not isinstance(clients, dict) or not required_clients.issubset(clients): + raise GateError("matrix must version current Android, legacy Android, and current iOS") + for client_name in required_clients: + client = clients.get(client_name) + if not isinstance(client, dict) or not isinstance(client.get("version"), str): + raise GateError(f"{client_name} must declare a version") + if not allow_placeholders and client["version"].startswith("replace-with-"): + raise GateError(f"{client_name} still contains a template version") + current_commit = clients["android-current"].get("commit") + if current_commit != commit and not (allow_placeholders and commit == "0" * 40): + raise GateError("current Android client commit must match the matrix commit") + lab_capabilities = matrix.get("lab_capabilities") + if not isinstance(lab_capabilities, list) or any( + not isinstance(capability, str) or not SAFE_ID_RE.fullmatch(capability) + for capability in lab_capabilities + ): + raise GateError("matrix must declare logical lab_capabilities") + devices = matrix.get("devices") + if not isinstance(devices, list): + raise GateError("matrix devices must be a list") + by_alias: dict[str, dict[str, Any]] = {} + for device in devices: + if not isinstance(device, dict): + raise GateError("each device must be an object") + alias = device.get("alias") + if not isinstance(alias, str) or not SAFE_ID_RE.fullmatch(alias): + raise GateError(f"invalid logical device alias: {alias!r}") + if alias in by_alias: + raise GateError(f"duplicate device alias: {alias}") + if device.get("physical") is not True: + raise GateError(f"{alias} is not a physical device") + if device.get("platform") not in {"android", "ios"}: + raise GateError(f"{alias} has an unsupported platform") + if not isinstance(device.get("capabilities"), list): + raise GateError(f"{alias} must declare capabilities") + if not allow_placeholders and ( + not isinstance(device.get("model"), str) + or device["model"].startswith("replace-with-") + ): + raise GateError(f"{alias} still contains template values") + by_alias[alias] = device + android = [device for device in devices if device.get("platform") == "android"] + if len(android) < 3: + raise GateError("three physical Android devices are required for relay scenarios") + api_levels = {device.get("api_level") for device in android} + manufacturers = {device.get("manufacturer_class") for device in android} + if len(api_levels) < 2 or not all(isinstance(level, int) for level in api_levels): + raise GateError("Android matrix must cover at least two API levels") + if len(manufacturers) < 2 or None in manufacturers: + raise GateError("Android matrix must cover at least two manufacturer classes") + for device in android: + capabilities = set(device["capabilities"]) + if not {"ble-central", "ble-peripheral"}.issubset(capabilities): + raise GateError(f"{device['alias']} lacks required BLE roles") + if not any("wifi-aware" in device["capabilities"] for device in android): + raise GateError("at least one Android device must support Wi-Fi Aware") + if not any(device.get("platform") == "ios" for device in devices): + raise GateError("a physical iOS device is required") + validate_privacy(matrix) + return by_alias + + +def _fixture_bytes(seed: bytes, size: int) -> bytes: + output = bytearray() + counter = 0 + while len(output) < size: + output.extend(hashlib.sha256(seed + counter.to_bytes(4, "big")).digest()) + counter += 1 + return bytes(output[:size]) + + +def create_fixtures(directory: Path, run_id: str) -> dict[str, Any]: + fixture_directory = directory / "fixtures" + fixture_directory.mkdir() + definitions = ( + ("empty.bin", 0, False), + ("small.bin", 4 * 1024, False), + ("lab-résumé-秘密.bin", 256 * 1024, False), + ("maximum.bin", MAX_FILE_SIZE_BYTES, True), + ("oversized.bin", MAX_FILE_SIZE_BYTES + 1, True), + ) + fixtures: list[dict[str, Any]] = [] + for name, size, sparse in definitions: + path = fixture_directory / name + if sparse: + with path.open("wb") as stream: + stream.truncate(size) + else: + path.write_bytes(_fixture_bytes(run_id.encode("utf-8"), size)) + fixtures.append( + { + "name": name, + "size_bytes": size, + "sparse": sparse, + "fixture_sha256": sha256_file(path), + } + ) + manifest = {"schema_version": SCHEMA_VERSION, "fixtures": fixtures} + write_json(fixture_directory / "manifest.json", manifest) + return manifest + + +def initialize_run( + manifest: dict[str, Any], + matrix: dict[str, Any], + output: Path, + commit: str, + run_id: str, + *, + started_at: str | None = None, +) -> dict[str, Any]: + scenarios = validate_manifest(manifest) + devices = validate_matrix(matrix, commit) + if not SAFE_ID_RE.fullmatch(run_id): + raise GateError("run id must be a non-identifying lowercase logical id") + missing_aliases = { + participant + for scenario in scenarios.values() + for participant in scenario["participants"] + if participant not in devices + } + if missing_aliases: + raise GateError(f"matrix lacks scenario aliases: {sorted(missing_aliases)}") + for scenario_id, scenario in scenarios.items(): + available = set(matrix["lab_capabilities"]) + for participant in scenario["participants"]: + available.update(devices[participant]["capabilities"]) + missing_capabilities = set(scenario["capabilities"]) - available + if missing_capabilities: + raise GateError( + f"{scenario_id} lacks capabilities: {sorted(missing_capabilities)}" + ) + output.mkdir(parents=True, exist_ok=False) + write_json(output / "manifest.json", manifest) + write_json(output / "matrix.json", matrix) + fixtures = create_fixtures(output, run_id) + results = { + "schema_version": SCHEMA_VERSION, + "run_id": run_id, + "commit": commit, + "started_at": started_at or utc_now(), + "completed_at": None, + "scenario_manifest_sha256": canonical_json_digest(manifest), + "fixture_manifest_sha256": canonical_json_digest(fixtures), + "scenario_results": { + scenario_id: { + "status": "pending", + "participants": scenario["participants"], + "evidence": {}, + "reason_code": None, + "updated_at": None, + "history": [], + } + for scenario_id, scenario in scenarios.items() + }, + } + write_json(output / "results.json", results) + (output / "trace.jsonl").write_text("", encoding="utf-8") + return results + + +def _parse_scalar(value: str) -> Any: + lowered = value.lower() + if lowered in {"true", "false"}: + return lowered == "true" + try: + return int(value) + except ValueError: + try: + return float(value) + except ValueError: + return value + + +def parse_evidence(values: Iterable[str]) -> dict[str, Any]: + evidence: dict[str, Any] = {} + for value in values: + if "=" not in value: + raise GateError("evidence must use key=value") + key, raw = value.split("=", 1) + if not SAFE_ID_RE.fullmatch(key): + raise GateError(f"invalid evidence key: {key!r}") + evidence[key] = _parse_scalar(raw) + validate_privacy(evidence) + return evidence + + +def record_result( + run_directory: Path, + scenario_id: str, + status: str, + evidence: dict[str, Any], + reason_code: str | None, + *, + updated_at: str | None = None, +) -> dict[str, Any]: + manifest = load_json(run_directory / "manifest.json") + scenarios = validate_manifest(manifest) + if scenario_id not in scenarios: + raise GateError(f"unknown scenario: {scenario_id}") + if status not in RESULT_STATUSES - {"pending"}: + raise GateError(f"invalid terminal status: {status}") + if reason_code is not None and not SAFE_ID_RE.fullmatch(reason_code): + raise GateError("reason code must be a non-identifying stable code") + if status == "pass" and not evidence: + raise GateError("passing a scenario requires structured evidence") + if status == "pass" and reason_code is not None: + raise GateError("passing a scenario cannot have a failure reason code") + if status in {"fail", "blocked", "unsupported"} and reason_code is None: + raise GateError(f"{status} requires a stable reason code") + if any(not SAFE_ID_RE.fullmatch(str(key)) for key in evidence): + raise GateError("evidence keys must be stable logical identifiers") + validate_privacy(evidence) + results = load_json(run_directory / "results.json") + result = results["scenario_results"][scenario_id] + terminal_update = { + "status": status, + "evidence": evidence, + "reason_code": reason_code, + "updated_at": updated_at or utc_now(), + } + result.setdefault("history", []).append(terminal_update.copy()) + result.update( + terminal_update + ) + results["completed_at"] = ( + result["updated_at"] + if all( + item.get("status") == "pass" + for item in results["scenario_results"].values() + ) + else None + ) + write_json(run_directory / "results.json", results) + return results + + +def append_trace_event( + run_directory: Path, + scenario_id: str, + source_alias: str, + event: str, + outcome: str, + reason_code: str | None, + metrics: dict[str, Any], + *, + timestamp: str | None = None, +) -> dict[str, Any]: + manifest = load_json(run_directory / "manifest.json") + matrix = load_json(run_directory / "matrix.json") + scenarios = validate_manifest(manifest) + devices = validate_matrix(matrix, allow_placeholders=False) + if scenario_id not in scenarios: + raise GateError(f"unknown scenario: {scenario_id}") + if source_alias not in devices: + raise GateError(f"unknown source alias: {source_alias}") + for label, value in (("event", event), ("outcome", outcome)): + if not SAFE_ID_RE.fullmatch(value): + raise GateError(f"invalid {label}") + if reason_code is not None and not SAFE_ID_RE.fullmatch(reason_code): + raise GateError("invalid reason code") + if any(not SAFE_ID_RE.fullmatch(str(key)) for key in metrics): + raise GateError("trace metric keys must be stable logical identifiers") + if any( + not isinstance(value, (int, float, bool)) + or isinstance(value, float) and not math.isfinite(value) + for value in metrics.values() + ): + raise GateError("trace metrics must be numeric or boolean aggregates") + trace = { + "timestamp": timestamp or utc_now(), + "scenario_id": scenario_id, + "source_alias": source_alias, + "event": event, + "outcome": outcome, + "reason_code": reason_code, + "metrics": metrics, + } + validate_privacy(trace) + with (run_directory / "trace.jsonl").open("a", encoding="utf-8") as stream: + stream.write(json.dumps(trace, sort_keys=True, ensure_ascii=False) + "\n") + return trace + + +def _validate_trace( + run_directory: Path, + scenarios: dict[str, dict[str, Any]], + devices: dict[str, dict[str, Any]], +) -> tuple[int, set[str]]: + path = run_directory / "trace.jsonl" + if not path.exists(): + raise GateError("trace.jsonl is missing") + count = 0 + traced_scenarios: set[str] = set() + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + try: + event = json.loads(line) + except json.JSONDecodeError as error: + raise GateError(f"invalid trace line {line_number}") from error + if not isinstance(event, dict): + raise GateError(f"trace line {line_number} must be an object") + if event.get("scenario_id") not in scenarios: + raise GateError(f"trace line {line_number} has an unknown scenario") + if event.get("source_alias") not in devices: + raise GateError(f"trace line {line_number} has an unknown source") + if set(event) != { + "timestamp", + "scenario_id", + "source_alias", + "event", + "outcome", + "reason_code", + "metrics", + }: + raise GateError(f"trace line {line_number} has unexpected fields") + if not isinstance(event.get("metrics"), dict) or any( + not isinstance(value, (int, float, bool)) + for value in event["metrics"].values() + ): + raise GateError(f"trace line {line_number} has invalid metrics") + validate_privacy(event) + count += 1 + traced_scenarios.add(event["scenario_id"]) + return count, traced_scenarios + + +def validate_run(run_directory: Path, *, allow_incomplete: bool = False) -> dict[str, Any]: + manifest = load_json(run_directory / "manifest.json") + matrix = load_json(run_directory / "matrix.json") + results = load_json(run_directory / "results.json") + scenarios = validate_manifest(manifest) + devices = validate_matrix(matrix, results.get("commit")) + if results.get("schema_version") != SCHEMA_VERSION: + raise GateError("unsupported results schema_version") + if results.get("scenario_manifest_sha256") != canonical_json_digest(manifest): + raise GateError("scenario manifest changed after run initialization") + fixture_manifest = load_json(run_directory / "fixtures" / "manifest.json") + if results.get("fixture_manifest_sha256") != canonical_json_digest(fixture_manifest): + raise GateError("fixture manifest changed after run initialization") + actual = results.get("scenario_results") + if not isinstance(actual, dict) or set(actual) != set(scenarios): + raise GateError("results must contain exactly every declared scenario") + validate_privacy(results) + for scenario_id, scenario in scenarios.items(): + result = actual[scenario_id] + if result.get("participants") != scenario["participants"]: + raise GateError(f"{scenario_id} participants changed") + if any(alias not in devices for alias in result["participants"]): + raise GateError(f"{scenario_id} references an unknown device") + available = set(matrix["lab_capabilities"]) + for participant in result["participants"]: + available.update(devices[participant]["capabilities"]) + missing_capabilities = set(scenario["capabilities"]) - available + if missing_capabilities: + raise GateError( + f"{scenario_id} lacks capabilities: {sorted(missing_capabilities)}" + ) + status = result.get("status") + if status not in RESULT_STATUSES: + raise GateError(f"{scenario_id} has invalid status") + if not allow_incomplete and status != "pass": + raise GateError(f"{scenario_id} is not passing: {status}") + if status == "pass": + evidence = result.get("evidence") + missing = set(scenario["evidence"]) - set(evidence or {}) + if missing: + raise GateError(f"{scenario_id} lacks evidence: {sorted(missing)}") + if scenario.get("minimum_duration_minutes") is not None and ( + evidence.get("duration-minutes", 0) + < scenario["minimum_duration_minutes"] + ): + raise GateError(f"{scenario_id} did not meet minimum duration") + if scenario.get("minimum_cycles") is not None and ( + evidence.get("cycle-count", 0) < scenario["minimum_cycles"] + ): + raise GateError(f"{scenario_id} did not meet minimum cycles") + trace_events, traced_scenarios = _validate_trace(run_directory, scenarios, devices) + if not allow_incomplete: + missing_traces = set(scenarios) - traced_scenarios + if missing_traces: + raise GateError( + f"passing scenarios lack structured traces: {sorted(missing_traces)}" + ) + if not results.get("completed_at"): + raise GateError("complete results must record completed_at") + summary = { + status: sum( + 1 for result in actual.values() if result.get("status") == status + ) + for status in sorted(RESULT_STATUSES) + } + summary["trace_events"] = trace_events + summary["complete"] = all( + result.get("status") == "pass" for result in actual.values() + ) + return summary + + +def render_summary(run_directory: Path) -> str: + results = load_json(run_directory / "results.json") + summary = validate_run(run_directory, allow_incomplete=True) + rows = [ + "# Physical release-gate result", + "", + f"- Run: `{results['run_id']}`", + f"- Commit: `{results['commit']}`", + f"- Complete: `{str(summary['complete']).lower()}`", + f"- Trace events: {summary['trace_events']}", + "", + "| Status | Count |", + "|---|---:|", + ] + rows.extend( + f"| {status} | {summary[status]} |" for status in sorted(RESULT_STATUSES) + ) + return "\n".join(rows) + "\n" + + +def create_bundle(run_directory: Path, output: Path) -> None: + validate_run(run_directory) + if output.exists(): + raise GateError("refusing to overwrite an existing release-gate bundle") + members = [ + Path("manifest.json"), + Path("matrix.json"), + Path("results.json"), + Path("trace.jsonl"), + Path("fixtures/manifest.json"), + ] + generated = {"summary.md": render_summary(run_directory).encode("utf-8")} + checksums: list[str] = [] + payloads: dict[str, bytes] = {} + for member in members: + payload = (run_directory / member).read_bytes() + payloads[member.as_posix()] = payload + payloads.update(generated) + for name in sorted(payloads): + checksums.append(f"{sha256_bytes(payloads[name])} {name}") + payloads["SHA256SUMS"] = ("\n".join(checksums) + "\n").encode("utf-8") + output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name in sorted(payloads): + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + archive.writestr(info, payloads[name]) + + +def _default_manifest() -> Path: + return Path(__file__).with_name("scenarios.json") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + manifest = subparsers.add_parser("validate-manifest") + manifest.add_argument("--manifest", type=Path, default=_default_manifest()) + + matrix = subparsers.add_parser("validate-matrix") + matrix.add_argument("--matrix", type=Path, required=True) + matrix.add_argument("--commit") + matrix.add_argument("--allow-template", action="store_true") + + initialize = subparsers.add_parser("init") + initialize.add_argument("--manifest", type=Path, default=_default_manifest()) + initialize.add_argument("--matrix", type=Path, required=True) + initialize.add_argument("--output", type=Path, required=True) + initialize.add_argument("--commit", required=True) + initialize.add_argument("--run-id", required=True) + + record = subparsers.add_parser("record") + record.add_argument("--run", type=Path, required=True) + record.add_argument("--scenario", required=True) + record.add_argument("--status", choices=sorted(RESULT_STATUSES - {"pending"}), required=True) + record.add_argument("--evidence", action="append", default=[]) + record.add_argument("--reason-code") + + trace = subparsers.add_parser("trace") + trace.add_argument("--run", type=Path, required=True) + trace.add_argument("--scenario", required=True) + trace.add_argument("--source", required=True) + trace.add_argument("--event", required=True) + trace.add_argument("--outcome", required=True) + trace.add_argument("--reason-code") + trace.add_argument("--metric", action="append", default=[]) + + validate = subparsers.add_parser("validate") + validate.add_argument("--run", type=Path, required=True) + validate.add_argument("--allow-incomplete", action="store_true") + + summary = subparsers.add_parser("summary") + summary.add_argument("--run", type=Path, required=True) + + bundle = subparsers.add_parser("bundle") + bundle.add_argument("--run", type=Path, required=True) + bundle.add_argument("--output", type=Path, required=True) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.command == "validate-manifest": + scenarios = validate_manifest(load_json(args.manifest)) + print(f"valid scenarios: {len(scenarios)}") + elif args.command == "validate-matrix": + devices = validate_matrix( + load_json(args.matrix), + args.commit, + allow_placeholders=args.allow_template, + ) + print(f"valid devices: {len(devices)}") + elif args.command == "init": + initialize_run( + load_json(args.manifest), + load_json(args.matrix), + args.output, + args.commit, + args.run_id, + ) + print(args.output) + elif args.command == "record": + record_result( + args.run, + args.scenario, + args.status, + parse_evidence(args.evidence), + args.reason_code, + ) + elif args.command == "trace": + append_trace_event( + args.run, + args.scenario, + args.source, + args.event, + args.outcome, + args.reason_code, + parse_evidence(args.metric), + ) + elif args.command == "validate": + print( + json.dumps( + validate_run(args.run, allow_incomplete=args.allow_incomplete), + sort_keys=True, + ) + ) + elif args.command == "summary": + print(render_summary(args.run), end="") + elif args.command == "bundle": + create_bundle(args.run, args.output) + print(args.output) + return 0 + except GateError as error: + print(f"release gate error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release_gate/scenarios.json b/tools/release_gate/scenarios.json new file mode 100644 index 00000000..d9b50907 --- /dev/null +++ b/tools/release_gate/scenarios.json @@ -0,0 +1,251 @@ +{ + "schema_version": 1, + "scenario_version": "1.0", + "scenarios": [ + { + "id": "TRN-001", + "category": "transport", + "title": "Complete the physical BLE, GATT, MTU, Wi-Fi Aware, and failover matrix", + "participants": ["android-low", "android-current", "android-aware"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"], + "evidence": ["matrix-check-count", "mtu-case-count", "failure-injection-count", "shutdown-check-count"] + }, + { + "id": "AND-001", + "category": "android-platform", + "title": "Complete API-level, manufacturer, permission-revocation, and background gates", + "participants": ["android-low", "android-current", "android-aware"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"], + "evidence": ["api-level-count", "manufacturer-count", "permission-revocation-count", "background-case-count"] + }, + { + "id": "A2A-001", + "category": "android-to-android", + "title": "Discover, connect, announce, disconnect, and reconnect", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["connection-transitions", "packet-correlation-count", "failure-reasons"] + }, + { + "id": "A2A-002", + "category": "android-to-android", + "title": "Exchange public and private messages in both directions", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["message-counts", "packet-correlation-count"] + }, + { + "id": "A2A-003", + "category": "android-to-android", + "title": "Advance delivery and read receipts", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["receipt-transitions"] + }, + { + "id": "A2A-004", + "category": "android-to-android", + "title": "Transfer image, audio, and generic files at multiple sizes", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["fixture-digests", "transfer-progress", "delivery-digests"] + }, + { + "id": "A2A-005", + "category": "android-to-android", + "title": "Relay across three Android devices", + "participants": ["android-low", "android-current", "android-relay"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["route-transitions", "ttl-values", "packet-correlation-count"] + }, + { + "id": "A2A-006", + "category": "android-to-android", + "title": "Partition and recover store-and-forward delivery", + "participants": ["android-low", "android-current", "android-relay"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["partition-window", "queue-counts", "delivery-counts"] + }, + { + "id": "A2A-007", + "category": "android-to-android", + "title": "Toggle Bluetooth during active transfers", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["radio-transitions", "transfer-terminal-state"] + }, + { + "id": "A2A-008", + "category": "android-to-android", + "title": "Lock screens and background both apps during mesh operation", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["lifecycle-transitions", "service-state", "delivery-counts"] + }, + { + "id": "A2A-009", + "category": "android-to-android", + "title": "Kill and recreate one process", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["process-generation", "restored-state", "reconnect-count"] + }, + { + "id": "A2A-010", + "category": "android-to-android", + "title": "Resolve simultaneous and duplicate links", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["candidate-links", "canonical-link-count"] + }, + { + "id": "A2A-011", + "category": "android-to-android", + "title": "Fail over through Wi-Fi Aware", + "participants": ["android-current", "android-aware"], + "required": true, + "capabilities": ["wifi-aware"], + "evidence": ["transport-selection", "failover-window", "delivery-counts"] + }, + { + "id": "XCL-001", + "category": "cross-client", + "title": "Current Android interoperates with current iOS", + "participants": ["android-current", "ios-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["client-versions", "payload-counts"] + }, + { + "id": "XCL-002", + "category": "cross-client", + "title": "Current rewrite contracts interoperate with the last supported Android", + "participants": ["android-current", "android-legacy"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["client-versions", "payload-counts"] + }, + { + "id": "XCL-003", + "category": "cross-client", + "title": "Legacy announcements without capabilities remain compatible", + "participants": ["android-current", "android-legacy"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["announcement-version", "peer-state"] + }, + { + "id": "XCL-004", + "category": "cross-client", + "title": "Older clients safely ignore current capability announcements", + "participants": ["android-current", "android-legacy"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["announcement-version", "peer-state"] + }, + { + "id": "XCL-005", + "category": "cross-client", + "title": "Canonical private media and prerelease decode-only alias interoperate", + "participants": ["android-current", "ios-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["wire-type", "delivery-digests", "downgrade-decision"] + }, + { + "id": "XCL-006", + "category": "cross-client", + "title": "Golden vectors match across implementations", + "participants": ["android-current", "ios-current", "android-legacy"], + "required": true, + "capabilities": [], + "evidence": ["vector-manifest-digest", "comparison-counts"] + }, + { + "id": "XCL-007", + "category": "cross-client", + "title": "Malformed and unauthenticated inputs are rejected consistently", + "participants": ["android-current", "ios-current", "android-legacy"], + "required": true, + "capabilities": [], + "evidence": ["corpus-digest", "rejection-counts", "crash-count"] + }, + { + "id": "XCL-008", + "category": "cross-client", + "title": "Nostr fallback messages and receipts interoperate", + "participants": ["android-current", "ios-current"], + "required": true, + "capabilities": ["local-relay", "tor"], + "evidence": ["relay-fixture", "event-id-count", "receipt-counts"] + }, + { + "id": "END-001", + "category": "endurance", + "title": "Multi-hour discovery, connect, and disconnect soak", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "minimum_duration_minutes": 240, + "evidence": ["duration-minutes", "connection-counts", "failure-counts"] + }, + { + "id": "END-002", + "category": "endurance", + "title": "Repeated large-transfer and cancellation cycles", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "minimum_cycles": 50, + "evidence": ["cycle-count", "delivery-counts", "cancellation-counts"] + }, + { + "id": "END-003", + "category": "endurance", + "title": "Resource growth remains bounded", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["memory-samples", "thread-samples", "fd-samples", "wakelock-samples", "battery-samples"] + }, + { + "id": "END-004", + "category": "endurance", + "title": "Foreground service survives with screens off", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["service-state", "screen-state", "delivery-counts"] + }, + { + "id": "END-005", + "category": "endurance", + "title": "Nostr survives network and Tor availability changes", + "participants": ["android-current"], + "required": true, + "capabilities": ["local-relay", "tor"], + "evidence": ["network-transitions", "tor-transitions", "receipt-counts"] + }, + { + "id": "END-006", + "category": "endurance", + "title": "Shutdown releases radios, sockets, jobs, and wake locks", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["resource-terminal-state", "late-callback-count"] + } + ] +} diff --git a/tools/release_gate/test_release_gate.py b/tools/release_gate/test_release_gate.py new file mode 100644 index 00000000..3ca39ac8 --- /dev/null +++ b/tools/release_gate/test_release_gate.py @@ -0,0 +1,367 @@ +import json +import subprocess +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest import mock + +from tools.release_gate import android_lab +from tools.release_gate.release_gate import ( + GateError, + append_trace_event, + canonical_json_digest, + create_bundle, + initialize_run, + load_json, + parse_evidence, + record_result, + validate_manifest, + validate_matrix, + validate_privacy, + validate_run, +) + + +COMMIT = "1" * 40 +TOOL_DIRECTORY = Path(__file__).parent + + +def valid_matrix(): + return { + "schema_version": 1, + "commit": COMMIT, + "clients": { + "android-current": {"version": "2.0.0-rc1", "commit": COMMIT}, + "android-legacy": {"version": "1.9.0"}, + "ios-current": {"version": "2.0.0"}, + }, + "lab_capabilities": ["local-relay", "tor"], + "devices": [ + { + "alias": "android-low", + "platform": "android", + "model": "model-low", + "manufacturer_class": "vendor-a", + "api_level": 28, + "physical": True, + "capabilities": ["ble-central", "ble-peripheral"], + }, + { + "alias": "android-current", + "platform": "android", + "model": "model-current", + "manufacturer_class": "vendor-b", + "api_level": 35, + "physical": True, + "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"], + }, + { + "alias": "android-relay", + "platform": "android", + "model": "model-relay", + "manufacturer_class": "vendor-c", + "api_level": 33, + "physical": True, + "capabilities": ["ble-central", "ble-peripheral"], + }, + { + "alias": "android-aware", + "platform": "android", + "model": "model-aware", + "manufacturer_class": "vendor-b", + "api_level": 35, + "physical": True, + "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"], + }, + { + "alias": "android-legacy", + "platform": "android", + "model": "model-legacy", + "manufacturer_class": "vendor-a", + "api_level": 28, + "physical": True, + "capabilities": ["ble-central", "ble-peripheral"], + }, + { + "alias": "ios-current", + "platform": "ios", + "model": "ios-model", + "manufacturer_class": "apple", + "physical": True, + "capabilities": ["ble-central", "ble-peripheral"], + }, + ], + } + + +class ReleaseGateTest(unittest.TestCase): + def setUp(self): + self.manifest = load_json(TOOL_DIRECTORY / "scenarios.json") + + def test_manifest_covers_every_release_category(self): + scenarios = validate_manifest(self.manifest) + self.assertEqual(27, len(scenarios)) + self.assertEqual( + { + "transport", + "android-platform", + "android-to-android", + "cross-client", + "endurance", + }, + {scenario["category"] for scenario in scenarios.values()}, + ) + + def test_documented_matrix_template_is_schema_valid_but_not_runnable(self): + template = load_json(TOOL_DIRECTORY / "device-matrix.example.json") + self.assertEqual(6, len(validate_matrix(template, allow_placeholders=True))) + with self.assertRaises(GateError): + validate_matrix(template) + + def test_matrix_enforces_distinct_api_manufacturer_and_counterpart_clients(self): + matrix = valid_matrix() + self.assertEqual(6, len(validate_matrix(matrix, COMMIT))) + + for device in matrix["devices"]: + if device["platform"] == "android": + device["manufacturer_class"] = "one-vendor" + with self.assertRaisesRegex(GateError, "manufacturer"): + validate_matrix(matrix, COMMIT) + + def test_privacy_policy_rejects_identifiers_paths_addresses_and_long_ids(self): + rejected = ( + {"serial": "device-selector"}, + {"note": "/" + "home/operator/result"}, + {"note": "operator@example.test"}, + {"note": "192.0.2.1"}, + {"note": "aa:bb:cc:dd:ee:ff"}, + {"note": "0123456789abcdef"}, + ) + for value in rejected: + with self.subTest(value=value), self.assertRaises(GateError): + validate_privacy(value) + validate_privacy({"commit": COMMIT, "packet-correlation-count": 3}) + + def test_initialize_creates_disposable_fixtures_and_pending_results(self): + with tempfile.TemporaryDirectory() as temporary: + run = Path(temporary) / "rc-run" + results = initialize_run( + self.manifest, + valid_matrix(), + run, + COMMIT, + "rc-run", + started_at="2026-01-01T00:00:00+00:00", + ) + self.assertTrue(all( + result["status"] == "pending" + for result in results["scenario_results"].values() + )) + fixtures = load_json(run / "fixtures" / "manifest.json")["fixtures"] + sizes = {fixture["name"]: fixture["size_bytes"] for fixture in fixtures} + self.assertEqual(0, sizes["empty.bin"]) + self.assertEqual(50 * 1024 * 1024, sizes["maximum.bin"]) + self.assertEqual(50 * 1024 * 1024 + 1, sizes["oversized.bin"]) + self.assertEqual( + results["scenario_manifest_sha256"], + canonical_json_digest(self.manifest), + ) + summary = validate_run(run, allow_incomplete=True) + self.assertEqual(27, summary["pending"]) + self.assertFalse(summary["complete"]) + + def test_record_requires_structured_evidence_and_rejects_pii(self): + with tempfile.TemporaryDirectory() as temporary: + run = Path(temporary) / "rc-run" + initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run") + with self.assertRaises(GateError): + record_result(run, "A2A-001", "pass", {}, None) + with self.assertRaises(GateError): + record_result( + run, + "A2A-001", + "pass", + {"connection-transitions": "operator@example.test"}, + None, + ) + with self.assertRaisesRegex(GateError, "reason code"): + record_result(run, "A2A-001", "fail", {}, None) + recorded = record_result( + run, + "A2A-001", + "blocked", + {}, + "counterpart-unavailable", + updated_at="2026-01-01T00:00:00+00:00", + ) + self.assertEqual( + ["blocked"], + [ + item["status"] + for item in recorded["scenario_results"]["A2A-001"]["history"] + ], + ) + + def test_complete_run_requires_all_evidence_traces_and_endurance_bounds(self): + with tempfile.TemporaryDirectory() as temporary: + run = Path(temporary) / "rc-run" + initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run") + scenarios = validate_manifest(self.manifest) + for scenario_id, scenario in scenarios.items(): + evidence = {key: 1 for key in scenario["evidence"]} + if "duration-minutes" in evidence: + evidence["duration-minutes"] = 240 + if "cycle-count" in evidence: + evidence["cycle-count"] = 50 + record_result( + run, + scenario_id, + "pass", + evidence, + None, + updated_at="2026-01-01T04:00:00+00:00", + ) + append_trace_event( + run, + scenario_id, + scenario["participants"][0], + "scenario-terminal", + "pass", + None, + {"assertion-count": len(evidence)}, + timestamp="2026-01-01T04:00:00+00:00", + ) + summary = validate_run(run) + self.assertTrue(summary["complete"]) + self.assertEqual(27, summary["pass"]) + self.assertEqual(27, summary["trace_events"]) + + bundle = Path(temporary) / "release-gate.zip" + create_bundle(run, bundle) + with zipfile.ZipFile(bundle) as archive: + self.assertEqual( + { + "SHA256SUMS", + "fixtures/manifest.json", + "manifest.json", + "matrix.json", + "results.json", + "summary.md", + "trace.jsonl", + }, + set(archive.namelist()), + ) + with self.assertRaisesRegex(GateError, "overwrite"): + create_bundle(run, bundle) + + def test_manifest_tampering_after_initialization_is_detected(self): + with tempfile.TemporaryDirectory() as temporary: + run = Path(temporary) / "rc-run" + initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run") + manifest = load_json(run / "manifest.json") + manifest["scenario_version"] = "tampered" + (run / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + with self.assertRaisesRegex(GateError, "changed"): + validate_run(run, allow_incomplete=True) + + def test_trace_accepts_only_aggregate_metrics(self): + with tempfile.TemporaryDirectory() as temporary: + run = Path(temporary) / "rc-run" + initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run") + with self.assertRaisesRegex(GateError, "numeric"): + append_trace_event( + run, + "A2A-001", + "android-current", + "packet", + "observed", + None, + {"raw-packet": "payload"}, + ) + + def test_evidence_parser_is_typed_and_privacy_checked(self): + self.assertEqual( + {"count": 3, "ratio": 0.5, "clean": True}, + parse_evidence(["count=3", "ratio=0.5", "clean=true"]), + ) + with self.assertRaises(GateError): + parse_evidence(["note=10.0.0.1"]) + + @mock.patch("tools.release_gate.android_lab.find_adb", return_value="adb") + def test_adb_device_count_never_returns_selectors(self, _find_adb): + completed = subprocess.CompletedProcess( + ["adb", "devices"], + 0, + "List of devices attached\nselector-one\tdevice\nselector-two\toffline\n", + "", + ) + count = android_lab.count_connected_devices(runner=lambda *args, **kwargs: completed) + self.assertEqual(1, count) + + @mock.patch("tools.release_gate.android_lab.run_adb") + def test_adb_probe_emits_only_logical_device_metadata(self, run_adb): + run_adb.side_effect = [ + "35", + "feature:android.hardware.wifi.aware", + "Vendor", + "Model", + ] + probe = android_lab.probe_device("ephemeral-selector", "android-current") + self.assertEqual("android-current", probe["alias"]) + self.assertNotIn("serial", probe) + self.assertIn("wifi-aware", probe["capabilities"]) + + @mock.patch("tools.release_gate.android_lab.run_adb") + def test_disposable_cleanup_targets_the_real_application_id(self, run_adb): + run_adb.side_effect = ["", "Success"] + + android_lab.prepare_disposable_device("ephemeral-selector", confirmed=True) + + self.assertEqual( + [ + mock.call( + "ephemeral-selector", + ["shell", "am", "force-stop", "com.bitchat.droid"], + ), + mock.call( + "ephemeral-selector", + ["shell", "pm", "clear", "com.bitchat.droid"], + ), + ], + run_adb.call_args_list, + ) + + @mock.patch("tools.release_gate.android_lab.run_adb") + def test_resource_snapshot_returns_only_aggregate_metrics(self, run_adb): + run_adb.side_effect = [ + "123", + "TOTAL 2048", + "7", + "11", + "WakeLock com.bitchat.droid\nWakeLock another.package", + "level: 73", + ] + metrics = android_lab.collect_resource_snapshot("ephemeral-selector") + self.assertEqual( + { + "process-running": True, + "total-pss-kb": 2048, + "thread-count": 7, + "fd-count": 11, + "app-wakelock-count": 1, + "battery-level-percent": 73, + }, + metrics, + ) + self.assertEqual( + mock.call( + "ephemeral-selector", + ["shell", "pidof", "com.bitchat.droid"], + ), + run_adb.call_args_list[0], + ) + + +if __name__ == "__main__": + unittest.main() From 3054cd801e39b4303e3722d0399944be15a14efd Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:27:40 +0200 Subject: [PATCH 35/70] Centralize background power scheduling --- .../com/bitchat/android/BitchatApplication.kt | 7 + .../mesh/BluetoothConnectionManager.kt | 69 +-- .../mesh/BluetoothGattClientManager.kt | 56 +- .../android/mesh/BluetoothMeshService.kt | 24 - .../java/com/bitchat/android/mesh/MeshCore.kt | 16 - .../com/bitchat/android/mesh/PowerManager.kt | 572 ++++++++++-------- .../android/mesh/UnifiedMeshService.kt | 34 ++ .../com/bitchat/android/net/ArtiTorManager.kt | 49 +- .../android/nostr/NostrBackgroundRuntime.kt | 213 +++++++ .../android/nostr/NostrRelayManager.kt | 167 +++-- .../android/nostr/NostrSubscriptionManager.kt | 29 +- .../android/service/AppShutdownCoordinator.kt | 2 + .../android/service/MeshForegroundService.kt | 65 +- .../bitchat/android/services/AppStateStore.kt | 16 +- .../com/bitchat/android/ui/ChatViewModel.kt | 30 +- .../bitchat/android/ui/GeohashViewModel.kt | 169 +----- .../com/bitchat/android/util/AppConstants.kt | 2 +- .../android/wifi-aware/SyncedSocket.kt | 7 +- .../android/wifi-aware/WifiAwareController.kt | 42 +- .../wifi-aware/WifiAwareMeshService.kt | 114 ++-- .../android/mesh/PowerProfileResolverTest.kt | 102 ++++ .../android/services/AppStateStoreTest.kt | 4 + 22 files changed, 1056 insertions(+), 733 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 282f3295..ecd32327 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -13,6 +13,9 @@ class BitchatApplication : Application() { override fun onCreate() { super.onCreate() + // Start the single process-wide power policy before transport components are constructed. + com.bitchat.android.mesh.PowerManager.getInstance(this).start() + // Initialize Tor first so any early network goes over Tor try { val torProvider = ArtiTorManager.getInstance() @@ -53,6 +56,10 @@ class BitchatApplication : Application() { com.bitchat.android.nostr.GeohashConversationRegistry.initialize(this) } catch (_: Exception) { } + // Own relay connectivity, selected-channel subscriptions, and presence scheduling at the + // process level so closing the Activity does not disconnect Nostr. + try { com.bitchat.android.nostr.NostrBackgroundRuntime.initialize(this) } catch (_: Exception) { } + // Initialize mesh service preferences try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 17e4ce28..35a3d9f4 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -18,7 +18,7 @@ class BluetoothConnectionManager( private val context: Context, private val myPeerID: String, private val fragmentManager: FragmentManager? = null -) : PowerManagerDelegate { +) { companion object { private const val TAG = "BluetoothConnectionManager" @@ -30,7 +30,7 @@ class BluetoothConnectionManager( private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter // Power management - private val powerManager = PowerManager(context.applicationContext) + private val powerManager = PowerManager.getInstance(context.applicationContext) // Coroutines private val connectionScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -117,7 +117,19 @@ class BluetoothConnectionManager( } init { - powerManager.delegate = this + connectionScope.launch { + var previousMode: PowerManager.PowerMode? = null + powerManager.profile.collect { profile -> + val modeChanged = previousMode != null && previousMode != profile.mode + previousMode = profile.mode + if (!isActive || !isBleTransportEnabled()) return@collect + + if (modeChanged && isGattServerEnabled()) { + serverManager.restartAdvertising() + } + clientManager.applyPowerProfile(profile) + } + } // Observe debug settings to enforce role state while active try { val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() @@ -294,9 +306,6 @@ class BluetoothConnectionManager( clientManager.stop() serverManager.stop() - // Stop power manager - powerManager.stop() - // Stop connection tracker connectionTracker.stop() @@ -451,54 +460,6 @@ class BluetoothConnectionManager( } } - // MARK: - PowerManagerDelegate Implementation - - override fun onPowerModeChanged(newMode: PowerManager.PowerMode) { - Log.i(TAG, "Power mode changed to: $newMode") - - connectionScope.launch { - if (!isActive || !isBleTransportEnabled()) { - serverManager.stop() - clientManager.stop() - return@launch - } - - // Avoid rapid scan restarts by checking if we need to change scan behavior - val wasUsingDutyCycle = powerManager.shouldUseDutyCycle() - - // Update advertising with new power settings if server enabled - val serverEnabled = isGattServerEnabled() - if (serverEnabled) { - serverManager.restartAdvertising() - } else { - serverManager.stop() - } - - // Only restart scanning if the duty cycle behavior changed - val nowUsingDutyCycle = powerManager.shouldUseDutyCycle() - if (wasUsingDutyCycle != nowUsingDutyCycle) { - val clientEnabled = isGattClientEnabled() - if (clientEnabled) { - clientManager.restartScanning() - } else { - clientManager.stop() - } - } - - // Enforce connection limits - enforceStrictLimits() - } - } - - override fun onScanStateChanged(shouldScan: Boolean) { - if (!isActive || !isBleTransportEnabled()) { - clientManager.onScanStateChanged(false) - return - } - clientManager.onScanStateChanged(shouldScan) - } - - // MARK: - Private Implementation - All moved to component managers } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index 66971ea4..58bb1f48 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -93,6 +93,7 @@ class BluetoothGattClientManager( @Volatile private var lastScanResultTime = 0L private var scanRetryCount = 0 private var scanWatchdogJob: Job? = null + private var scanDutyCycleJob: Job? = null // RSSI monitoring state private var rssiMonitoringJob: Job? = null @@ -131,18 +132,9 @@ class BluetoothGattClientManager( isActive = true connectionScope.launch { - if (powerManager.shouldUseDutyCycle()) { - Log.i(TAG, "Using power-aware duty cycling") - // Duty cycle drives onScanStateChanged(true/false); scanningDesired follows that. - } else { - scanningDesired = true - startScanning() - } - + applyPowerProfile(powerManager.profile.value) // Start RSSI monitoring startRSSIMonitoring() - // Start the scan watchdog so a silently-dead or wedged scanner self-heals. - startScanWatchdog() } return true @@ -153,6 +145,8 @@ class BluetoothGattClientManager( */ fun stop() { scanningDesired = false + scanDutyCycleJob?.cancel() + scanDutyCycleJob = null stopScanWatchdog() if (!isActive) { // Idempotent stop @@ -208,10 +202,10 @@ class BluetoothGattClientManager( Log.d(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}") } } - delay(AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS) + delay(powerManager.profile.value.ble.rssiPollIntervalMs) } catch (e: Exception) { Log.w(TAG, "Error in RSSI monitoring: ${e.message}") - delay(AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS) + delay(powerManager.profile.value.ble.rssiPollIntervalMs) } } } @@ -659,13 +653,37 @@ class BluetoothGattClientManager( connectionScope.launch { stopScanning() delay(1000) // Extra delay to avoid rate limiting - - if (powerManager.shouldUseDutyCycle()) { - Log.i(TAG, "Switching to duty cycle scanning mode") - // Duty cycle will handle scanning - } else { - Log.i(TAG, "Switching to continuous scanning mode") - startScanning() + applyPowerProfile(powerManager.profile.value) + } + } + + /** + * Apply the current process-wide profile without ever disabling background discovery. + */ + fun applyPowerProfile(profile: PowerManager.RuntimePerformanceProfile) { + scanDutyCycleJob?.cancel() + scanDutyCycleJob = null + if (!isActive || !isClientRoleEnabled()) { + onScanStateChanged(false) + return + } + + if (profile.ble.continuousScan) { + startScanWatchdog() + onScanStateChanged(true) + return + } + + // Duty-cycled scans are re-armed every window, so the continuous-scan watchdog would only + // create background wakeups during intentional OFF periods. + stopScanWatchdog() + scanDutyCycleJob = connectionScope.launch { + while (isActive && isClientRoleEnabled()) { + onScanStateChanged(true) + delay(profile.ble.scanOnMs) + if (!isActive || !isClientRoleEnabled()) break + onScanStateChanged(false) + delay(profile.ble.scanOffMs) } } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 55cdb6df..bdd49d8e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -126,7 +126,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic var delegate: BluetoothMeshDelegate? = null // Coroutines - private var announceJob: Job? = null // Tracks whether this instance has been terminated via stopServices() private var terminated = false private val provisionalBleClaims = @@ -217,23 +216,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } } - /** - * Send broadcast announcement every 30 seconds - */ - private fun sendPeriodicBroadcastAnnounce() { - announceJob?.cancel() - announceJob = serviceScope.launch { - while (isActive) { - try { - delay(30000) // 30 seconds - sendBroadcastAnnounce() - } catch (e: Exception) { - Log.e(TAG, "Error in periodic broadcast announce: ${e.message}") - } - } - } - } - /** * Setup delegate connections between components */ @@ -778,8 +760,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic isActive = true TransportBridgeService.register("BLE", this) - // Start periodic announcements for peer discovery and connectivity - sendPeriodicBroadcastAnnounce() // Start periodic syncs com.bitchat.android.service.MeshServiceHolder.startSharedGossip("BLE") } else { @@ -801,8 +781,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic private fun pauseServicesForTransportDisable() { Log.i(TAG, "Disabling BLE mesh transport") isActive = false - announceJob?.cancel() - announceJob = null com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE") TransportBridgeService.unregister("BLE") try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } @@ -822,8 +800,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic Log.i(TAG, "Stopping Bluetooth mesh service") isActive = false - announceJob?.cancel() - announceJob = null TransportBridgeService.unregister("BLE") try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { } diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 375e6531..0c963cc1 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -116,7 +116,6 @@ class MeshCore( var delegate: MeshDelegate? = null - private var announceJob: Job? = null private var isActive = false init { @@ -145,7 +144,6 @@ class MeshCore( fun startCore() { if (isActive) return isActive = true - startPeriodicBroadcastAnnounce() if (ownsGossipManager) { gossipSyncManager.start() } @@ -154,8 +152,6 @@ class MeshCore( fun stopCore() { if (!isActive) return isActive = false - announceJob?.cancel() - announceJob = null if (ownsGossipManager) { gossipSyncManager.stop() } @@ -195,18 +191,6 @@ class MeshCore( TransportBridgeService.broadcast(transport.id, routed) } - private fun startPeriodicBroadcastAnnounce() { - announceJob?.cancel() - announceJob = scope.launch { - while (isActive) { - try { - delay(30_000) - sendBroadcastAnnounce() - } catch (_: Exception) { } - } - } - } - private fun setupDelegates() { peerManager.delegate = object : PeerManagerDelegate { override fun onPeerListUpdated(peerIDs: List) { diff --git a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt index 3096828a..9714c63a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt @@ -14,327 +14,261 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner -import kotlinx.coroutines.* -import kotlin.math.max +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.util.AppConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch /** - * Power-aware Bluetooth management for bitchat - * Adjusts scanning, advertising, and connection behavior based on battery state + * Process-wide source of truth for background and battery-aware scheduling. + * + * The manager deliberately exposes policy only. Consumers retain ownership of their hardware and + * network jobs, so changing profile never implies disconnecting a transport or suspending Tor. */ -class PowerManager(private val context: Context) : LifecycleEventObserver { - +class PowerManager private constructor(context: Context) : LifecycleEventObserver { + + enum class PowerMode { + PERFORMANCE, + BALANCED, + POWER_SAVER, + ULTRA_LOW_POWER + } + + enum class BatteryBand { NORMAL, LOW, CRITICAL } + + data class BleSchedule( + val scanOnMs: Long, + val scanOffMs: Long, + val continuousScan: Boolean, + val rssiPollIntervalMs: Long, + val maxConnections: Int = 8 + ) + + data class WifiAwareSchedule( + val tcpKeepAliveMs: Long, + val discoveryKeepAliveMs: Long, + val connectionMaintenanceMs: Long, + val discoveryIdleRefreshMs: Long, + val discoverySessionRefreshMinMs: Long, + val discoveryStaleMs: Long + ) + + data class NostrSchedule( + val presenceHeartbeatMinMs: Long, + val presenceHeartbeatMaxMs: Long, + val subscriptionValidationMs: Long + ) + + data class RuntimePerformanceProfile( + val mode: PowerMode, + val batteryBand: BatteryBand, + val isBackground: Boolean, + val isCharging: Boolean, + val hasDirectPeers: Boolean, + val ble: BleSchedule, + val meshAnnouncementIntervalMs: Long, + val wifiAware: WifiAwareSchedule, + val nostr: NostrSchedule + ) + companion object { private const val TAG = "PowerManager" - - // Battery thresholds - private const val CRITICAL_BATTERY = com.bitchat.android.util.AppConstants.Power.CRITICAL_BATTERY_PERCENT - private const val LOW_BATTERY = com.bitchat.android.util.AppConstants.Power.LOW_BATTERY_PERCENT - private const val MEDIUM_BATTERY = com.bitchat.android.util.AppConstants.Power.MEDIUM_BATTERY_PERCENT - - // Scan duty cycle periods (ms) - private const val SCAN_ON_DURATION_NORMAL = com.bitchat.android.util.AppConstants.Power.SCAN_ON_DURATION_NORMAL_MS // 8 seconds on - private const val SCAN_OFF_DURATION_NORMAL = com.bitchat.android.util.AppConstants.Power.SCAN_OFF_DURATION_NORMAL_MS // 2 seconds off - private const val SCAN_ON_DURATION_POWER_SAVE = com.bitchat.android.util.AppConstants.Power.SCAN_ON_DURATION_POWER_SAVE_MS // 2 seconds on - private const val SCAN_OFF_DURATION_POWER_SAVE = com.bitchat.android.util.AppConstants.Power.SCAN_OFF_DURATION_POWER_SAVE_MS // 8 seconds off - private const val SCAN_ON_DURATION_ULTRA_LOW = com.bitchat.android.util.AppConstants.Power.SCAN_ON_DURATION_ULTRA_LOW_MS // 1 second on - private const val SCAN_OFF_DURATION_ULTRA_LOW = com.bitchat.android.util.AppConstants.Power.SCAN_OFF_DURATION_ULTRA_LOW_MS // 10 seconds off - - // Connection limits - private const val MAX_CONNECTIONS_NORMAL = com.bitchat.android.util.AppConstants.Power.MAX_CONNECTIONS_NORMAL - private const val MAX_CONNECTIONS_POWER_SAVE = com.bitchat.android.util.AppConstants.Power.MAX_CONNECTIONS_POWER_SAVE - private const val MAX_CONNECTIONS_ULTRA_LOW = com.bitchat.android.util.AppConstants.Power.MAX_CONNECTIONS_ULTRA_LOW + + @Volatile + private var INSTANCE: PowerManager? = null + + fun getInstance(context: Context): PowerManager = + INSTANCE ?: synchronized(this) { + INSTANCE ?: PowerManager(context.applicationContext).also { INSTANCE = it } + } } - - enum class PowerMode { - PERFORMANCE, // Full power, no restrictions - BALANCED, // Moderate power saving - POWER_SAVER, // Aggressive power saving - ULTRA_LOW_POWER // Minimal operations only - } - - private var currentMode = PowerMode.BALANCED - private var isCharging = false - private var batteryLevel = 100 - private var isAppInBackground = true - - private val powerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private var dutyCycleJob: Job? = null - - var delegate: PowerManagerDelegate? = null - - // Battery monitoring + + private val appContext = context.applicationContext + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + @Volatile private var batteryLevel = 100 + @Volatile private var isCharging = false + @Volatile private var isAppInBackground = true + @Volatile private var hasDirectPeers = false + @Volatile private var shutdown = false + + private val _profile = MutableStateFlow( + PowerProfileResolver.resolve( + batteryLevel = batteryLevel, + isCharging = isCharging, + isBackground = isAppInBackground, + hasDirectPeers = hasDirectPeers + ) + ) + val profile: StateFlow = _profile.asStateFlow() + private val batteryReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { Intent.ACTION_BATTERY_CHANGED -> { val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) - if (level != -1 && scale != -1) { - batteryLevel = (level * 100) / scale - } - + if (level >= 0 && scale > 0) batteryLevel = (level * 100) / scale + val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || - status == BatteryManager.BATTERY_STATUS_FULL - - updatePowerMode() + status == BatteryManager.BATTERY_STATUS_FULL + refreshProfile() } Intent.ACTION_POWER_CONNECTED -> { isCharging = true - updatePowerMode() + refreshProfile() } Intent.ACTION_POWER_DISCONNECTED -> { isCharging = false - updatePowerMode() + refreshProfile() } } } } - + init { registerBatteryReceiver() - - // Register for process lifecycle events on the main thread Handler(Looper.getMainLooper()).post { try { ProcessLifecycleOwner.get().lifecycle.addObserver(this) + isAppInBackground = !ProcessLifecycleOwner.get().lifecycle.currentState + .isAtLeast(Lifecycle.State.STARTED) + refreshProfile() } catch (e: Exception) { - Log.e(TAG, "Failed to register lifecycle observer: ${e.message}") + Log.e(TAG, "Failed to register process lifecycle observer: ${e.message}") } } - - updatePowerMode() + scope.launch { + AppStateStore.directPeers + .collect { peers -> + hasDirectPeers = peers.isNotEmpty() + refreshProfile() + } + } } - - fun start() { - Log.i(TAG, "Starting power management") - startDutyCycle() - } - - fun stop() { - Log.i(TAG, "Stopping power management") - powerScope.cancel() - unregisterBatteryReceiver() - - // Unregister lifecycle observer + + /** Kept for existing callers; initialization is eager and idempotent. */ + fun start() = Unit + + /** + * Process-level teardown for explicit full application shutdown only. + * Transport stop/restart paths must not call this. + */ + fun shutdown() { + if (shutdown) return + shutdown = true + scope.cancel() + try { appContext.unregisterReceiver(batteryReceiver) } catch (_: Exception) { } Handler(Looper.getMainLooper()).post { - try { - ProcessLifecycleOwner.get().lifecycle.removeObserver(this) - } catch (e: Exception) { - Log.e(TAG, "Failed to remove lifecycle observer: ${e.message}") - } + try { ProcessLifecycleOwner.get().lifecycle.removeObserver(this) } catch (_: Exception) { } } } - + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { when (event) { Lifecycle.Event.ON_START -> { - Log.d(TAG, "Process lifecycle: ON_START (App coming to foreground)") isAppInBackground = false - updatePowerMode() + refreshProfile() } Lifecycle.Event.ON_STOP -> { - Log.d(TAG, "Process lifecycle: ON_STOP (App going to background)") isAppInBackground = true - updatePowerMode() + refreshProfile() } - else -> {} + else -> Unit } } - - /** - * Get scan settings optimized for current power mode - */ + fun getScanSettings(): ScanSettings { + val current = profile.value val builder = ScanSettings.Builder() .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) - when (currentMode) { + when (current.mode) { PowerMode.PERFORMANCE -> builder .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) .setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT) - PowerMode.BALANCED -> builder .setScanMode(ScanSettings.SCAN_MODE_BALANCED) .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) - - PowerMode.POWER_SAVER -> builder - .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) - .setMatchMode(ScanSettings.MATCH_MODE_STICKY) - .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) - + PowerMode.POWER_SAVER, PowerMode.ULTRA_LOW_POWER -> builder .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) .setMatchMode(ScanSettings.MATCH_MODE_STICKY) .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) } - return builder.setReportDelay(0).build() } - - /** - * Get advertising settings optimized for current power mode - */ - fun getAdvertiseSettings(): AdvertiseSettings { - return when (currentMode) { - PowerMode.PERFORMANCE -> AdvertiseSettings.Builder() - .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) - .setConnectable(true) - .setTimeout(0) - .build() - - PowerMode.BALANCED -> AdvertiseSettings.Builder() - .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) - .setConnectable(true) - .setTimeout(0) - .build() - - PowerMode.POWER_SAVER -> AdvertiseSettings.Builder() - .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER) - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_LOW) - .setConnectable(true) - .setTimeout(0) - .build() - - PowerMode.ULTRA_LOW_POWER -> AdvertiseSettings.Builder() - .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER) - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW) - .setConnectable(true) - .setTimeout(0) - .build() - } + + fun getAdvertiseSettings(): AdvertiseSettings = when (profile.value.mode) { + PowerMode.PERFORMANCE -> AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) + .setConnectable(true).setTimeout(0).build() + PowerMode.BALANCED -> AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) + .setConnectable(true).setTimeout(0).build() + PowerMode.POWER_SAVER -> AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_LOW) + .setConnectable(true).setTimeout(0).build() + PowerMode.ULTRA_LOW_POWER -> AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW) + .setConnectable(true).setTimeout(0).build() } - - /** - * Get maximum allowed connections for current power mode - */ - fun getMaxConnections(): Int { - return when (currentMode) { - PowerMode.PERFORMANCE -> MAX_CONNECTIONS_NORMAL - PowerMode.BALANCED -> MAX_CONNECTIONS_NORMAL - PowerMode.POWER_SAVER -> MAX_CONNECTIONS_POWER_SAVE - PowerMode.ULTRA_LOW_POWER -> MAX_CONNECTIONS_ULTRA_LOW - } + + fun getMaxConnections(): Int = profile.value.ble.maxConnections + + fun getRSSIThreshold(): Int = when (profile.value.mode) { + PowerMode.PERFORMANCE -> -95 + PowerMode.BALANCED -> -85 + PowerMode.POWER_SAVER -> -75 + PowerMode.ULTRA_LOW_POWER -> -65 } - - /** - * Get RSSI filter threshold for current power mode - */ - fun getRSSIThreshold(): Int { - return when (currentMode) { - PowerMode.PERFORMANCE -> -95 - PowerMode.BALANCED -> -85 - PowerMode.POWER_SAVER -> -75 - PowerMode.ULTRA_LOW_POWER -> -65 - } - } - - /** - * Check if duty cycling should be used - */ - fun shouldUseDutyCycle(): Boolean { - return currentMode != PowerMode.PERFORMANCE - } - - /** - * Get current power mode information - */ - fun getPowerInfo(): String { - return buildString { + + fun shouldUseDutyCycle(): Boolean = !profile.value.ble.continuousScan + + fun getPowerInfo(): String = profile.value.let { current -> + buildString { appendLine("=== Power Manager Status ===") - appendLine("Current Mode: $currentMode") - appendLine("Battery Level: $batteryLevel%") - appendLine("Is Charging: $isCharging") - appendLine("App In Background: $isAppInBackground") - appendLine("Max Connections: ${getMaxConnections()}") - appendLine("RSSI Threshold: ${getRSSIThreshold()} dBm") - appendLine("Use Duty Cycle: ${shouldUseDutyCycle()}") + appendLine("Current Mode: ${current.mode}") + appendLine("Battery Band: ${current.batteryBand} ($batteryLevel%)") + appendLine("Is Charging: ${current.isCharging}") + appendLine("App In Background: ${current.isBackground}") + appendLine("Has Direct Peers: ${current.hasDirectPeers}") + appendLine("BLE Scan: ${current.ble.scanOnMs}ms ON / ${current.ble.scanOffMs}ms OFF") + appendLine("Max Connections: ${current.ble.maxConnections}") } } - - private fun updatePowerMode() { - // Determine the base mode from battery/charging state only - val baseMode = when { - // Charging in foreground may use performance - isCharging && !isAppInBackground -> PowerMode.PERFORMANCE - // Critical battery - force ultra low power regardless of foreground/background - batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER - - // Low battery - prefer power saver - batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER - - // Otherwise balanced - else -> PowerMode.BALANCED - } - - // If app is in background (including when running as a foreground service), - // cap the power mode to at least POWER_SAVER. Preserve ULTRA_LOW_POWER. - val newMode = if (isAppInBackground) { - if (baseMode == PowerMode.ULTRA_LOW_POWER) PowerMode.ULTRA_LOW_POWER else PowerMode.POWER_SAVER - } else { - baseMode - } - - if (newMode != currentMode) { - val oldMode = currentMode - currentMode = newMode - Log.i(TAG, "Power mode changed: $oldMode → $newMode (battery: $batteryLevel%, charging: $isCharging, background: $isAppInBackground)") - - delegate?.onPowerModeChanged(currentMode) - - // Restart duty cycle with new parameters - if (shouldUseDutyCycle()) { - startDutyCycle() - } else { - stopDutyCycle() - } + private fun refreshProfile() { + if (shutdown) return + val next = PowerProfileResolver.resolve( + batteryLevel = batteryLevel, + isCharging = isCharging, + isBackground = isAppInBackground, + hasDirectPeers = hasDirectPeers + ) + if (_profile.value != next) { + Log.i( + TAG, + "Profile changed: mode=${next.mode}, battery=${next.batteryBand}, " + + "background=${next.isBackground}, directPeers=${next.hasDirectPeers}" + ) + _profile.value = next } } - - private fun startDutyCycle() { - stopDutyCycle() - - if (!shouldUseDutyCycle()) { - delegate?.onScanStateChanged(true) // Always scan in performance mode - return - } - - val (onDuration, offDuration) = when (currentMode) { - PowerMode.BALANCED -> SCAN_ON_DURATION_NORMAL to SCAN_OFF_DURATION_NORMAL - PowerMode.POWER_SAVER -> SCAN_ON_DURATION_POWER_SAVE to SCAN_OFF_DURATION_POWER_SAVE - PowerMode.ULTRA_LOW_POWER -> SCAN_ON_DURATION_ULTRA_LOW to SCAN_OFF_DURATION_ULTRA_LOW - PowerMode.PERFORMANCE -> return // No duty cycle - } - - dutyCycleJob = powerScope.launch { - while (isActive && shouldUseDutyCycle()) { - // Scan ON period - Log.d(TAG, "Duty cycle: Scan ON for ${onDuration}ms") - delegate?.onScanStateChanged(true) - delay(onDuration) - - // Scan OFF period (keep advertising active) - if (isActive && shouldUseDutyCycle()) { - Log.d(TAG, "Duty cycle: Scan OFF for ${offDuration}ms") - delegate?.onScanStateChanged(false) - delay(offDuration) - } - } - } - - Log.i(TAG, "Started duty cycle: ${onDuration}ms ON, ${offDuration}ms OFF") - } - - private fun stopDutyCycle() { - dutyCycleJob?.cancel() - dutyCycleJob = null - } - + private fun registerBatteryReceiver() { try { val filter = IntentFilter().apply { @@ -342,25 +276,141 @@ class PowerManager(private val context: Context) : LifecycleEventObserver { addAction(Intent.ACTION_POWER_CONNECTED) addAction(Intent.ACTION_POWER_DISCONNECTED) } - context.registerReceiver(batteryReceiver, filter) + appContext.registerReceiver(batteryReceiver, filter) } catch (e: Exception) { Log.w(TAG, "Failed to register battery receiver: ${e.message}") } } - - private fun unregisterBatteryReceiver() { - try { - context.unregisterReceiver(batteryReceiver) - } catch (e: Exception) { - Log.w(TAG, "Failed to unregister battery receiver: ${e.message}") - } - } } /** - * Delegate interface for power management callbacks + * Pure policy resolver so cadence and battery-boundary behavior can be tested without Android. */ -interface PowerManagerDelegate { - fun onPowerModeChanged(newMode: PowerManager.PowerMode) - fun onScanStateChanged(shouldScan: Boolean) +internal object PowerProfileResolver { + fun resolve( + batteryLevel: Int, + isCharging: Boolean, + isBackground: Boolean, + hasDirectPeers: Boolean + ): PowerManager.RuntimePerformanceProfile { + val batteryBand = when { + batteryLevel <= AppConstants.Power.CRITICAL_BATTERY_PERCENT -> + PowerManager.BatteryBand.CRITICAL + batteryLevel <= AppConstants.Power.LOW_BATTERY_PERCENT -> + PowerManager.BatteryBand.LOW + else -> PowerManager.BatteryBand.NORMAL + } + + val mode = when { + isBackground && batteryBand == PowerManager.BatteryBand.CRITICAL -> + PowerManager.PowerMode.ULTRA_LOW_POWER + isBackground -> PowerManager.PowerMode.POWER_SAVER + isCharging -> PowerManager.PowerMode.PERFORMANCE + batteryBand == PowerManager.BatteryBand.CRITICAL -> + PowerManager.PowerMode.ULTRA_LOW_POWER + batteryBand == PowerManager.BatteryBand.LOW -> + PowerManager.PowerMode.POWER_SAVER + else -> PowerManager.PowerMode.BALANCED + } + + val ble = when { + isBackground && hasDirectPeers -> + PowerManager.BleSchedule( + scanOnMs = 1_000L, + scanOffMs = 29_000L, + continuousScan = false, + rssiPollIntervalMs = backgroundRssiInterval(batteryBand) + ) + isBackground -> + PowerManager.BleSchedule( + scanOnMs = 1_000L, + scanOffMs = 59_000L, + continuousScan = false, + rssiPollIntervalMs = backgroundRssiInterval(batteryBand) + ) + mode == PowerManager.PowerMode.PERFORMANCE -> + PowerManager.BleSchedule(Long.MAX_VALUE, 0L, true, 5_000L) + mode == PowerManager.PowerMode.BALANCED -> + PowerManager.BleSchedule(8_000L, 2_000L, false, 10_000L) + mode == PowerManager.PowerMode.POWER_SAVER -> + PowerManager.BleSchedule(2_000L, 28_000L, false, 30_000L) + else -> + PowerManager.BleSchedule(1_000L, 29_000L, false, 60_000L) + } + + val announcementInterval = when { + isBackground && batteryBand == PowerManager.BatteryBand.NORMAL -> 60_000L + isBackground && batteryBand == PowerManager.BatteryBand.LOW -> 120_000L + isBackground -> 300_000L + mode == PowerManager.PowerMode.POWER_SAVER -> 60_000L + mode == PowerManager.PowerMode.ULTRA_LOW_POWER -> 120_000L + else -> 30_000L + } + + val wifi = when { + isBackground && batteryBand == PowerManager.BatteryBand.NORMAL -> + wifi(10, 60, 60, 5, 5, 10) + isBackground && batteryBand == PowerManager.BatteryBand.LOW -> + wifi(20, 120, 120, 10, 10, 15) + isBackground -> + wifi(30, 180, 180, 15, 15, 20) + mode == PowerManager.PowerMode.PERFORMANCE -> + PowerManager.WifiAwareSchedule(2_000L, 20_000L, 15_000L, 120_000L, 90_000L, 300_000L) + mode == PowerManager.PowerMode.BALANCED -> + PowerManager.WifiAwareSchedule(5_000L, 30_000L, 30_000L, 180_000L, 120_000L, 300_000L) + mode == PowerManager.PowerMode.POWER_SAVER -> + PowerManager.WifiAwareSchedule(10_000L, 60_000L, 60_000L, 300_000L, 180_000L, 600_000L) + else -> + PowerManager.WifiAwareSchedule(15_000L, 90_000L, 90_000L, 600_000L, 300_000L, 900_000L) + } + + val nostr = when { + isBackground && batteryBand == PowerManager.BatteryBand.NORMAL -> + PowerManager.NostrSchedule(300_000L, 330_000L, 300_000L) + isBackground && batteryBand == PowerManager.BatteryBand.LOW -> + PowerManager.NostrSchedule(600_000L, 660_000L, 600_000L) + isBackground -> + PowerManager.NostrSchedule(900_000L, 990_000L, 900_000L) + mode == PowerManager.PowerMode.POWER_SAVER -> + PowerManager.NostrSchedule(120_000L, 132_000L, 60_000L) + mode == PowerManager.PowerMode.ULTRA_LOW_POWER -> + PowerManager.NostrSchedule(300_000L, 330_000L, 120_000L) + else -> + PowerManager.NostrSchedule(40_000L, 80_000L, 30_000L) + } + + return PowerManager.RuntimePerformanceProfile( + mode = mode, + batteryBand = batteryBand, + isBackground = isBackground, + isCharging = isCharging, + hasDirectPeers = hasDirectPeers, + ble = ble, + meshAnnouncementIntervalMs = announcementInterval, + wifiAware = wifi, + nostr = nostr + ) + } + + private fun wifi( + tcpSeconds: Long, + discoverySeconds: Long, + maintenanceSeconds: Long, + idleMinutes: Long, + minRefreshMinutes: Long, + staleMinutes: Long + ) = PowerManager.WifiAwareSchedule( + tcpKeepAliveMs = tcpSeconds * 1_000L, + discoveryKeepAliveMs = discoverySeconds * 1_000L, + connectionMaintenanceMs = maintenanceSeconds * 1_000L, + discoveryIdleRefreshMs = idleMinutes * 60_000L, + discoverySessionRefreshMinMs = minRefreshMinutes * 60_000L, + discoveryStaleMs = staleMinutes * 60_000L + ) + + private fun backgroundRssiInterval(band: PowerManager.BatteryBand): Long = when (band) { + PowerManager.BatteryBand.NORMAL -> 60_000L + PowerManager.BatteryBand.LOW -> 120_000L + PowerManager.BatteryBand.CRITICAL -> 300_000L + } } diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index ca49def9..b9f84547 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -6,7 +6,17 @@ import com.bitchat.android.favorites.FavoriteControlMessage import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.BitchatMessage import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.services.AppStateStore import com.bitchat.android.wifiaware.WifiAwareController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch /** * Feature-facing mesh service that hides local transport selection from the rest of the app. @@ -24,6 +34,10 @@ class UnifiedMeshService( private const val TAG = "UnifiedMeshService" } + private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val powerManager = PowerManager.getInstance(context.applicationContext) + private var announcementJob: Job? = null + override val myPeerID: String get() = bluetooth.myPeerID @@ -49,14 +63,34 @@ class UnifiedMeshService( try { WifiAwareController.startIfPossible() } catch (e: Exception) { Log.w(TAG, "Failed to start Wi-Fi Aware transport: ${e.message}") } + startAnnouncementScheduler() refreshDelegates() } override fun stopServices() { + announcementJob?.cancel() + announcementJob = null try { bluetooth.stopServices() } catch (_: Exception) { } try { WifiAwareController.stop() } catch (_: Exception) { } } + private fun startAnnouncementScheduler() { + if (announcementJob?.isActive == true) return + announcementJob = serviceScope.launch { + combine(powerManager.profile, AppStateStore.directPeers) { profile, peers -> + profile.meshAnnouncementIntervalMs to peers.isNotEmpty() + }.collectLatest { (intervalMs, hasRecipients) -> + if (!hasRecipients) return@collectLatest + // Connection-specific paths already send an immediate announce. Begin the + // periodic cadence after the configured interval to avoid a transition burst. + while (isActive) { + delay(intervalMs) + if (AppStateStore.directPeers.value.isNotEmpty()) sendBroadcastAnnounce() + } + } + } + } + override fun sendMessage(content: String, mentions: List, channel: String?) { when { isBleEnabled() -> bluetooth.sendMessage(content, mentions, channel) diff --git a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt index d560c648..b1eed65e 100644 --- a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt +++ b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt @@ -133,6 +133,9 @@ class ArtiTorManager private constructor() { lastLogTime.set(System.currentTimeMillis()) _statusFlow.update { it.copy(lastLogLine = s) } handleArtiLogLine(s) + if (_statusFlow.value.bootstrapPercent < 100) { + armBootstrapInactivityWatchdog() + } } artiProxy = ArtiProxy.Builder(application) @@ -374,29 +377,30 @@ class ArtiTorManager private constructor() { } private fun startInactivityMonitoring() { - inactivityJob?.cancel() - inactivityJob = appScope.launch { - while (true) { - delay(INACTIVITY_TIMEOUT_MS) - val currentTime = System.currentTimeMillis() - val lastActivity = lastLogTime.get() - val timeSinceLastActivity = currentTime - lastActivity + armBootstrapInactivityWatchdog() + } - if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) { - val currentMode = _statusFlow.value.mode - if (currentMode == TorMode.ON) { - val bootstrapPercent = _statusFlow.value.bootstrapPercent - if (bootstrapPercent < 100) { - Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti") - currentApplication?.let { app -> - appScope.launch { - restartArti(app) - } - } - break - } - } - } + /** + * One-shot startup watchdog. Arti log activity re-arms it and successful bootstrap cancels it, + * so a healthy background Tor process has no permanent five-second polling coroutine. + */ + private fun armBootstrapInactivityWatchdog() { + inactivityJob?.cancel() + if (lifecycleState != LifecycleState.RUNNING || _statusFlow.value.bootstrapPercent >= 100) { + inactivityJob = null + return + } + inactivityJob = appScope.launch { + delay(INACTIVITY_TIMEOUT_MS) + val timeSinceLastActivity = System.currentTimeMillis() - lastLogTime.get() + if ( + timeSinceLastActivity >= INACTIVITY_TIMEOUT_MS && + _statusFlow.value.mode == TorMode.ON && + _statusFlow.value.bootstrapPercent < 100 && + lifecycleState == LifecycleState.RUNNING + ) { + Log.w(TAG, "Bootstrap inactivity detected (${timeSinceLastActivity}ms), restarting Arti") + currentApplication?.let { restartArti(it) } } } } @@ -494,6 +498,7 @@ class ArtiTorManager private constructor() { running = true ) } + stopInactivityMonitoring() completeWaitersIf(TorState.RUNNING) } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt new file mode 100644 index 00000000..8abb4d6a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt @@ -0,0 +1,213 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.mesh.PowerManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.security.SecureRandom +import kotlin.random.asKotlinRandom + +/** + * Process-owned Nostr connectivity and low-volume background subscriptions. + * + * Stable subscription handlers delegate to the most recently attached UI-independent handlers. + * This avoids relay subscriptions retaining a cleared ViewModel while keeping DMs and the selected + * geohash channel alive for the lifetime of the foreground-service process. + */ +object NostrBackgroundRuntime { + private const val TAG = "NostrBackground" + private const val MAX_PENDING_EVENTS = 256 + + data class Handlers( + val accountDm: (NostrEvent, NostrIdentity) -> Unit, + val geohashMessage: (NostrEvent, String) -> Unit, + val geohashDm: (NostrEvent, String, NostrIdentity) -> Unit + ) + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + internal val eventScope: CoroutineScope + get() = scope + private val random = SecureRandom().asKotlinRandom() + private val lock = Any() + private val pendingEvents = ArrayDeque<(Handlers) -> Unit>() + + @Volatile private var handlers: Handlers? = null + @Volatile private var initialized = false + @Volatile private var activeGeohash: String? = null + @Volatile private var conversationGeohash: String? = null + private lateinit var application: Application + private lateinit var subscriptions: NostrSubscriptionManager + private lateinit var locationChannels: LocationChannelManager + + fun initialize(app: Application) { + synchronized(lock) { + if (initialized) return + initialized = true + application = app + locationChannels = LocationChannelManager.getInstance(app) + subscriptions = NostrSubscriptionManager( + app, + scope, + owner = NostrRelayManager.OWNER_BACKGROUND + ) + } + + subscriptions.connect() + subscribeAccountDm() + observeSelectedChannel() + startPresenceScheduler() + } + + fun attachHandlers(newHandlers: Handlers) { + handlers = newHandlers + val pending = synchronized(lock) { + pendingEvents.toList().also { pendingEvents.clear() } + } + pending.forEach { event -> runCatching { event(newHandlers) } } + } + + fun resetSubscriptions() { + if (!initialized) return + subscriptions.unsubscribeAllOwned() + scope.launch { + // Let CLOSE frames be queued before replacing the deterministic IDs. + delay(100) + subscribeAccountDm() + activeGeohash?.let(::subscribeSelectedGeohash) + } + } + + fun ensureConversationDm(geohash: String) { + if (!initialized || geohash == activeGeohash || geohash == conversationGeohash) return + conversationGeohash?.let { subscriptions.unsubscribe("geo-dm-conversation-$it") } + conversationGeohash = geohash + subscribeGeohashDm(geohash, "geo-dm-conversation-$geohash") + } + + private fun subscribeAccountDm() { + val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) ?: return + subscriptions.subscribeGiftWraps( + pubkey = identity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172_800_000L, + id = "chat-messages", + handler = { event -> + dispatch { it.accountDm(event, identity) } + } + ) + } + + private fun observeSelectedChannel() { + scope.launch { + locationChannels.selectedChannel.collectLatest { channel -> + val next = (channel as? ChannelID.Location)?.channel?.geohash + val previous = activeGeohash + if (previous == next) return@collectLatest + + previous?.let { + subscriptions.unsubscribe("geohash-$it") + subscriptions.unsubscribe("geo-dm-$it") + } + activeGeohash = next + if (conversationGeohash == next) { + subscriptions.unsubscribe("geo-dm-conversation-$next") + conversationGeohash = null + } + next?.let(::subscribeSelectedGeohash) + } + } + } + + private fun subscribeSelectedGeohash(geohash: String) { + subscriptions.subscribeGeohashMessages( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 3_600_000L, + limit = 200, + id = "geohash-$geohash", + handler = { event -> dispatch { it.geohashMessage(event, geohash) } } + ) + subscribeGeohashDm(geohash, "geo-dm-$geohash") + } + + private fun subscribeGeohashDm(geohash: String, subscriptionId: String) { + scope.launch { + val identity = NostrIdentityBridge.deriveIdentity(geohash, application) + subscriptions.subscribeGiftWraps( + pubkey = identity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172_800_000L, + id = subscriptionId, + handler = { event -> dispatch { it.geohashDm(event, geohash, identity) } } + ) + GeohashAliasRegistry.put( + "nostr_${identity.publicKeyHex.take(16)}", + identity.publicKeyHex + ) + } + } + + private fun startPresenceScheduler() { + val powerManager = PowerManager.getInstance(application) + scope.launch { + val nostrSchedule = powerManager.profile + .map { it.nostr } + .distinctUntilChanged() + combine(locationChannels.availableChannels, nostrSchedule) { channels, schedule -> + val targets = channels + .filter { it.level.precision <= 5 } + .map { it.geohash } + .distinct() + targets to schedule + }.collectLatest { (targets, schedule) -> + if (targets.isEmpty()) return@collectLatest + while (true) { + val waitMs = if (schedule.presenceHeartbeatMaxMs > schedule.presenceHeartbeatMinMs) { + random.nextLong( + schedule.presenceHeartbeatMinMs, + schedule.presenceHeartbeatMaxMs + 1 + ) + } else { + schedule.presenceHeartbeatMinMs + } + delay(waitMs) + + // Send every target in one wake window; do not spread the batch over seconds. + targets.forEach { geohash -> + try { + val identity = NostrIdentityBridge.deriveIdentity(geohash, application) + val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity) + NostrRelayManager.getInstance(application).sendEventToGeohash( + event, + geohash, + includeDefaults = false, + nRelays = 5 + ) + } catch (e: Exception) { + Log.w(TAG, "Presence heartbeat failed for $geohash: ${e.message}") + } + } + } + } + } + } + + private fun dispatch(event: (Handlers) -> Unit) { + val current = handlers + if (current != null) { + event(current) + return + } + synchronized(lock) { + if (pendingEvents.size >= MAX_PENDING_EVENTS) pendingEvents.removeFirst() + pendingEvents.addLast(event) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index f50d65cc..20a1ce18 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.* import okhttp3.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.min import kotlin.math.pow @@ -25,11 +26,15 @@ class NostrRelayManager private constructor() { val shared = NostrRelayManager() private const val TAG = "NostrRelayManager" + private const val MAX_QUEUED_EVENTS = 500 + const val OWNER_LEGACY = "legacy" + const val OWNER_BACKGROUND = "background" /** * Get instance for Android compatibility (context-aware calls) */ fun getInstance(context: android.content.Context): NostrRelayManager { + shared.appContext = context.applicationContext return shared } @@ -82,6 +87,8 @@ class NostrRelayManager private constructor() { // Internal state private val relaysList = mutableListOf() private val connections = ConcurrentHashMap() + private val reconnectJobs = ConcurrentHashMap() + private val desiredConnected = AtomicBoolean(false) private val subscriptions = ConcurrentHashMap>() // relay URL -> subscription IDs private val messageHandlers = ConcurrentHashMap Unit>() @@ -97,14 +104,19 @@ class NostrRelayManager private constructor() { val handler: (NostrEvent) -> Unit, val targetRelayUrls: Set? = null, // null means all relays val createdAt: Long = System.currentTimeMillis(), - val originGeohash: String? = null // used for logging and grouping + val originGeohash: String? = null, // used for logging and grouping + val owner: String = OWNER_LEGACY ) // Event deduplication system private val eventDeduplicator = NostrEventDeduplicator.getInstance() // Message queue for reliability - private val messageQueue = mutableListOf>>() + private data class QueuedEvent( + val event: NostrEvent, + val pendingRelayUrls: MutableSet + ) + private val messageQueue = mutableListOf() private val messageQueueLock = Any() // Coroutine scope for background operations @@ -112,7 +124,9 @@ class NostrRelayManager private constructor() { // Subscription validation timer private var subscriptionValidationJob: Job? = null - private val SUBSCRIPTION_VALIDATION_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.SUBSCRIPTION_VALIDATION_INTERVAL_MS // 30 seconds + private val powerManager: com.bitchat.android.mesh.PowerManager? + get() = appContext?.let { com.bitchat.android.mesh.PowerManager.getInstance(it) } + @Volatile private var appContext: android.content.Context? = null // OkHttp client for WebSocket connections (via provider to honor Tor) private val httpClient: OkHttpClient @@ -162,7 +176,8 @@ class NostrRelayManager private constructor() { id: String = generateSubscriptionId(), handler: (NostrEvent) -> Unit, includeDefaults: Boolean = false, - nRelays: Int = 5 + nRelays: Int = 5, + owner: String = OWNER_LEGACY ): String { ensureGeohashRelaysConnected(geohash, nRelays, includeDefaults) val relayUrls = getRelaysForGeohash(geohash) @@ -170,7 +185,8 @@ class NostrRelayManager private constructor() { filter = filter, id = id, handler = handler, - targetRelayUrls = relayUrls + targetRelayUrls = relayUrls, + owner = owner ).also { // update origin geohash for this subscription activeSubscriptions[it]?.let { sub -> @@ -204,6 +220,7 @@ class NostrRelayManager private constructor() { } updateRelaysList() + if (!desiredConnected.get()) return scope.launch { relayUrls.forEach { relayUrl -> launch { @@ -239,6 +256,7 @@ class NostrRelayManager private constructor() { * Connect to all configured relays */ fun connect() { + desiredConnected.set(true) Log.i(TAG, "Connecting to ${relaysList.size} Nostr relays") scope.launch { @@ -258,18 +276,26 @@ class NostrRelayManager private constructor() { */ fun disconnect() { Log.i(TAG, "Disconnecting from all Nostr relays") + desiredConnected.set(false) // Stop subscription validation stopSubscriptionValidation() + reconnectJobs.values.forEach { it.cancel() } + reconnectJobs.clear() - connections.values.forEach { webSocket -> + val sockets = connections.values.toList() + connections.clear() + sockets.forEach { webSocket -> webSocket.close(1000, "Manual disconnect") } - connections.clear() - // Clear subscriptions + // Preserve logical subscriptions for controlled resets, but forget per-socket state. subscriptions.clear() - + relaysList.forEach { + it.isConnected = false + it.nextReconnectTime = null + } + updateRelaysList() updateConnectionStatus() } @@ -281,7 +307,8 @@ class NostrRelayManager private constructor() { // Add to queue for reliability synchronized(messageQueueLock) { - messageQueue.add(Pair(event, targetRelays)) + if (messageQueue.size >= MAX_QUEUED_EVENTS) messageQueue.removeAt(0) + messageQueue.add(QueuedEvent(event, targetRelays.toMutableSet())) } // Attempt immediate send @@ -289,7 +316,9 @@ class NostrRelayManager private constructor() { targetRelays.forEach { relayUrl -> val webSocket = connections[relayUrl] if (webSocket != null) { - sendToRelay(event, webSocket, relayUrl) + if (sendToRelay(event, webSocket, relayUrl)) { + markQueuedRelayDelivered(event.id, relayUrl) + } } } } @@ -303,14 +332,16 @@ class NostrRelayManager private constructor() { filter: NostrFilter, id: String = generateSubscriptionId(), handler: (NostrEvent) -> Unit, - targetRelayUrls: List? = null + targetRelayUrls: List? = null, + owner: String = OWNER_LEGACY ): String { // Store subscription info for persistent tracking val subscriptionInfo = SubscriptionInfo( id = id, filter = filter, handler = handler, - targetRelayUrls = targetRelayUrls?.toSet() + targetRelayUrls = targetRelayUrls?.toSet(), + owner = owner ) activeSubscriptions[id] = subscriptionInfo @@ -386,20 +417,28 @@ class NostrRelayManager private constructor() { } } } + + fun unsubscribeOwner(owner: String) { + activeSubscriptions.values + .filter { it.owner == owner } + .map { it.id } + .forEach(::unsubscribe) + } /** * Manually retry connection to a specific relay */ fun retryConnection(relayUrl: String) { val relay = relaysList.find { it.url == relayUrl } ?: return + desiredConnected.set(true) // Reset reconnection attempts relay.reconnectAttempts = 0 relay.nextReconnectTime = null // Disconnect if connected - connections[relayUrl]?.close(1000, "Manual retry") - connections.remove(relayUrl) + reconnectJobs.remove(relayUrl)?.cancel() + connections.remove(relayUrl)?.close(1000, "Manual retry") // Attempt immediate reconnection scope.launch { @@ -412,6 +451,7 @@ class NostrRelayManager private constructor() { * This will automatically restore all subscriptions when reconnected */ fun resetAllConnections() { + val shouldReconnect = desiredConnected.get() disconnect() // Reset all relay states @@ -421,8 +461,8 @@ class NostrRelayManager private constructor() { relay.lastError = null } - // Reconnect - subscriptions will be automatically restored in onOpen - connect() + // Reconnect only when connectivity was desired before the controlled reset. + if (shouldReconnect) connect() } /** @@ -546,8 +586,12 @@ class NostrRelayManager private constructor() { stopSubscriptionValidation() // Stop any existing validation subscriptionValidationJob = scope.launch { - while (isActive) { - delay(SUBSCRIPTION_VALIDATION_INTERVAL) + while (isActive && desiredConnected.get()) { + val validationInterval = powerManager?.profile?.value + ?.nostr?.subscriptionValidationMs + ?: com.bitchat.android.util.AppConstants.Nostr.SUBSCRIPTION_VALIDATION_INTERVAL_MS + delay(validationInterval) + if (!desiredConnected.get()) break try { val report = validateSubscriptionConsistency() @@ -587,6 +631,7 @@ class NostrRelayManager private constructor() { // MARK: - Private Methods private suspend fun connectToRelay(urlString: String) { + if (!desiredConnected.get()) return // Skip if we already have a connection if (connections.containsKey(urlString)) { return @@ -598,16 +643,22 @@ class NostrRelayManager private constructor() { .build() val webSocket = httpClient.newWebSocket(request, RelayWebSocketListener(urlString)) - connections[urlString] = webSocket + val existing = connections.putIfAbsent(urlString, webSocket) + if (existing != null) { + webSocket.close(1000, "Duplicate connection") + } else if (!desiredConnected.get()) { + connections.remove(urlString, webSocket) + webSocket.close(1000, "Connection no longer desired") + } } catch (e: Exception) { Log.e(TAG, "Failed to create WebSocket connection to $urlString: ${e.message}") - handleDisconnection(urlString, e) + handleConnectionCreationFailure(urlString, e) } } - private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String) { - try { + private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String): Boolean { + return try { val request = NostrRequest.Event(event) val message = gson.toJson(request, NostrRequest::class.java) @@ -617,11 +668,26 @@ class NostrRelayManager private constructor() { val relay = relaysList.find { it.url == relayUrl } relay?.messagesSent = (relay?.messagesSent ?: 0) + 1 updateRelaysList() + true } else { Log.e(TAG, "Failed to send event to $relayUrl: WebSocket send failed") + false } } catch (e: Exception) { Log.e(TAG, "Failed to send event to $relayUrl: ${e.message}") + false + } + } + + private fun markQueuedRelayDelivered(eventId: String, relayUrl: String) { + synchronized(messageQueueLock) { + val iterator = messageQueue.iterator() + while (iterator.hasNext()) { + val queued = iterator.next() + if (queued.event.id != eventId) continue + queued.pendingRelayUrls.remove(relayUrl) + if (queued.pendingRelayUrls.isEmpty()) iterator.remove() + } } } @@ -690,12 +756,22 @@ class NostrRelayManager private constructor() { } } - private fun handleDisconnection(relayUrl: String, error: Throwable) { - connections.remove(relayUrl) - // NOTE: Don't remove subscriptions here - keep them for restoration on reconnection - // subscriptions.remove(relayUrl) // REMOVED - this was causing subscription loss - + private fun handleDisconnection(relayUrl: String, webSocket: WebSocket, error: Throwable) { + // A close/failure from an intentionally closed or replaced socket is stale. In particular, + // it must not remove a newer socket or schedule a reconnect after manual disconnect. + if (!connections.remove(relayUrl, webSocket)) return + subscriptions.remove(relayUrl) + handleCurrentDisconnection(relayUrl, error) + } + + private fun handleConnectionCreationFailure(relayUrl: String, error: Throwable) { + if (!desiredConnected.get()) return + handleCurrentDisconnection(relayUrl, error) + } + + private fun handleCurrentDisconnection(relayUrl: String, error: Throwable) { updateRelayStatus(relayUrl, false, error) + if (!desiredConnected.get()) return // Check if this is a DNS error val errorMessage = error.message?.lowercase() ?: "" @@ -730,11 +806,13 @@ class NostrRelayManager private constructor() { Log.d(TAG, "Scheduling reconnection to $relayUrl in ${backoffInterval / 1000}s (attempt ${relay.reconnectAttempts})") - // Schedule reconnection - scope.launch { + reconnectJobs.remove(relayUrl)?.cancel() + val reconnectJob = scope.launch { delay(backoffInterval) - connectToRelay(relayUrl) + if (desiredConnected.get()) connectToRelay(relayUrl) } + reconnectJobs[relayUrl] = reconnectJob + reconnectJob.invokeOnCompletion { reconnectJobs.remove(relayUrl, reconnectJob) } } private fun updateRelayStatus(url: String, isConnected: Boolean, error: Throwable? = null) { @@ -808,25 +886,32 @@ class NostrRelayManager private constructor() { private inner class RelayWebSocketListener(private val relayUrl: String) : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { + if (!desiredConnected.get() || connections[relayUrl] !== webSocket) { + webSocket.close(1000, "Stale connection") + return + } + reconnectJobs.remove(relayUrl)?.cancel() Log.i(TAG, "Connected to Nostr relay: $relayUrl") updateRelayStatus(relayUrl, true) // Restore all active subscriptions for this relay restoreSubscriptionsForRelay(relayUrl, webSocket) - // Process any queued messages for this relay - synchronized(messageQueueLock) { - val iterator = messageQueue.iterator() - while (iterator.hasNext()) { - val (event, targetRelays) = iterator.next() - if (relayUrl in targetRelays) { - sendToRelay(event, webSocket, relayUrl) - } + // Process only events still pending for this relay, outside the queue lock. + val queuedForRelay = synchronized(messageQueueLock) { + messageQueue + .filter { relayUrl in it.pendingRelayUrls } + .map { it.event } + } + queuedForRelay.forEach { event -> + if (sendToRelay(event, webSocket, relayUrl)) { + markQueuedRelayDelivered(event.id, relayUrl) } } } override fun onMessage(webSocket: WebSocket, text: String) { + if (connections[relayUrl] !== webSocket) return handleMessage(text, relayUrl) } @@ -837,12 +922,12 @@ class NostrRelayManager private constructor() { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { Log.i(TAG, "Disconnected from Nostr relay $relayUrl: $code $reason") val error = Exception("WebSocket closed: $code $reason") - handleDisconnection(relayUrl, error) + handleDisconnection(relayUrl, webSocket, error) } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { Log.e(TAG, "WebSocket failure for $relayUrl: ${t.message}") - handleDisconnection(relayUrl, t) + handleDisconnection(relayUrl, webSocket, t) } } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt index 1a8514ab..601e885c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt @@ -11,7 +11,8 @@ import kotlinx.coroutines.launch */ class NostrSubscriptionManager( private val application: Application, - private val scope: CoroutineScope + private val scope: CoroutineScope, + private val owner: String = NostrRelayManager.OWNER_LEGACY ) { companion object { private const val TAG = "NostrSubscriptionManager" } @@ -23,7 +24,7 @@ class NostrSubscriptionManager( fun subscribeGiftWraps(pubkey: String, sinceMs: Long, id: String, handler: (NostrEvent) -> Unit) { scope.launch { val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs) - relayManager.subscribe(filter, id, handler) + relayManager.subscribe(filter, id, handler, owner = owner) } } @@ -31,7 +32,15 @@ class NostrSubscriptionManager( fun subscribeGeohashMessages(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { scope.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, + owner = owner + ) } } @@ -39,9 +48,21 @@ class NostrSubscriptionManager( fun subscribeGeohashPresence(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { scope.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, + owner = owner + ) } } fun unsubscribe(id: String) { scope.launch { runCatching { relayManager.unsubscribe(id) } } } + + fun unsubscribeAllOwned() { + scope.launch { runCatching { relayManager.unsubscribeOwner(owner) } } + } } diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt index f4ca9f74..30a641d0 100644 --- a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -56,6 +56,8 @@ object AppShutdownCoordinator { // Stop mesh (best-effort) try { mesh?.stopServices() } catch (_: Exception) { } + try { com.bitchat.android.nostr.NostrRelayManager.shared.disconnect() } catch (_: Exception) { } + try { com.bitchat.android.mesh.PowerManager.getInstance(app).shutdown() } catch (_: Exception) { } // Stop Tor temporarily (do not change user setting) val torProvider = ArtiTorManager.getInstance() diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index d9ad2d7f..1286988f 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -19,8 +19,8 @@ import com.bitchat.android.mesh.BluetoothMeshService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch class MeshForegroundService : Service() { @@ -115,6 +115,7 @@ class MeshForegroundService : Service() { private val scope = CoroutineScope(Dispatchers.Default + serviceJob) private var isInForeground: Boolean = false private var isShuttingDown: Boolean = false + private var lastNotifiedPeerCount: Int? = null override fun onCreate() { super.onCreate() @@ -131,6 +132,16 @@ class MeshForegroundService : Service() { MeshServiceHolder.attach(created) } MeshServiceHolder.getUnifiedOrCreate(applicationContext) + + // Notification content is driven by peer-state changes, not a permanent timer. + updateJob = scope.launch { + com.bitchat.android.services.AppStateStore.peers + .map { peers -> peers.distinct().size } + .distinctUntilChanged() + .collect { + if (isInForeground) updateNotification(force = false) + } + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -175,9 +186,11 @@ class MeshForegroundService : Service() { ACTION_UPDATE_NOTIFICATION -> { // If we became eligible and are not in foreground yet, promote once if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { - val n = buildNotification(getUnifiedActivePeerCount()) + val count = getUnifiedActivePeerCount() + val n = buildNotification(count) startForegroundCompat(n) isInForeground = true + lastNotifiedPeerCount = count } else { updateNotification(force = true) } @@ -190,32 +203,11 @@ class MeshForegroundService : Service() { // Promote exactly once when eligible, otherwise stay background (or stop) if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { - val notification = buildNotification(getUnifiedActivePeerCount()) + val count = getUnifiedActivePeerCount() + val notification = buildNotification(count) startForegroundCompat(notification) isInForeground = true - } - - // Periodically refresh the notification with live network size - if (updateJob == null) { - updateJob = scope.launch { - while (isActive) { - // Retry enabling mesh/foreground once permissions become available - ensureMeshStarted() - val eligible = MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() - if (eligible) { - // Only update the notification; do not re-call startForeground() - updateNotification(force = false) - } else { - // If disabled or perms missing, ensure we are not in foreground and clear notif - if (isInForeground) { - try { stopForeground(false) } catch (_: Exception) { } - isInForeground = false - } - notificationManager.cancel(NOTIFICATION_ID) - } - delay(5000) - } - } + lastNotifiedPeerCount = count } return START_STICKY @@ -229,19 +221,9 @@ class MeshForegroundService : Service() { android.util.Log.e("MeshForegroundService", "Failed to ensure Wi-Fi Aware transport: ${e.message}") } - val bleEnabled = try { - com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) - } catch (_: Exception) { - true - } - if (!bleEnabled) { - try { meshService?.setBleTransportEnabled(false) } catch (_: Exception) { } - return - } - if (!hasBluetoothPermissions()) return try { android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") - val service = MeshServiceHolder.getOrCreate(applicationContext) + val service = MeshServiceHolder.getUnifiedOrCreate(applicationContext) service.startServices() } catch (e: Exception) { android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}") @@ -254,14 +236,17 @@ class MeshForegroundService : Service() { return } val count = getUnifiedActivePeerCount() - val notification = buildNotification(count) if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { - notificationManager.notify(NOTIFICATION_ID, notification) + if (lastNotifiedPeerCount != count) { + notificationManager.notify(NOTIFICATION_ID, buildNotification(count)) + lastNotifiedPeerCount = count + } } else if (force) { // If disabled and forced, make sure to remove any prior foreground state try { stopForeground(false) } catch (_: Exception) { } notificationManager.cancel(NOTIFICATION_ID) isInForeground = false + lastNotifiedPeerCount = null } } diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index c7971eb8..c88e1bc5 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -17,6 +17,8 @@ object AppStateStore { private val peerIdsByTransport = mutableMapOf>() // Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set. private val directPeerIdsByTransport = mutableMapOf>() + private val _directPeers = MutableStateFlow>(emptySet()) + val directPeers: StateFlow> = _directPeers.asStateFlow() // Connected peer IDs (mesh ephemeral IDs) private val _peers = MutableStateFlow>(emptyList()) val peers: StateFlow> = _peers.asStateFlow() @@ -69,20 +71,25 @@ object AppStateStore { fun setTransportDirectPeers(transportId: String, ids: Collection) { synchronized(this) { directPeerIdsByTransport[transportId] = ids.toSet() + publishDirectPeersLocked() } } fun clearTransportDirectPeers(transportId: String) { synchronized(this) { directPeerIdsByTransport.remove(transportId) + publishDirectPeersLocked() } } /** Union of direct peers across all transports. */ - fun getDirectPeers(): Set { - synchronized(this) { - return directPeerIdsByTransport.values.flatten().toSet() - } + fun getDirectPeers(): Set = _directPeers.value + + private fun publishDirectPeersLocked() { + _directPeers.value = directPeerIdsByTransport.values + .asSequence() + .flatten() + .toSet() } fun addPublicMessage(msg: BitchatMessage) { @@ -210,6 +217,7 @@ object AppStateStore { peerIdsByTransport.clear() directPeerIdsByTransport.clear() _peers.value = emptyList() + _directPeers.value = emptySet() _publicMessages.value = emptyList() _privateMessages.value = emptyMap() _channelMessages.value = emptyMap() diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 4c6d5cdc..87e6104e 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -340,6 +340,7 @@ class ChatViewModel( } override fun onCleared() { + geohashViewModel.shutdownUiSubscriptions() super.onCleared() // Note: Mesh service lifecycle is now managed by MainActivity } @@ -358,34 +359,9 @@ class ChatViewModel( */ 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) + val gh = geohashViewModel.conversationGeohash(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) - } - ) - } + com.bitchat.android.nostr.NostrBackgroundRuntime.ensureConversationDm(gh) } } catch (e: Exception) { Log.w(TAG, "ensureGeohashDMSubscriptionIfNeeded failed: ${e.message}") diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index d94e5fff..2e74f3e0 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.viewModelScope import com.bitchat.android.nostr.GeohashMessageHandler import com.bitchat.android.nostr.GeohashRepository import com.bitchat.android.nostr.NostrDirectMessageHandler +import com.bitchat.android.nostr.NostrBackgroundRuntime import com.bitchat.android.nostr.NostrIdentityBridge import com.bitchat.android.nostr.NostrProtocol import com.bitchat.android.nostr.NostrRelayManager @@ -23,11 +24,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow 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 kotlin.random.asKotlinRandom class GeohashViewModel( application: Application, @@ -39,19 +37,21 @@ class GeohashViewModel( private val notificationManager: NotificationManager ) : AndroidViewModel(application), DefaultLifecycleObserver { - companion object { - private const val TAG = "GeohashViewModel" - private val secureRandom = SecureRandom().asKotlinRandom() - } + companion object { private const val TAG = "GeohashViewModel" } private val repo = GeohashRepository(application, state, dataManager) - private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope) + private val uiSubscriptionOwner = "geohash-ui-${System.identityHashCode(this)}" + private val subscriptionManager = NostrSubscriptionManager( + application, + viewModelScope, + owner = uiSubscriptionOwner + ) private val geohashMessageHandler = GeohashMessageHandler( application = application, state = state, messageManager = messageManager, repo = repo, - scope = viewModelScope, + scope = NostrBackgroundRuntime.eventScope, dataManager = dataManager ) private val dmHandler = NostrDirectMessageHandler( @@ -59,18 +59,14 @@ class GeohashViewModel( state = state, privateChatManager = privateChatManager, meshDelegateHandler = meshDelegateHandler, - scope = viewModelScope, + scope = NostrBackgroundRuntime.eventScope, repo = repo, dataManager = dataManager ) - // Live channel message stream (kind 20000). Low-volume; kept alive in the background. - private var currentGeohashMsgSubId: String? = null // Presence heartbeat firehose (kind 20001). High-volume; paused while backgrounded. private var currentGeohashPresenceSubId: String? = null - private var currentDmSubId: 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() @@ -82,23 +78,21 @@ class GeohashViewModel( val selectedLocationChannel: StateFlow = state.selectedLocationChannel fun initialize() { - subscriptionManager.connect() // Observe process lifecycle to manage background sampling kotlin.runCatching { ProcessLifecycleOwner.get().lifecycle.addObserver(this) } - val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) - if (identity != null) { - // Use global chat-messages only for full account DMs (mesh context). For geohash DMs, subscribe per-geohash below. - subscriptionManager.subscribeGiftWraps( - pubkey = identity.publicKeyHex, - sinceMs = System.currentTimeMillis() - 172800000L, - id = "chat-messages", - handler = { event -> dmHandler.onGiftWrap(event, "", identity) } // geohash="" means global account DM (not geohash identity) - ) - } try { locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) + NostrBackgroundRuntime.attachHandlers( + NostrBackgroundRuntime.Handlers( + accountDm = { event, identity -> dmHandler.onGiftWrap(event, "", identity) }, + geohashMessage = { event, geohash -> geohashMessageHandler.onEvent(event, geohash) }, + geohashDm = { event, geohash, identity -> + dmHandler.onGiftWrap(event, geohash, identity) + } + ) + ) viewModelScope.launch { locationChannelManager?.selectedChannel?.collect { channel -> state.setSelectedLocationChannel(channel) @@ -110,10 +104,6 @@ class GeohashViewModel( state.setIsTeleported(teleported) } } - - // Start global presence heartbeat loop - startGlobalPresenceHeartbeat() - } catch (e: Exception) { Log.e(TAG, "Failed to initialize location channel state: ${e.message}") state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh) @@ -121,77 +111,17 @@ 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 } - - if (targetGeohashes.isNotEmpty()) { - // Enter heartbeat loop for this set of channels - // If channels change (e.g. user moves), collectLatest cancels this loop and starts a new one immediately - while (true) { - // Randomize loop interval (40-80s, average 60s) - val loopInterval = secureRandom.nextLong(40000L, 80000L) - var timeSpent = 0L - - try { - Log.v(TAG, "💓 Broadcasting global presence to ${targetGeohashes.size} channels") - targetGeohashes.forEach { geohash -> - // Decorrelate individual broadcasts with random delay (1s-5s) - val stepDelay = secureRandom.nextLong(1000L, 10000L) - delay(stepDelay) - timeSpent += stepDelay - - broadcastPresence(geohash) - } - } catch (e: Exception) { - Log.w(TAG, "Global presence heartbeat error: ${e.message}") - } - - // Wait remaining time to satisfy target average cadence - val remaining = loopInterval - timeSpent - if (remaining > 0) { - delay(remaining) - } else { - delay(10000L) // Minimum guard delay - } - } - } - } - } - } - fun panicReset() { repo.clearAll() GeohashAliasRegistry.clear() GeohashConversationRegistry.clear() - subscriptionManager.disconnect() - currentGeohashMsgSubId = null + subscriptionManager.unsubscribeAllOwned() currentGeohashPresenceSubId = null - currentDmSubId = null activeChannelGeohash = null geoTimer?.cancel() geoTimer = null - globalPresenceJob?.cancel() - globalPresenceJob = null try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {} - initialize() - } - - private suspend fun broadcastPresence(geohash: String) { - 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") - } catch (e: Exception) { - Log.w(TAG, "Failed to send presence for $geohash: ${e.message}") - } + NostrBackgroundRuntime.resetSubscriptions() } fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { @@ -331,15 +261,14 @@ class GeohashViewModel( fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex) fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + fun conversationGeohash(conversationKey: String): String? = repo.getConversationGeohash(conversationKey) fun peerColorSeedForNostrPubkey(pubkeyHex: String): PeerColorSeed = nostrPeerColorSeed(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 } when (channel) { is com.bitchat.android.geohash.ChannelID.Mesh -> { @@ -368,17 +297,11 @@ class GeohashViewModel( startGeoParticipantsTimer() - // Chat message stream (kind 20000) is low-volume; keep it alive even when - // backgrounded so geohash messages still arrive. - subscribeChannelMessages(channel.channel.geohash) // 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) } - // Gift-wrap DM subscription is lightweight (filtered to our pubkey) and is - // kept alive in the background so geohash DMs still arrive. - subscribeChannelDM(channel.channel.geohash) } null -> { Log.d(TAG, "📡 No channel selected") @@ -388,21 +311,6 @@ 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 - subscriptionManager.subscribeGeohashMessages( - geohash = geohash, - sinceMs = System.currentTimeMillis() - 3600000L, - limit = 200, - id = subId, - handler = { event -> geohashMessageHandler.onEvent(event, geohash) } - ) - } - /** * Subscribe to the presence heartbeat firehose (kind 20001) for a geohash channel. * High-volume; only used to refresh the participant list, so it is torn down in @@ -419,25 +327,6 @@ class GeohashViewModel( ) } - /** - * Subscribe to gift-wrap DMs for a geohash channel's derived identity. - * 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) - } - } - private fun startGeoParticipantsTimer() { geoTimer = viewModelScope.launch { while (repo.getCurrentGeohash() != null) { @@ -449,6 +338,11 @@ class GeohashViewModel( override fun onCleared() { super.onCleared() + shutdownUiSubscriptions() + } + + fun shutdownUiSubscriptions() { + subscriptionManager.unsubscribeAllOwned() kotlin.runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this) } @@ -465,10 +359,6 @@ class GeohashViewModel( if (repo.getCurrentGeohash() != null && geoTimer?.isActive != true) { startGeoParticipantsTimer() } - // Resume the global presence heartbeat - if (globalPresenceJob?.isActive != true) { - startGlobalPresenceHeartbeat() - } } override fun onStop(owner: LifecycleOwner) { @@ -478,12 +368,9 @@ class GeohashViewModel( currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null } // Drop geohash sampling subscriptions activeSamplingGeohashes.forEach { subscriptionManager.unsubscribe("sampling-$it") } - // Stop broadcasting presence heartbeats - globalPresenceJob?.cancel(); globalPresenceJob = null // Stop participant-refresh polling geoTimer?.cancel(); geoTimer = null - // NOTE: gift-wrap DM subscriptions (per-geohash + global "chat-messages") are intentionally - // left active so direct messages still arrive while backgrounded. + // Process-owned message and DM subscriptions remain active. } private fun performSubscribeSampling(geohash: String) { diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 4905c927..2be47a99 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -90,7 +90,7 @@ object AppConstants { const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 29_000L const val MAX_CONNECTIONS_NORMAL: Int = 8 const val MAX_CONNECTIONS_POWER_SAVE: Int = 8 - const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4 + const val MAX_CONNECTIONS_ULTRA_LOW: Int = 8 } object Nostr { diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt b/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt index c3039327..9688f04f 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt @@ -24,10 +24,9 @@ class SyncedSocket( private val outputStream: DataOutputStream companion object { - // Both peers exchange keep-alive frames every ~2s while connected, so a read that - // stalls well beyond that means the link is dead (half-open). Time out so the read - // loop can detect it and trigger disconnection instead of blocking forever. - const val DEFAULT_READ_TIMEOUT_MS = 15_000 + // Critical-background peers send at most every 30s. Tolerate three missed frames so a + // faster local profile cannot drop a healthy, slower remote peer merely for being idle. + const val DEFAULT_READ_TIMEOUT_MS = 90_000 } init { diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt index f2ec4cfa..1c2b7e5f 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt @@ -6,12 +6,10 @@ import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicBoolean @@ -68,24 +66,20 @@ object WifiAwareController { Log.i(TAG, "Wi-Fi Aware unsupported: ${status.reason}") } setEnabled(enabledByDefault) - // Start background poller for debug surfacing - scope.launch { - while (isActive) { - try { - val s = service - if (s != null) { - _connectedPeers.value = s.getDeviceAddressToPeerMapping() // peerID -> ip - _knownPeers.value = s.getPeerNicknames() - _discoveredPeers.value = s.getDiscoveredPeerIds() - } else { - _connectedPeers.value = emptyMap() - _knownPeers.value = emptyMap() - _discoveredPeers.value = emptySet() - } - } catch (_: Exception) { } - delay(1000) - } - } + } + + internal fun publishDebugSnapshot( + connected: Map, + known: Map, + discovered: Set + ) { + _connectedPeers.value = connected + _knownPeers.value = known + _discoveredPeers.value = discovered + } + + private fun clearDebugSnapshot() { + publishDebugSnapshot(emptyMap(), emptyMap(), emptySet()) } fun setEnabled(value: Boolean) { @@ -202,9 +196,7 @@ object WifiAwareController { } try { stopped?.stopServices() } catch (_: Exception) { } try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } - _connectedPeers.value = emptyMap() - _knownPeers.value = emptyMap() - _discoveredPeers.value = emptySet() + clearDebugSnapshot() try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware stopped")) } catch (_: Exception) {} } @@ -214,9 +206,7 @@ object WifiAwareController { service = null _running.value = false try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } - _connectedPeers.value = emptyMap() - _knownPeers.value = emptyMap() - _discoveredPeers.value = emptySet() + clearDebugSnapshot() } } diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index d6cbf981..cd83e898 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -76,10 +76,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor private const val CLIENT_SOCKET_ATTEMPTS = 3 private const val CLIENT_ROLE_REVERSAL_FAILURES = 3 private const val WIFI_AUTHENTICATION_TIMEOUT_MS = 30_000L - // Discovery freshness window for reconnection maintenance - private const val DISCOVERY_STALE_MS = 5L * 60 * 1000 - private const val DISCOVERY_IDLE_REFRESH_MS = 2L * 60 * 1000 - private const val DISCOVERY_SESSION_REFRESH_MIN_INTERVAL_MS = 90L * 1000 private const val ROLE_REVERSAL_PREFIX = "ROLE_SERVER:" } @@ -89,6 +85,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor // Peer ID must match BluetoothMeshService: first 16 hex chars of identity fingerprint (8 bytes) override val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val powerManager = com.bitchat.android.mesh.PowerManager.getInstance(context.applicationContext) private val wifiTransport = WifiAwareTransport() private lateinit var meshCore: MeshCore private lateinit var fragmentingSender: FragmentingPacketSender @@ -175,6 +172,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor hooks = MeshCore.Hooks( onMessageReceived = { message -> handleMessageReceived(message) }, onAnnounceProcessed = { routed, _ -> + publishControllerDebugSnapshot() routed.peerID?.let { pid -> try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } @@ -653,6 +651,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor val now = System.currentTimeMillis() discoveredTimestamps[peerId] = now lastDiscoveryActivityAt.set(now) + publishControllerDebugSnapshot() } @RequiresApi(Build.VERSION_CODES.Q) @@ -669,7 +668,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor if (!com.bitchat.android.wifiaware.WifiAwareController.enabled.value) return false val lastRefresh = lastDiscoveryRefreshAt.get() - if ((now - lastRefresh) < DISCOVERY_SESSION_REFRESH_MIN_INTERVAL_MS) return false + val minRefreshMs = powerManager.profile.value.wifiAware.discoverySessionRefreshMinMs + if ((now - lastRefresh) < minRefreshMs) return false if (!lastDiscoveryRefreshAt.compareAndSet(lastRefresh, now)) return false Log.i(TAG, "Refreshing Wi-Fi Aware discovery sessions ($reason)") @@ -684,7 +684,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor serviceScope.launch { while (isActive) { try { - delay(15_000) // Check every 15 seconds + val schedule = powerManager.profile.value.wifiAware + delay(schedule.connectionMaintenanceMs) if (!isActive) break val now = System.currentTimeMillis() @@ -692,18 +693,19 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor // 0. Prune stale discovery entries. PeerHandles become invalid when the // discovery sessions restart, so we must not keep pinging old handles forever. val staleIds = discoveredTimestamps.filter { (id, ts) -> - (now - ts) >= DISCOVERY_STALE_MS && !connectionTracker.isConnected(id) + (now - ts) >= schedule.discoveryStaleMs && !connectionTracker.isConnected(id) }.keys.toSet() if (staleIds.isNotEmpty()) { staleIds.forEach { discoveredTimestamps.remove(it) } handleToPeerId.entries.removeIf { it.value in staleIds } staleIds.forEach { subscribeHandles.remove(it) } staleIds.forEach { publishHandles.remove(it) } + publishControllerDebugSnapshot() } // 1. Identify peers that are discovered (recently seen) but not currently connected val recentDiscovered = discoveredTimestamps.filter { (id, ts) -> - (now - ts) < DISCOVERY_STALE_MS // Seen in last 5 minutes + (now - ts) < schedule.discoveryStaleMs }.keys // 2. Filter out those who are already connected @@ -753,7 +755,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor disconnectedPeers.isNotEmpty() && missingUsableHandle && !attemptedReconnect -> { refreshDiscoverySessions("missing peer handle", now) } - recentDiscovered.isEmpty() && idleFor >= DISCOVERY_IDLE_REFRESH_MS -> { + recentDiscovered.isEmpty() && idleFor >= schedule.discoveryIdleRefreshMs -> { refreshDiscoverySessions("idle discovery", now) } } @@ -896,6 +898,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor val synced = SyncedSocket(client) activeSocket = synced connectionTracker.onClientConnected(peerId, synced) + publishControllerDebugSnapshot() // We only ever accept a single data socket per server request. Close the // listening ServerSocket now so it can't block a future re-serve (its // presence makes hasOpenServerSocket() true for the life of the process) @@ -964,30 +967,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor pubSession: PublishDiscoverySession, peerHandle: PeerHandle ) { - // TCP keep-alive pings - serviceScope.launch { - try { - while (connectionTracker.isConnected(peerId)) { - // write empty byte array effectively sends [4 bytes length=0] which is our ping - try { - client.write(ByteArray(0)) - } catch (_: IOException) { - // The write side is dead. Don't just stop pinging: actively tear down so the - // half-open socket stops counting as "connected" and maintenance can retry. - handlePeerDisconnection(peerId, client) - break - } - delay(2_000) - } - } catch (_: Exception) {} - } - // Discovery keep-alive - serviceScope.launch { - var msgId = 0 - while (connectionTracker.isConnected(peerId)) { - try { pubSession.sendMessage(peerHandle, msgId++, ByteArray(0)) } catch (_: Exception) { break } - delay(20_000) - } + startProfiledKeepAlive(client, peerId) { msgId -> + pubSession.sendMessage(peerHandle, msgId, ByteArray(0)) } } @@ -1155,6 +1136,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor val synced = SyncedSocket(sock) activeSocket = synced connectionTracker.onClientConnected(peerId, synced) + publishControllerDebugSnapshot() clientSocketFailures.remove(peerId) try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {} try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} @@ -1199,28 +1181,50 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor peerId: String, peerHandle: PeerHandle ) { - // TCP keep-alive + startProfiledKeepAlive(sock, peerId) { msgId -> + subscribeSession?.sendMessage(peerHandle, msgId, ByteArray(0)) + } + } + + /** + * One coroutine per peer schedules both TCP and discovery deadlines. This avoids two + * independently waking jobs while still adapting after every send to the latest profile. + */ + private fun startProfiledKeepAlive( + socket: SyncedSocket, + peerId: String, + sendDiscovery: (Int) -> Unit + ) { serviceScope.launch { - try { - while (connectionTracker.isConnected(peerId)) { + var discoveryMessageId = 0 + var nextTcpAt = 0L + var nextDiscoveryAt = 0L + while (connectionTracker.isConnected(peerId)) { + val now = android.os.SystemClock.elapsedRealtime() + val schedule = powerManager.profile.value.wifiAware + + if (now >= nextTcpAt) { try { - sock.write(ByteArray(0)) + socket.write(ByteArray(0)) } catch (_: IOException) { - // The write side is dead. Tear down so the half-open socket stops counting - // as "connected" and maintenance can retry instead of silently stalling. - handlePeerDisconnection(peerId, sock) + handlePeerDisconnection(peerId, socket) break } - delay(2_000) + nextTcpAt = now + schedule.tcpKeepAliveMs } - } catch (_: Exception) {} - } - // Discovery keep-alive - serviceScope.launch { - var msgId = 0 - while (connectionTracker.isConnected(peerId)) { - try { subscribeSession?.sendMessage(peerHandle, msgId++, ByteArray(0)) } catch (_: Exception) { break } - delay(20_000) + + if (now >= nextDiscoveryAt) { + try { + sendDiscovery(discoveryMessageId++) + } catch (_: Exception) { + // The TCP data path remains usable; discovery maintenance will recover its + // session independently without dropping this idle connection. + } + nextDiscoveryAt = now + schedule.discoveryKeepAliveMs + } + + val nextWakeAt = minOf(nextTcpAt, nextDiscoveryAt) + delay((nextWakeAt - android.os.SystemClock.elapsedRealtime()).coerceAtLeast(250L)) } } } @@ -1297,6 +1301,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor try { meshCore.addOrUpdatePeer(canonicalPeerId, meshCore.getPeerNickname(canonicalPeerId) ?: canonicalPeerId) } catch (_: Exception) { } try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { } try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(canonicalPeerId, 1_000) } catch (_: Exception) { } + publishControllerDebugSnapshot() Log.i(TAG, "Noise-authenticated Wi-Fi peer ${provisionalPeerId.take(8)} -> ${canonicalPeerId.take(8)}") } @@ -1401,6 +1406,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor } } // Else: socket replaced or inactive; do not remove peer/session, as a new socket has likely taken over + publishControllerDebugSnapshot() } } @@ -1611,6 +1617,16 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor fun getDiscoveredPeerIds(): Set = (handleToPeerId.values + discoveredTimestamps.keys).filter { it.isNotBlank() }.toSet() + private fun publishControllerDebugSnapshot() { + try { + com.bitchat.android.wifiaware.WifiAwareController.publishDebugSnapshot( + connected = getDeviceAddressToPeerMapping(), + known = getPeerNicknames(), + discovered = getDiscoveredPeerIds() + ) + } catch (_: Exception) { } + } + /** * Utility for logs/UI: pretty-prints one peer-to-address mapping per line. */ diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt new file mode 100644 index 00000000..8d4df343 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt @@ -0,0 +1,102 @@ +package com.bitchat.android.mesh + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PowerProfileResolverTest { + + @Test + fun `background normal without peers scans one second per minute`() { + val profile = resolve(battery = 80, background = true, peers = false) + + assertEquals(PowerManager.BatteryBand.NORMAL, profile.batteryBand) + assertEquals(1_000L, profile.ble.scanOnMs) + assertEquals(59_000L, profile.ble.scanOffMs) + assertFalse(profile.ble.continuousScan) + assertEquals(8, profile.ble.maxConnections) + assertEquals(60_000L, profile.meshAnnouncementIntervalMs) + } + + @Test + fun `any direct transport peer accelerates background BLE discovery to thirty seconds`() { + val profile = resolve(battery = 80, background = true, peers = true) + + assertEquals(1_000L, profile.ble.scanOnMs) + assertEquals(29_000L, profile.ble.scanOffMs) + } + + @Test + fun `background low profile uses selected wifi and nostr cadence`() { + val profile = resolve(battery = 20, background = true) + + assertEquals(PowerManager.BatteryBand.LOW, profile.batteryBand) + assertEquals(20_000L, profile.wifiAware.tcpKeepAliveMs) + assertEquals(120_000L, profile.wifiAware.discoveryKeepAliveMs) + assertEquals(120_000L, profile.wifiAware.connectionMaintenanceMs) + assertEquals(600_000L, profile.wifiAware.discoveryIdleRefreshMs) + assertEquals(900_000L, profile.wifiAware.discoveryStaleMs) + assertEquals(600_000L, profile.nostr.presenceHeartbeatMinMs) + assertEquals(600_000L, profile.nostr.subscriptionValidationMs) + assertEquals(120_000L, profile.meshAnnouncementIntervalMs) + } + + @Test + fun `background critical profile uses slowest wifi and nostr cadence without reducing connections`() { + val profile = resolve(battery = 10, background = true, peers = true) + + assertEquals(PowerManager.BatteryBand.CRITICAL, profile.batteryBand) + assertEquals(30_000L, profile.wifiAware.tcpKeepAliveMs) + assertEquals(180_000L, profile.wifiAware.discoveryKeepAliveMs) + assertEquals(180_000L, profile.wifiAware.connectionMaintenanceMs) + assertEquals(900_000L, profile.wifiAware.discoveryIdleRefreshMs) + assertEquals(1_200_000L, profile.wifiAware.discoveryStaleMs) + assertEquals(900_000L, profile.nostr.presenceHeartbeatMinMs) + assertEquals(900_000L, profile.nostr.subscriptionValidationMs) + assertEquals(300_000L, profile.meshAnnouncementIntervalMs) + assertEquals(8, profile.ble.maxConnections) + } + + @Test + fun `foreground normal preserves balanced BLE cadence`() { + val profile = resolve(battery = 80, background = false) + + assertEquals(PowerManager.PowerMode.BALANCED, profile.mode) + assertEquals(8_000L, profile.ble.scanOnMs) + assertEquals(2_000L, profile.ble.scanOffMs) + assertEquals(5_000L, profile.wifiAware.tcpKeepAliveMs) + assertEquals(30_000L, profile.meshAnnouncementIntervalMs) + } + + @Test + fun `foreground charging is performance but background charging stays power limited`() { + val foreground = PowerProfileResolver.resolve(80, true, false, false) + val background = PowerProfileResolver.resolve(80, true, true, false) + + assertEquals(PowerManager.PowerMode.PERFORMANCE, foreground.mode) + assertTrue(foreground.ble.continuousScan) + assertEquals(PowerManager.PowerMode.POWER_SAVER, background.mode) + assertFalse(background.ble.continuousScan) + assertEquals(59_000L, background.ble.scanOffMs) + } + + @Test + fun `battery thresholds are inclusive`() { + assertEquals(PowerManager.BatteryBand.NORMAL, resolve(21, true).batteryBand) + assertEquals(PowerManager.BatteryBand.LOW, resolve(20, true).batteryBand) + assertEquals(PowerManager.BatteryBand.LOW, resolve(11, true).batteryBand) + assertEquals(PowerManager.BatteryBand.CRITICAL, resolve(10, true).batteryBand) + } + + private fun resolve( + battery: Int, + background: Boolean, + peers: Boolean = false + ) = PowerProfileResolver.resolve( + batteryLevel = battery, + isCharging = false, + isBackground = background, + hasDirectPeers = peers + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index 577823ac..bebae3eb 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -77,6 +77,10 @@ class AppStateStoreTest { setOf("ble-1", "wifi-1", "shared"), AppStateStore.getDirectPeers() ) + assertEquals( + setOf("ble-1", "wifi-1", "shared"), + AppStateStore.directPeers.value + ) } @Test From 7025009788e2681c00ea5b2ce9254d3bfaaf796e Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:33:11 +0200 Subject: [PATCH 36/70] =?UTF-8?q?ui:=20complete=20the=20redesign=20?= =?UTF-8?q?=E2=80=94=20palette,=20top=20bar,=20composer,=20About,=20and=20?= =?UTF-8?q?a=20motion=20pass=20(#774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * first pass * pass 2 * cleanup * capitalization * strings * input bar fixes * fixes * notes * nice * nicer * lists * cleanup * button * fixes * animations * Fix layout jumpiness in chat and geohash people list Three separate causes of things moving when they should not: - Chat lurched whenever a bottom sheet closed. Placement animation is meant to soften insertions and removals, but any relayout moves every item -- a sheet's text field opening the keyboard changes the chat's IME inset, and closing it changes it back. Placement animation is now armed only briefly around a real change to the message list, so items otherwise track the viewport exactly. - Anon list changed height as participants churned. Rows sized to their content, so any reorder could change the card's height; and the card sized to the live anon count, which moves constantly in a busy geohash. Rows now have an exact height, and a trimmed anon card reserves the full capped height regardless of how many are present beyond the cap. - Anons are now their own trailing section rather than a tail on each of "on location" and "teleported in", which had pushed the few recognisable names out of view twice over. Self is never grouped as an anon. Adds 7 tests covering the sectioning and the fixed-length behaviour. * Group geohash people as People and Anon Replaces the "on location" / "teleported in" / "anonymous" split with two sections: peers who announced a nickname, then the anons. Teleport state was never worth a section of its own -- every row already carries it as a distinct glyph -- and splitting on it fragmented the short list people actually read, in a channel where most participants are anonymous anyway. Self stays in the People section even when unnamed. * Key message list state per conversation Switching channels reused every piece of state in MessagesList, because none of it was keyed on which conversation was being shown: - The LazyListState carried the previous channel's scroll offset, so the new channel opened at a stale position and then corrected itself. - hasScrolledToInitialPosition and followIncomingMessages carried over, so a channel entered after scrolling up in another one did not land on its newest message at all. - The arrival tracker had never seen the incoming channel's ids, so a backlog of six or fewer messages was treated as six simultaneous arrivals and each one slid in. - previousMessageCount carried over, arming placement animation for the relayout that the switch itself caused. All of it is now keyed on a conversationKey derived the same way displayMessages is. The tracker also detects a list sharing no ids with the previous one and adopts it silently, which covers /clear and any caller that does not supply a distinct key. Adds 4 tests for wholesale replacement, including the case that the burst cap cannot catch on its own. * fix location channel layout * icon * location sheet * move location error * fix location channel lifecycle bug * remove empty lable * geist mono * timestamp no seconds * new icons * icons * cleanup * mentions * fix mentions * grouping of geohash channel list * colors * fix mention colors * Bring private and group chat headers up to the main header's layout Both conversation headers were built on TopAppBar with a centred title, a back arrow on the left and everything else crowded into the title slot, at 14sp with 14dp icons. Moving between the timeline and a conversation visibly shifted the bar's height, insets and type. Introduces ConversationHeader, built from the main header's own tokens rather than TopAppBar: same ChatHeaderHeight, same 12/8dp edge insets, leading glyph in a 44dp slot so it lands exactly where the brand mark does, same -6dp optical nudge pulling the title toward it, same 17sp label. - Drops the back button; the close action on the right is the way out. Leaving a channel outright already lives on its row in the network sheet, so it does not need a second home beside the exit. - Leading glyph is the transport: globe over the internet, wifi/bluetooth/ routed on the mesh, matching the main header's channel button. - Actions are right-aligned and unweighted -- favourite, encryption state, close -- so a long title yields space to them instead of pushing them off screen. - Private chat titles use the primary green like every other header label, rather than orange for Nostr-reachable peers. Height and edge insets now belong to each header variant instead of the ChatFloatingHeader wrapper, which was applying them a second time to the channel header. Adds nine spec icons in the existing 20x20 / 1.25-stroke language -- bluetooth, wifi, routed, close, check, warning, sync, lock_open, envelope -- so the headers and peer rows no longer mix Material glyphs into the set. * color --- .gitignore | 3 + app/src/main/assets/design-icons/README.md | 6 + .../assets/design-icons/bookmark-filled.svg | 3 + .../assets/design-icons/bookmark-outline.svg | 3 + .../main/assets/design-icons/chat-bubbles.svg | 6 + app/src/main/assets/design-icons/command.svg | 6 + app/src/main/assets/design-icons/eye-off.svg | 6 + app/src/main/assets/design-icons/globe.svg | 7 + app/src/main/assets/design-icons/lock.svg | 7 + app/src/main/assets/design-icons/mention.svg | 6 + .../design-icons/on-location-person.svg | 5 + app/src/main/assets/design-icons/panic.svg | 3 + app/src/main/assets/design-icons/people.svg | 6 + app/src/main/assets/design-icons/person.svg | 6 + app/src/main/assets/design-icons/range.svg | 6 + app/src/main/assets/design-icons/shuffle.svg | 3 + .../main/assets/design-icons/star-filled.svg | 3 + app/src/main/assets/design-icons/star.svg | 3 + app/src/main/assets/design-icons/teleport.svg | 5 + app/src/main/assets/design-icons/waveform.svg | 3 + app/src/main/assets/design-icons/wifi-off.svg | 8 + .../ui/component/button/BitChatBrandButton.kt | 61 +- .../core/ui/component/button/CloseButton.kt | 22 +- .../ui/component/sheet/BitchatBottomSheet.kt | 41 +- .../ui/component/sheet/BitchatSheetTopBar.kt | 11 +- .../android/hotspot/HotspotActivity.kt | 8 +- .../bitchat/android/model/BitchatMessage.kt | 11 +- .../android/nostr/GeohashMessageHandler.kt | 1 + .../nostr/NostrDirectMessageHandler.kt | 4 +- .../BackgroundLocationPermissionScreen.kt | 24 +- .../onboarding/BatteryOptimizationScreen.kt | 32 +- .../onboarding/BluetoothCheckScreen.kt | 20 +- .../android/onboarding/InitializingScreen.kt | 20 +- .../android/onboarding/LocationCheckScreen.kt | 24 +- .../onboarding/PermissionExplanationScreen.kt | 18 +- .../com/bitchat/android/ui/AboutSections.kt | 605 ++++++++ .../java/com/bitchat/android/ui/AboutSheet.kt | 480 +++--- .../com/bitchat/android/ui/AnimatedCount.kt | 117 ++ .../bitchat/android/ui/AnimatedRowColumn.kt | 85 ++ .../java/com/bitchat/android/ui/ChatHeader.kt | 786 +++++++--- .../java/com/bitchat/android/ui/ChatScreen.kt | 496 +++--- .../com/bitchat/android/ui/ChatUIUtils.kt | 677 ++++---- .../com/bitchat/android/ui/ChatUserSheet.kt | 26 +- .../com/bitchat/android/ui/ChatViewModel.kt | 17 +- .../bitchat/android/ui/CommandProcessor.kt | 48 +- .../bitchat/android/ui/GeohashPeopleList.kt | 448 +++--- .../android/ui/GeohashPickerActivity.kt | 24 +- .../bitchat/android/ui/GeohashViewModel.kt | 50 +- .../com/bitchat/android/ui/InputComponents.kt | 838 ++++++---- .../com/bitchat/android/ui/LinkPreviewPill.kt | 21 +- .../android/ui/LocationChannelsSheet.kt | 1360 +++++++++++------ .../bitchat/android/ui/LocationNotesButton.kt | 48 +- .../bitchat/android/ui/LocationNotesSheet.kt | 52 +- .../android/ui/MatrixEncryptionAnimation.kt | 179 --- .../bitchat/android/ui/MeshPeerListSheet.kt | 754 ++++----- .../bitchat/android/ui/MessageComponents.kt | 507 ++++-- .../com/bitchat/android/ui/MessageGrouping.kt | 84 + .../com/bitchat/android/ui/PeerIdentity.kt | 71 + .../bitchat/android/ui/PoWStatusIndicator.kt | 133 -- .../com/bitchat/android/ui/PressFeedback.kt | 64 + .../android/ui/SecurityVerificationSheet.kt | 53 +- .../bitchat/android/ui/VerificationSheet.kt | 36 +- .../android/ui/VoiceInputComponents.kt | 217 ++- .../android/ui/debug/DebugSettingsSheet.kt | 148 +- .../com/bitchat/android/ui/debug/MeshGraph.kt | 6 +- .../android/ui/media/AudioMessageItem.kt | 21 +- .../android/ui/media/FileMessageItem.kt | 4 +- .../android/ui/media/FilePickerButton.kt | 4 +- .../android/ui/media/FileSendingAnimation.kt | 11 +- .../android/ui/media/FullScreenImageViewer.kt | 9 +- .../android/ui/media/ImageMessageItem.kt | 23 +- .../android/ui/media/ImagePickerButton.kt | 52 +- .../android/ui/media/MediaPickerOptions.kt | 6 +- .../android/ui/media/VoiceNotePlayer.kt | 10 +- .../android/ui/theme/BitchatPalette.kt | 91 ++ .../android/ui/theme/ChatVisualTokens.kt | 64 + .../bitchat/android/ui/theme/PeerColors.kt | 30 + .../com/bitchat/android/ui/theme/Theme.kt | 71 +- .../bitchat/android/ui/theme/Typography.kt | 48 +- .../com/bitchat/android/util/AppConstants.kt | 2 +- .../main/res/drawable/ic_spec_bluetooth.xml | 9 + .../res/drawable/ic_spec_bookmark_filled.xml | 6 + .../res/drawable/ic_spec_bookmark_outline.xml | 6 + .../res/drawable/ic_spec_chat_bubbles.xml | 14 + app/src/main/res/drawable/ic_spec_check.xml | 7 + app/src/main/res/drawable/ic_spec_close.xml | 8 + app/src/main/res/drawable/ic_spec_command.xml | 14 + .../main/res/drawable/ic_spec_envelope.xml | 8 + app/src/main/res/drawable/ic_spec_eye_off.xml | 7 + app/src/main/res/drawable/ic_spec_globe.xml | 17 + app/src/main/res/drawable/ic_spec_lock.xml | 11 + .../main/res/drawable/ic_spec_lock_open.xml | 13 + app/src/main/res/drawable/ic_spec_mention.xml | 7 + .../drawable/ic_spec_on_location_person.xml | 13 + app/src/main/res/drawable/ic_spec_panic.xml | 6 + app/src/main/res/drawable/ic_spec_people.xml | 14 + app/src/main/res/drawable/ic_spec_person.xml | 14 + app/src/main/res/drawable/ic_spec_range.xml | 11 + app/src/main/res/drawable/ic_spec_routed.xml | 11 + app/src/main/res/drawable/ic_spec_shuffle.xml | 6 + app/src/main/res/drawable/ic_spec_star.xml | 8 + .../main/res/drawable/ic_spec_star_filled.xml | 8 + app/src/main/res/drawable/ic_spec_sync.xml | 10 + .../main/res/drawable/ic_spec_teleport.xml | 9 + app/src/main/res/drawable/ic_spec_warning.xml | 9 + .../main/res/drawable/ic_spec_waveform.xml | 5 + app/src/main/res/drawable/ic_spec_wifi.xml | 9 + .../main/res/drawable/ic_spec_wifi_off.xml | 12 + app/src/main/res/font/geist_mono_bold.ttf | Bin 0 -> 150492 bytes app/src/main/res/font/geist_mono_medium.ttf | Bin 0 -> 149328 bytes app/src/main/res/font/geist_mono_regular.ttf | Bin 0 -> 148516 bytes app/src/main/res/font/geist_mono_semibold.ttf | Bin 0 -> 149700 bytes app/src/main/res/raw/geist_mono_ofl.txt | 93 ++ app/src/main/res/values-ar/strings.xml | 15 - app/src/main/res/values-bn/strings.xml | 15 - app/src/main/res/values-de/strings.xml | 15 - app/src/main/res/values-es/strings.xml | 15 - app/src/main/res/values-fa/strings.xml | 15 - app/src/main/res/values-fil/strings.xml | 10 - app/src/main/res/values-fr/strings.xml | 10 - app/src/main/res/values-hi/strings.xml | 15 - app/src/main/res/values-id/strings.xml | 15 - app/src/main/res/values-it/strings.xml | 15 - app/src/main/res/values-ja/strings.xml | 15 - app/src/main/res/values-ka/strings.xml | 15 - app/src/main/res/values-ko/strings.xml | 15 - app/src/main/res/values-mg/strings.xml | 15 - app/src/main/res/values-ne/strings.xml | 10 - app/src/main/res/values-nl/strings.xml | 15 - app/src/main/res/values-pa-rPK/strings.xml | 15 - app/src/main/res/values-pt-rBR/strings.xml | 15 - app/src/main/res/values-pt/strings.xml | 15 - app/src/main/res/values-ru/strings.xml | 10 - app/src/main/res/values-sv/strings.xml | 10 - app/src/main/res/values-th/strings.xml | 15 - app/src/main/res/values-tr/strings.xml | 10 - app/src/main/res/values-ur/strings.xml | 15 - app/src/main/res/values-vi/strings.xml | 15 - app/src/main/res/values-zh/strings.xml | 10 - app/src/main/res/values/strings.xml | 362 +++-- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 552 ++++++- .../android/ui/GeohashPresenceGroupingTest.kt | 130 ++ .../android/ui/LocationChannelsSheetTest.kt | 49 + .../android/ui/MentionSuggestionsTest.kt | 54 + .../android/ui/MessageArrivalTrackerTest.kt | 186 +++ .../bitchat/android/ui/MessageGroupingTest.kt | 154 ++ 146 files changed, 7589 insertions(+), 3895 deletions(-) create mode 100644 app/src/main/assets/design-icons/README.md create mode 100644 app/src/main/assets/design-icons/bookmark-filled.svg create mode 100644 app/src/main/assets/design-icons/bookmark-outline.svg create mode 100644 app/src/main/assets/design-icons/chat-bubbles.svg create mode 100644 app/src/main/assets/design-icons/command.svg create mode 100644 app/src/main/assets/design-icons/eye-off.svg create mode 100644 app/src/main/assets/design-icons/globe.svg create mode 100644 app/src/main/assets/design-icons/lock.svg create mode 100644 app/src/main/assets/design-icons/mention.svg create mode 100644 app/src/main/assets/design-icons/on-location-person.svg create mode 100644 app/src/main/assets/design-icons/panic.svg create mode 100644 app/src/main/assets/design-icons/people.svg create mode 100644 app/src/main/assets/design-icons/person.svg create mode 100644 app/src/main/assets/design-icons/range.svg create mode 100644 app/src/main/assets/design-icons/shuffle.svg create mode 100644 app/src/main/assets/design-icons/star-filled.svg create mode 100644 app/src/main/assets/design-icons/star.svg create mode 100644 app/src/main/assets/design-icons/teleport.svg create mode 100644 app/src/main/assets/design-icons/waveform.svg create mode 100644 app/src/main/assets/design-icons/wifi-off.svg create mode 100644 app/src/main/java/com/bitchat/android/ui/AboutSections.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt delete mode 100644 app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt delete mode 100644 app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/PressFeedback.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt create mode 100644 app/src/main/res/drawable/ic_spec_bluetooth.xml create mode 100644 app/src/main/res/drawable/ic_spec_bookmark_filled.xml create mode 100644 app/src/main/res/drawable/ic_spec_bookmark_outline.xml create mode 100644 app/src/main/res/drawable/ic_spec_chat_bubbles.xml create mode 100644 app/src/main/res/drawable/ic_spec_check.xml create mode 100644 app/src/main/res/drawable/ic_spec_close.xml create mode 100644 app/src/main/res/drawable/ic_spec_command.xml create mode 100644 app/src/main/res/drawable/ic_spec_envelope.xml create mode 100644 app/src/main/res/drawable/ic_spec_eye_off.xml create mode 100644 app/src/main/res/drawable/ic_spec_globe.xml create mode 100644 app/src/main/res/drawable/ic_spec_lock.xml create mode 100644 app/src/main/res/drawable/ic_spec_lock_open.xml create mode 100644 app/src/main/res/drawable/ic_spec_mention.xml create mode 100644 app/src/main/res/drawable/ic_spec_on_location_person.xml create mode 100644 app/src/main/res/drawable/ic_spec_panic.xml create mode 100644 app/src/main/res/drawable/ic_spec_people.xml create mode 100644 app/src/main/res/drawable/ic_spec_person.xml create mode 100644 app/src/main/res/drawable/ic_spec_range.xml create mode 100644 app/src/main/res/drawable/ic_spec_routed.xml create mode 100644 app/src/main/res/drawable/ic_spec_shuffle.xml create mode 100644 app/src/main/res/drawable/ic_spec_star.xml create mode 100644 app/src/main/res/drawable/ic_spec_star_filled.xml create mode 100644 app/src/main/res/drawable/ic_spec_sync.xml create mode 100644 app/src/main/res/drawable/ic_spec_teleport.xml create mode 100644 app/src/main/res/drawable/ic_spec_warning.xml create mode 100644 app/src/main/res/drawable/ic_spec_waveform.xml create mode 100644 app/src/main/res/drawable/ic_spec_wifi.xml create mode 100644 app/src/main/res/drawable/ic_spec_wifi_off.xml create mode 100644 app/src/main/res/font/geist_mono_bold.ttf create mode 100644 app/src/main/res/font/geist_mono_medium.ttf create mode 100644 app/src/main/res/font/geist_mono_regular.ttf create mode 100644 app/src/main/res/font/geist_mono_semibold.ttf create mode 100644 app/src/main/res/raw/geist_mono_ofl.txt create mode 100644 app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt create mode 100644 app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt create mode 100644 app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt create mode 100644 app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt create mode 100644 app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt diff --git a/.gitignore b/.gitignore index 42117323..0986ac74 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ google-services.json # Arti build artifacts (cloned repo and Rust build cache) tools/arti-build/.arti-source/ tools/arti-build/target/ + +# JVM heap dumps (a Gradle daemon OOM drops these in the repo root) +*.hprof diff --git a/app/src/main/assets/design-icons/README.md b/app/src/main/assets/design-icons/README.md new file mode 100644 index 00000000..cd04de8f --- /dev/null +++ b/app/src/main/assets/design-icons/README.md @@ -0,0 +1,6 @@ +# Design-spec icons + +These standalone SVGs were extracted from the supplied 393 px Figma screen exports. The matching +Android vector resources in `res/drawable/ic_spec_*.xml` are the runtime copies used by Compose. +Paths and stroke weights remain faithful to the exports; UI tint and opacity are applied at the +call site so selected and disabled states remain theme-aware. diff --git a/app/src/main/assets/design-icons/bookmark-filled.svg b/app/src/main/assets/design-icons/bookmark-filled.svg new file mode 100644 index 00000000..a2f90c78 --- /dev/null +++ b/app/src/main/assets/design-icons/bookmark-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/bookmark-outline.svg b/app/src/main/assets/design-icons/bookmark-outline.svg new file mode 100644 index 00000000..7ffb105f --- /dev/null +++ b/app/src/main/assets/design-icons/bookmark-outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/chat-bubbles.svg b/app/src/main/assets/design-icons/chat-bubbles.svg new file mode 100644 index 00000000..32546ff8 --- /dev/null +++ b/app/src/main/assets/design-icons/chat-bubbles.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/command.svg b/app/src/main/assets/design-icons/command.svg new file mode 100644 index 00000000..3be46195 --- /dev/null +++ b/app/src/main/assets/design-icons/command.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/eye-off.svg b/app/src/main/assets/design-icons/eye-off.svg new file mode 100644 index 00000000..1e8ad44d --- /dev/null +++ b/app/src/main/assets/design-icons/eye-off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/globe.svg b/app/src/main/assets/design-icons/globe.svg new file mode 100644 index 00000000..e0dc1a45 --- /dev/null +++ b/app/src/main/assets/design-icons/globe.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/assets/design-icons/lock.svg b/app/src/main/assets/design-icons/lock.svg new file mode 100644 index 00000000..28a95cb0 --- /dev/null +++ b/app/src/main/assets/design-icons/lock.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/assets/design-icons/mention.svg b/app/src/main/assets/design-icons/mention.svg new file mode 100644 index 00000000..59f2fa19 --- /dev/null +++ b/app/src/main/assets/design-icons/mention.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/on-location-person.svg b/app/src/main/assets/design-icons/on-location-person.svg new file mode 100644 index 00000000..5b448ae9 --- /dev/null +++ b/app/src/main/assets/design-icons/on-location-person.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/assets/design-icons/panic.svg b/app/src/main/assets/design-icons/panic.svg new file mode 100644 index 00000000..614b9070 --- /dev/null +++ b/app/src/main/assets/design-icons/panic.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/people.svg b/app/src/main/assets/design-icons/people.svg new file mode 100644 index 00000000..f0bce1af --- /dev/null +++ b/app/src/main/assets/design-icons/people.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/person.svg b/app/src/main/assets/design-icons/person.svg new file mode 100644 index 00000000..c0c93053 --- /dev/null +++ b/app/src/main/assets/design-icons/person.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/range.svg b/app/src/main/assets/design-icons/range.svg new file mode 100644 index 00000000..88c36115 --- /dev/null +++ b/app/src/main/assets/design-icons/range.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/shuffle.svg b/app/src/main/assets/design-icons/shuffle.svg new file mode 100644 index 00000000..7f054978 --- /dev/null +++ b/app/src/main/assets/design-icons/shuffle.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/star-filled.svg b/app/src/main/assets/design-icons/star-filled.svg new file mode 100644 index 00000000..ebc21c4f --- /dev/null +++ b/app/src/main/assets/design-icons/star-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/star.svg b/app/src/main/assets/design-icons/star.svg new file mode 100644 index 00000000..b385fc1f --- /dev/null +++ b/app/src/main/assets/design-icons/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/teleport.svg b/app/src/main/assets/design-icons/teleport.svg new file mode 100644 index 00000000..4209d337 --- /dev/null +++ b/app/src/main/assets/design-icons/teleport.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/assets/design-icons/waveform.svg b/app/src/main/assets/design-icons/waveform.svg new file mode 100644 index 00000000..558ccc9d --- /dev/null +++ b/app/src/main/assets/design-icons/waveform.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/wifi-off.svg b/app/src/main/assets/design-icons/wifi-off.svg new file mode 100644 index 00000000..5bb5a4d7 --- /dev/null +++ b/app/src/main/assets/design-icons/wifi-off.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt index 94529d77..731a85fb 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt @@ -1,8 +1,11 @@ package com.bitchat.android.core.ui.component.button +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -12,10 +15,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.bitchat.android.core.ui.icon.BitChatIcon +import com.bitchat.android.ui.rememberPressScale import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -30,6 +38,7 @@ fun BitChatBrandButton( contentDescription: String, modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.primary, + iconSize: Dp = 22.dp, ) { var tapCount by remember { mutableIntStateOf(0) } var resetJob by remember { mutableStateOf(null) } @@ -37,33 +46,47 @@ fun BitChatBrandButton( val currentOnClick by rememberUpdatedState(onClick) val currentOnTripleClick by rememberUpdatedState(onTripleClick) - IconButton( - onClick = { - tapCount += 1 - resetJob?.cancel() + val interactionSource = remember { MutableInteractionSource() } + val pressScale = rememberPressScale(interactionSource) - if (tapCount == 3) { - tapCount = 0 - resetJob = null - currentOnTripleClick() - } else { - resetJob = coroutineScope.launch { - delay(MultiClickThreshold) - if (tapCount == 1) { - currentOnClick() - } + // A plain Box rather than an IconButton: IconButton insists on drawing a ripple, which was the + // only press background left in the header once every other control moved to scale-only + // feedback. + Box( + modifier = modifier + .clip(CircleShape) + .clickable( + interactionSource = interactionSource, + indication = null, + onClickLabel = contentDescription + ) { + tapCount += 1 + resetJob?.cancel() + + if (tapCount == 3) { tapCount = 0 resetJob = null + currentOnTripleClick() + } else { + resetJob = coroutineScope.launch { + delay(MultiClickThreshold) + if (tapCount == 1) { + currentOnClick() + } + tapCount = 0 + resetJob = null + } } - } - }, - modifier = modifier, + }, + contentAlignment = Alignment.Center ) { Icon( imageVector = BitChatIcon, contentDescription = contentDescription, tint = tint, - modifier = Modifier.size(16.dp), + modifier = Modifier + .size(iconSize) + .scale(pressScale), ) } } diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt index 96f408bc..9c60123f 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt @@ -1,34 +1,38 @@ package com.bitchat.android.core.ui.component.button -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.bitchat.android.R @Composable fun CloseButton( onClick: () -> Unit, - modifier: Modifier = Modifier.Companion + modifier: Modifier = Modifier ) { + val colorScheme = MaterialTheme.colorScheme IconButton( onClick = onClick, - modifier = modifier - .size(32.dp), + // 44.dp to match every other tap target in the app's chrome. + modifier = modifier.size(44.dp), colors = IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), - containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f) + contentColor = colorScheme.primary, + containerColor = Color.Transparent ) ) { Icon( imageVector = Icons.Default.Close, - contentDescription = "Close", - modifier = Modifier.Companion.size(18.dp) + contentDescription = stringResource(R.string.close_plain), + modifier = Modifier.size(18.dp) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt index 948fd6d9..e53fed46 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt @@ -9,8 +9,26 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +/** + * Dismisses the enclosing [BitchatBottomSheet], playing the slide-down first. + * + * `ModalBottomSheet` only animates itself out when *it* initiates the dismissal — a swipe or a tap + * on the scrim. Anything that closes a sheet programmatically (a close button, picking an item from + * a list) previously flipped the caller's `isPresented` flag straight to false, which yanks the + * composable out of the tree and makes the sheet vanish instantly. + * + * Anything inside a sheet that wants to close it should prefer this over calling its own + * `onDismiss` directly. + */ +val LocalSheetDismiss = staticCompositionLocalOf<(() -> Unit)?> { null } @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -20,6 +38,20 @@ fun BitchatBottomSheet( onDismissRequest: () -> Unit, content: @Composable (ColumnScope.() -> Unit), ) { + val scope = rememberCoroutineScope() + + // Runs the hide animation to completion, then tells the caller to drop the sheet. `hide()` + // throws if the sheet is already on its way out (two rapid taps on a close button), which is + // benign — the dismissal still has to go through. + val animatedDismiss: () -> Unit = remember(sheetState, onDismissRequest) { + { + scope.launch { + runCatching { sheetState.hide() } + onDismissRequest() + } + } + } + ModalBottomSheet( modifier = modifier.statusBarsPadding(), onDismissRequest = onDismissRequest, @@ -27,6 +59,9 @@ fun BitchatBottomSheet( dragHandle = null, shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), containerColor = MaterialTheme.colorScheme.background, - content = content, - ) -} \ No newline at end of file + ) { + CompositionLocalProvider(LocalSheetDismiss provides animatedDismiss) { + content() + } + } +} diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt index 036cf01b..b8a027dc 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt @@ -10,9 +10,10 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.runtime.CompositionLocalProvider +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.core.ui.component.button.CloseButton @OptIn(ExperimentalMaterial3Api::class) @@ -30,8 +31,9 @@ fun BitchatSheetTopBar( navigationIcon = { navigationIcon?.invoke() }, actions = { actions() + val dismiss = LocalSheetDismiss.current CloseButton( - onClick = onClose, + onClick = { dismiss?.invoke() ?: onClose() }, modifier = Modifier.padding(horizontal = 16.dp) ) }, @@ -59,8 +61,9 @@ fun BitchatSheetCenterTopBar( navigationIcon = { navigationIcon?.invoke() }, actions = { actions() + val dismiss = LocalSheetDismiss.current CloseButton( - onClick = onClose, + onClick = { dismiss?.invoke() ?: onClose() }, modifier = Modifier.padding(horizontal = 16.dp) ) }, @@ -80,7 +83,7 @@ fun BitchatSheetTitle(text: String) { text = text, style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt index 795cce34..dbd3888b 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt @@ -30,12 +30,12 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BitchatTheme import com.bitchat.android.util.UniversalApkManager import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -112,7 +112,7 @@ fun HotspotScreen( title = { Text( text = "Share BitChat", - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) }, navigationIcon = { @@ -398,7 +398,7 @@ fun ActiveHotspotScreen(state: HotspotViewModel.HotspotState.Active) { text = { Text( text = title, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal ) } @@ -615,7 +615,7 @@ fun CredentialCard( Text( text = value, style = MaterialTheme.typography.bodyLarge, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSecondaryContainer ) diff --git a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt index 8e1731b1..528a269e 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt @@ -69,7 +69,15 @@ data class BitchatMessage( val encryptedContent: ByteArray? = null, val isEncrypted: Boolean = false, val deliveryStatus: DeliveryStatus? = null, - val powDifficulty: Int? = null + val powDifficulty: Int? = null, + /** + * Full canonical Nostr public key supplied by the local Nostr bridge. + * + * This is local identity metadata, not part of the Bitchat binary wire format. It lets UI + * surfaces color the sender by the same stable key while [senderPeerID] remains available for + * mesh IDs and private-chat routing aliases. + */ + val senderNostrPubkey: String? = null ) : Parcelable { /** @@ -355,4 +363,3 @@ data class BitchatMessage( } } - diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index 1c4896f3..1fc802d9 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -96,6 +96,7 @@ class GeohashMessageHandler( isRelay = false, originalSender = repo.displayNameForNostrPubkey(pubkey), senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, mentions = null, channel = "#$subscribedGeohash", powDifficulty = try { diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 9d8ae8af..7a49057d 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -148,6 +148,7 @@ class NostrDirectMessageHandler( isPrivate = true, recipientNickname = state.getNicknameValue(), senderPeerID = conversationID, + senderNostrPubkey = senderPubkey, deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date()) ) @@ -201,7 +202,8 @@ class NostrDirectMessageHandler( isRelay = false, isPrivate = true, recipientNickname = state.getNicknameValue(), - senderPeerID = conversationID + senderPeerID = conversationID, + senderNostrPubkey = senderPubkey ) Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)") withContext(Dispatchers.Main) { diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt index 1346cb23..50a58beb 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt @@ -1,5 +1,8 @@ package com.bitchat.android.onboarding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Security import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,9 +16,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.LocationOn -import androidx.compose.material.icons.filled.Security import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ColorScheme @@ -28,11 +28,11 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -99,7 +99,7 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.background_location_settings_tip), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = colorScheme.onBackground.copy(alpha = 0.8f) ) @@ -140,14 +140,14 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.background_location_needs_bullets), style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.8f) ) Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(R.string.background_location_privacy_note), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium ), color = colorScheme.onBackground @@ -183,7 +183,7 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.grant_background_location), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -201,7 +201,7 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.check_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } @@ -213,7 +213,7 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.battery_optimization_skip), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } @@ -234,7 +234,7 @@ private fun HeaderSection(colorScheme: ColorScheme) { Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -244,7 +244,7 @@ private fun HeaderSection(colorScheme: ColorScheme) { Text( text = stringResource(R.string.background_location_required_subtitle), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } diff --git a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt index 3fe392d6..713fe7f8 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt @@ -1,13 +1,13 @@ package com.bitchat.android.onboarding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* import androidx.compose.animation.core.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -15,11 +15,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -105,7 +105,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -115,7 +115,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.battery_optimization_detected_title), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } @@ -225,7 +225,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.disable_battery_optimization), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ) ) @@ -243,7 +243,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.check_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } @@ -259,7 +259,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.battery_optimization_skip), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } @@ -284,7 +284,7 @@ private fun BatteryOptimizationCheckingContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -294,7 +294,7 @@ private fun BatteryOptimizationCheckingContent( Text( text = stringResource(R.string.battery_optimization_disabled_title), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } @@ -322,7 +322,7 @@ private fun BatteryOptimizationCheckingContent( Text( text = stringResource(R.string.battery_optimization_success_message), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.8f) ), textAlign = TextAlign.Center @@ -347,7 +347,7 @@ private fun BatteryOptimizationNotSupportedContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -357,7 +357,7 @@ private fun BatteryOptimizationNotSupportedContent( Text( text = stringResource(R.string.battery_optimization_not_required), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } @@ -372,7 +372,7 @@ private fun BatteryOptimizationNotSupportedContent( Text( text = stringResource(R.string.battery_optimization_not_supported_message), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.8f) ), textAlign = TextAlign.Center @@ -388,7 +388,7 @@ private fun BatteryOptimizationNotSupportedContent( Text( text = stringResource(R.string.continue_btn), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt index 60c6e5e4..3eb6eea9 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt @@ -1,20 +1,20 @@ package com.bitchat.android.onboarding -import androidx.compose.animation.core.* -import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -83,7 +83,7 @@ private fun BluetoothDisabledContent( Text( text = stringResource(R.string.bluetooth_recommended), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -114,7 +114,7 @@ private fun BluetoothDisabledContent( Text( text = stringResource(R.string.bluetooth_needs_bullets), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ) ) @@ -138,7 +138,7 @@ private fun BluetoothDisabledContent( Text( text = stringResource(R.string.enable_bluetooth), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -187,7 +187,7 @@ private fun BluetoothNotSupportedContent( Text( text = stringResource(R.string.bluetooth_not_supported), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.error ), @@ -204,7 +204,7 @@ private fun BluetoothNotSupportedContent( Text( text = stringResource(R.string.bluetooth_unsupported_explanation), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface ), modifier = Modifier.padding(16.dp), @@ -235,7 +235,7 @@ private fun BluetoothCheckingContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -247,7 +247,7 @@ private fun BluetoothCheckingContent( Text( text = stringResource(R.string.checking_bluetooth_status), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.7f) ) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt index bd231afd..f7895c65 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt @@ -8,11 +8,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -63,7 +63,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -92,7 +92,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.initializing_mesh_network), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.7f) ) ) @@ -102,7 +102,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.dot), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = alpha) ) ) @@ -127,7 +127,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.setting_up_bluetooth), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ), textAlign = TextAlign.Center @@ -136,7 +136,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.should_take_seconds), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.6f) ), textAlign = TextAlign.Center @@ -184,7 +184,7 @@ fun InitializationErrorScreen( Text( text = stringResource(R.string.setup_not_complete), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.error ), @@ -201,7 +201,7 @@ fun InitializationErrorScreen( Text( text = errorMessage, style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface ), modifier = Modifier.padding(16.dp), @@ -220,7 +220,7 @@ fun InitializationErrorScreen( Text( text = stringResource(R.string.try_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -234,7 +234,7 @@ fun InitializationErrorScreen( Text( text = stringResource(R.string.open_settings), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), modifier = Modifier.padding(vertical = 4.dp) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt index c5bb8c9b..3654aba8 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt @@ -1,21 +1,21 @@ package com.bitchat.android.onboarding -import androidx.compose.animation.core.* -import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -80,7 +80,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.location_services_required), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -122,7 +122,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.location_explanation), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ) ) @@ -142,7 +142,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.location_needs_bullets), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ) ) @@ -166,7 +166,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.open_location_settings), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -180,7 +180,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.check_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), modifier = Modifier.padding(vertical = 4.dp) ) @@ -209,7 +209,7 @@ private fun LocationNotAvailableContent( Text( text = stringResource(R.string.location_services_unavailable), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.error ), @@ -226,7 +226,7 @@ private fun LocationNotAvailableContent( Text( text = stringResource(R.string.location_unavailable_explanation), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface ), modifier = Modifier.padding(16.dp), @@ -247,7 +247,7 @@ private fun LocationCheckingContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -259,7 +259,7 @@ private fun LocationCheckingContent( Text( text = stringResource(R.string.checking_location_services), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.7f) ) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt index 48138d66..afd91da3 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -1,9 +1,5 @@ package com.bitchat.android.onboarding -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.LocationOn @@ -13,17 +9,21 @@ import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.filled.Settings +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -68,7 +68,7 @@ fun PermissionExplanationScreen( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -79,7 +79,7 @@ fun PermissionExplanationScreen( Text( text = stringResource(R.string.about_tagline), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } @@ -117,7 +117,7 @@ fun PermissionExplanationScreen( Text( text = stringResource(R.string.privacy_bullets), style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.8f) ) } @@ -164,7 +164,7 @@ fun PermissionExplanationScreen( Text( text = stringResource(R.string.grant_permissions), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt new file mode 100644 index 00000000..e36ee7be --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -0,0 +1,605 @@ +package com.bitchat.android.ui + +import androidx.annotation.DrawableRes +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.R +import com.bitchat.android.core.ui.icon.BitChatIcon +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette + +/** + * Building blocks for the redesigned About sheet. + * + * Kept in a separate file from [AboutSheet] because the sheet itself is mostly wiring for + * preferences, whereas these are pure presentation. + */ + +/** Horizontal inset shared by every About section, so cards and labels align to one grid. */ +internal val AboutHorizontalPadding = 20.dp + +/** Card corner radius for grouped rows. */ +internal val AboutCardShape = RoundedCornerShape(16.dp) + +/** Leading icon column in settings-style sheet rows. */ +internal val SheetRowLeadingSlot = 22.dp +internal val SheetRowLeadingGutter = 16.dp +internal val SheetRowHorizontal = 16.dp +internal val SheetRowVertical = 13.dp + +/** + * Exact height of a people-list row. + * + * Fixed rather than derived from content: these lists reorder themselves constantly, and a row + * whose height depends on its content makes the whole card change height every time the order + * changes. Equals the leading glyph plus [SheetRowVertical] above and below. + */ +internal val SheetRowHeight = SheetRowLeadingSlot + SheetRowVertical * 2 +internal val SheetRowDividerInset = SheetRowHorizontal + SheetRowLeadingSlot + SheetRowLeadingGutter +/** Selection indicator sized for [SheetRowLeadingSlot]. */ +internal val SheetRowSelectedDot = 12.dp + +/** + * Two top-level views of the sheet: what the app is and how to drive it, versus the knobs. + */ +enum class AboutTab { + Info, + Settings, +} + +/** + * Small uppercase section label, e.g. `SETTINGS`. + * + * Uppercasing happens here rather than in the string resource so translators supply natural + * sentence case and locales without a case distinction are unaffected. + */ +@Composable +internal fun AboutSectionLabel( + text: String, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + Text( + text = text.uppercase(), + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = palette.textTertiary, + modifier = modifier.padding(start = AboutHorizontalPadding, top = 24.dp, bottom = 8.dp) + ) +} + +/** + * Icon + title on one line, optional short subtitle beneath. Used by location / network sheets. + */ +@Composable +internal fun SheetIconSectionHeader( + @DrawableRes iconRes: Int, + title: String, + subtitle: String? = null, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + Text( + text = title, + fontSize = 17.sp, + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + color = colorScheme.primary + ) + } + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + fontSize = 12.sp, + lineHeight = 17.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onSurfaceVariant + ) + } + } +} + +/** Inset divider used inside grouped sheet cards (aligns with text column after the leading slot). */ +@Composable +internal fun SheetCardDivider() { + val colorScheme = MaterialTheme.colorScheme + HorizontalDivider( + modifier = Modifier.padding(start = SheetRowDividerInset), + thickness = 1.dp, + color = colorScheme.outlineVariant + ) +} + +/** + * Centered app identity block: logo, wordmark, tagline, version. + */ +@Composable +internal fun AboutHero( + versionName: String, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = BitChatIcon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(64.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.app_name), + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 40.sp, + // Monospace at display size leaves too much air between glyphs; pull it in slightly + // so the wordmark reads as a single unit. + letterSpacing = (-0.5).sp, + color = colorScheme.primary + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.about_tagline), + fontFamily = BitchatFontFamily, + fontSize = 16.sp, + color = colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = stringResource(R.string.version_prefix, versionName), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = palette.textTertiary + ) + } +} + +/** + * Two-up tab bar with a sliding underline indicator. + * + * The indicator animates its offset rather than cross-fading two static bars, which is what + * makes the switch feel physically connected to the tap. + */ +@Composable +internal fun AboutTabBar( + selected: AboutTab, + onSelect: (AboutTab) -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val density = LocalDensity.current + + var rowWidth by remember { mutableStateOf(0.dp) } + val tabWidth = rowWidth / 2 + val indicatorOffset by animateDpAsState( + targetValue = if (selected == AboutTab.Info) 0.dp else tabWidth, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "aboutTabIndicator" + ) + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .onSizeChanged { size -> + rowWidth = with(density) { size.width.toDp() } + } + ) { + Row(modifier = Modifier.fillMaxWidth()) { + AboutTabLabel( + text = stringResource(R.string.about_tab_info), + isSelected = selected == AboutTab.Info, + onClick = { onSelect(AboutTab.Info) }, + modifier = Modifier.weight(1f) + ) + AboutTabLabel( + text = stringResource(R.string.about_tab_settings), + isSelected = selected == AboutTab.Settings, + onClick = { onSelect(AboutTab.Settings) }, + modifier = Modifier.weight(1f) + ) + } + + Box(modifier = Modifier.fillMaxWidth()) { + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) + Box( + modifier = Modifier + // Lambda overload: the offset is animated every frame, and the non-lambda + // version would invalidate composition rather than just layout. + .offset { IntOffset(x = indicatorOffset.roundToPx(), y = 0) } + .width(tabWidth) + .height(2.dp) + .background(colorScheme.primary) + ) + } + } +} + +@Composable +private fun AboutTabLabel( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + + val color by animateColorAsState( + targetValue = if (isSelected) colorScheme.primary else colorScheme.onSurfaceVariant, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + label = "aboutTabLabelColor" + ) + + Box( + modifier = modifier + .height(44.dp) + .clickable(onClickLabel = text) { onClick() }, + contentAlignment = Alignment.Center + ) { + Text( + text = text.uppercase(), + fontFamily = BitchatFontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = color + ) + } +} + +/** One line of the "How To Use" list: an icon plus a single instruction. */ +@Composable +private fun AboutInstructionRow( + @DrawableRes iconRes: Int, + text: String +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 1.dp) + .size(22.dp) + ) + Text( + text = text, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + lineHeight = 20.sp, + color = colorScheme.onSurface + ) + } +} + +/** + * The "How To Use" tab: a short, scannable list of the gestures that are not self-evident. + */ +@Composable +internal fun AboutHowToUseSection(modifier: Modifier = Modifier) { + val colorScheme = MaterialTheme.colorScheme + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.about_how_to_use_heading), + fontFamily = BitchatFontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = colorScheme.primary, + modifier = Modifier.padding( + start = AboutHorizontalPadding, + top = 20.dp, + bottom = 8.dp + ) + ) + + AboutInstructionRow( + iconRes = R.drawable.ic_spec_person, + text = stringResource(R.string.about_howto_nickname) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_globe, + text = stringResource(R.string.about_howto_channels) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_people, + text = stringResource(R.string.about_howto_people) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_bookmark_outline, + text = stringResource(R.string.about_howto_bookmark) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_mention, + text = stringResource(R.string.about_howto_mention) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_command, + text = stringResource(R.string.about_howto_commands) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_waveform, + text = stringResource(R.string.about_howto_panic) + ) + } +} + +/** + * Capability list, laid out like [AboutHowToUseSection]: flat rows, no card surface or dividers. + */ +@Composable +internal fun AboutFeatureCard(modifier: Modifier = Modifier) { + val features = listOf( + Triple( + R.drawable.ic_spec_wifi_off, + R.string.about_offline_mesh_title, + R.string.about_offline_mesh_desc + ), + Triple( + R.drawable.ic_spec_lock, + R.string.about_e2e_title, + R.string.about_e2e_desc + ), + Triple( + R.drawable.ic_spec_globe, + R.string.about_online_geohash_title, + R.string.about_online_geohash_desc + ), + Triple( + R.drawable.ic_spec_eye_off, + R.string.about_no_tracking_title, + R.string.about_no_tracking_desc + ), + ) + + Column(modifier = modifier.fillMaxWidth()) { + features.forEach { (icon, titleRes, descRes) -> + AboutFeatureRow( + iconRes = icon, + title = stringResource(titleRes), + subtitle = stringResource(descRes) + ) + } + } +} + +@Composable +private fun AboutFeatureRow( + @DrawableRes iconRes: Int, + title: String, + subtitle: String +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 1.dp) + .size(22.dp) + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + lineHeight = 20.sp, + color = colorScheme.onSurface + ) + Text( + text = subtitle, + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + lineHeight = 17.sp, + color = colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Small uppercase pill, e.g. the `RECOMMENDED` badge beside the Tor routing toggle. + */ +@Composable +internal fun BitchatBadge( + text: String, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + Box( + modifier = modifier + .background(colorScheme.primary.copy(alpha = 0.15f), RoundedCornerShape(4.dp)) + .padding(horizontal = 5.dp, vertical = 2.dp) + ) { + Text( + text = text.uppercase(), + fontFamily = BitchatFontFamily, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + color = colorScheme.primary + ) + } +} + +// MARK: - Shared bottom-sheet primitives + +/** Horizontal inset for sheet content. Cards inset from here; labels align to the same edge. */ +internal val SheetHorizontalPadding = 16.dp + +/** + * Section divider label used across the sheets, e.g. `BOOKMARKED`, `NEARBY`, `ON LOCATION`. + */ +@Composable +internal fun SheetSectionLabel( + text: String, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + Text( + text = text.uppercase(), + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = palette.textTertiary, + modifier = modifier + .fillMaxWidth() + .padding( + start = SheetHorizontalPadding + 14.dp, + end = SheetHorizontalPadding, + top = 20.dp, + bottom = 6.dp + ) + ) +} + +/** + * Circular tinted badge holding a section's icon, used at the top of the sheets. + */ +@Composable +internal fun SheetHeaderBadge( + icon: ImageVector, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + Box( + modifier = modifier + .size(44.dp) + .background(colorScheme.surface, androidx.compose.foundation.shape.CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(26.dp) + ) + } +} + +/** + * Full-width destructive action, e.g. `REMOVE LOCATION ACCESS`. + * + * Tinted fill plus an outline rather than a solid red button: the action is legitimate but + * rarely wanted, and a solid red block would dominate the sheet. + */ +@Composable +internal fun SheetDestructiveButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isDestructive: Boolean = true +) { + val colorScheme = MaterialTheme.colorScheme + val accent = if (isDestructive) colorScheme.error else colorScheme.primary + + Surface( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .height(44.dp), + shape = RoundedCornerShape(10.dp), + color = accent.copy(alpha = 0.10f), + border = BorderStroke(1.dp, accent.copy(alpha = 0.30f)) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = text.uppercase(), + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = accent + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 7a64ff50..67065182 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -3,11 +3,19 @@ package com.bitchat.android.ui import android.content.Intent import android.widget.Toast import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Speed import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -26,43 +34,17 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -70,14 +52,15 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.hotspot.HotspotActivity import com.bitchat.android.net.ArtiTorManager @@ -85,51 +68,10 @@ import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager import com.bitchat.android.nostr.NostrProofOfWork import com.bitchat.android.nostr.PoWPreferenceManager +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.util.UniversalApkManager -/** - * Feature row for displaying app capabilities - */ -@Composable -private fun FeatureRow( - icon: ImageVector, - title: String, - subtitle: String -) { - val colorScheme = MaterialTheme.colorScheme - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.Top - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier - .padding(top = 2.dp) - .size(22.dp) - ) - Spacer(modifier = Modifier.width(14.dp)) - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface.copy(alpha = 0.6f), - lineHeight = 18.sp - ) - } - } -} - /** * Theme selection chip with Apple-like styling */ @@ -141,17 +83,25 @@ private fun ThemeChip( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - + + // Cross-fade the chip so switching theme does not read as two separate flashes (the chip + // recolouring plus the whole app recolouring underneath it). + val containerColor by animateColorAsState( + targetValue = if (selected) colorScheme.primary else colorScheme.surfaceVariant, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "themeChipContainer" + ) + val labelColor by animateColorAsState( + targetValue = if (selected) Color.White else colorScheme.onSurfaceVariant, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "themeChipLabel" + ) + Surface( modifier = modifier, onClick = onClick, shape = RoundedCornerShape(10.dp), - color = if (selected) { - if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - } else { - colorScheme.surfaceVariant.copy(alpha = 0.5f) - } + color = containerColor ) { Box( modifier = Modifier @@ -161,9 +111,10 @@ private fun ThemeChip( ) { Text( text = label, - style = MaterialTheme.typography.bodySmall, + fontFamily = BitchatFontFamily, + fontSize = 13.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - color = if (selected) Color.White else colorScheme.onSurface.copy(alpha = 0.8f) + color = labelColor ) } } @@ -184,23 +135,49 @@ private fun SettingsToggleRow( statusIndicator: (@Composable () -> Unit)? = null ) { val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - + val palette = LocalBitchatPalette.current + val interactionSource = remember { MutableInteractionSource() } + + // Colours cross-fade so a row becoming available (Tor finishing bootstrap) eases in rather + // than popping. + val iconTint by animateColorAsState( + targetValue = if (enabled) colorScheme.primary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowIcon" + ) + val titleColor by animateColorAsState( + targetValue = if (enabled) colorScheme.onSurface else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowTitle" + ) + val subtitleColor by animateColorAsState( + targetValue = if (enabled) colorScheme.onSurfaceVariant else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowSubtitle" + ) + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), + // The whole row toggles, not just the switch: a 14.dp-tall switch is a poor target + // when there is a full-width row sitting right next to it. + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled + ) { onCheckedChange(!checked) } + .padding(horizontal = 16.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = icon, contentDescription = null, - tint = if (enabled) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0.3f), + tint = iconTint, modifier = Modifier.size(22.dp) ) - - Spacer(modifier = Modifier.width(14.dp)) - + + Spacer(modifier = Modifier.width(16.dp)) + Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) @@ -211,29 +188,32 @@ private fun SettingsToggleRow( ) { Text( text = title, - style = MaterialTheme.typography.bodyMedium, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = if (enabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f) + color = titleColor ) statusIndicator?.invoke() } Text( text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f), - lineHeight = 16.sp + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = subtitleColor, + lineHeight = 17.sp ) } - + Spacer(modifier = Modifier.width(16.dp)) - + Switch( checked = checked, onCheckedChange = { if (enabled) onCheckedChange(it) }, enabled = enabled, + interactionSource = interactionSource, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, - checkedTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), + checkedTrackColor = colorScheme.primary, uncheckedThumbColor = Color.White, uncheckedTrackColor = colorScheme.surfaceVariant ) @@ -272,12 +252,14 @@ fun AboutSheet( } val topBarAlpha by animateFloatAsState( targetValue = if (isScrolled) 0.98f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), label = "topBarAlpha" ) val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - + val palette = LocalBitchatPalette.current + var selectedTab by remember { mutableStateOf(AboutTab.Info) } + if (isPresented) { BitchatBottomSheet( modifier = modifier, @@ -287,103 +269,49 @@ fun AboutSheet( LazyColumn( state = lazyListState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 80.dp, bottom = 32.dp), - verticalArrangement = Arrangement.spacedBy(20.dp) + contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) ) { // Header Section - App Identity - item(key = "header") { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = stringResource(R.string.app_name), - style = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = 28.sp, - letterSpacing = 1.sp - ), - color = colorScheme.onBackground - ) - Text( - text = stringResource(R.string.version_prefix, versionName ?: ""), - fontSize = 13.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onBackground.copy(alpha = 0.5f) - ) - Text( - text = stringResource(R.string.about_tagline), - fontSize = 13.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onBackground.copy(alpha = 0.6f), - modifier = Modifier.padding(top = 4.dp) - ) - } + item(key = "hero") { + AboutHero(versionName = versionName ?: "") } - // Features Section - Grouped Card - item(key = "features") { - Column(modifier = Modifier.padding(horizontal = 20.dp)) { - Text( - text = stringResource(R.string.about_appearance).uppercase(), - style = MaterialTheme.typography.labelSmall, - color = colorScheme.onBackground.copy(alpha = 0.5f), - letterSpacing = 0.5.sp, - modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) - ) - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) - ) { - Column { - FeatureRow( - icon = Icons.Filled.Bluetooth, - title = stringResource(R.string.about_offline_mesh_title), - subtitle = stringResource(R.string.about_offline_mesh_desc) - ) - HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - color = colorScheme.outline.copy(alpha = 0.12f) - ) - FeatureRow( - icon = Icons.Default.Public, - title = stringResource(R.string.about_online_geohash_title), - subtitle = stringResource(R.string.about_online_geohash_desc) - ) - HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - color = colorScheme.outline.copy(alpha = 0.12f) - ) - FeatureRow( - icon = Icons.Default.Lock, - title = stringResource(R.string.about_e2e_title), - subtitle = stringResource(R.string.about_e2e_desc) - ) - } + item(key = "tabs") { + AboutTabBar( + selected = selectedTab, + onSelect = { selectedTab = it }, + modifier = Modifier.padding(top = 24.dp) + ) + } + + if (selectedTab == AboutTab.Info) { + // What the app is, then how to drive it. Both are reference material a + // new user reads once, so they belong on the same tab. + item(key = "features") { + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_about)) + AboutFeatureCard() } } + + item(key = "how_to_use") { + AboutHowToUseSection() + } } + if (selectedTab == AboutTab.Settings) { // Appearance Section item(key = "appearance") { - Column(modifier = Modifier.padding(horizontal = 20.dp)) { - Text( - text = "THEME", - style = MaterialTheme.typography.labelSmall, - color = colorScheme.onBackground.copy(alpha = 0.5f), - letterSpacing = 0.5.sp, - modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) - ) + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_theme)) val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() Surface( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + shape = AboutCardShape ) { Row( modifier = Modifier @@ -425,18 +353,14 @@ fun AboutSheet( val torStatus by torProvider.statusFlow.collectAsState() val torAvailable = remember { torProvider.isTorAvailable() } - Column(modifier = Modifier.padding(horizontal = 20.dp)) { - Text( - text = "SETTINGS", - style = MaterialTheme.typography.labelSmall, - color = colorScheme.onBackground.copy(alpha = 0.5f), - letterSpacing = 0.5.sp, - modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) - ) + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_settings)) Surface( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + shape = AboutCardShape ) { Column { // Background Mode Toggle @@ -455,12 +379,13 @@ fun AboutSheet( } } ) - + HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - color = colorScheme.outline.copy(alpha = 0.12f) + modifier = Modifier.padding(start = 54.dp), + thickness = 1.dp, + color = colorScheme.outlineVariant ) - + // Proof of Work Toggle SettingsToggleRow( icon = Icons.Filled.Speed, @@ -469,16 +394,17 @@ fun AboutSheet( checked = powEnabled, onCheckedChange = { PoWPreferenceManager.setPowEnabled(it) } ) - + HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - color = colorScheme.outline.copy(alpha = 0.12f) + modifier = Modifier.padding(start = 54.dp), + thickness = 1.dp, + color = colorScheme.outlineVariant ) - + // Tor Toggle SettingsToggleRow( icon = Icons.Filled.Security, - title = "Tor Network", + title = stringResource(R.string.about_tor_title), subtitle = stringResource(R.string.about_tor_route), checked = torMode.value == TorMode.ON, onCheckedChange = { enabled -> @@ -491,9 +417,9 @@ fun AboutSheet( statusIndicator = if (torMode.value == TorMode.ON) { { val statusColor = when { - torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - torStatus.running -> Color(0xFFFF9500) - else -> Color(0xFFFF3B30) + torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary + torStatus.running -> palette.accentOrange + else -> colorScheme.error } Surface( color = statusColor, @@ -857,15 +783,18 @@ fun AboutSheet( } } - + // Tor unavailable hint if (!torAvailable) { Text( text = stringResource(R.string.tor_not_available_in_this_build), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onBackground.copy(alpha = 0.5f), - modifier = Modifier.padding(start = 16.dp, top = 8.dp) + fontFamily = BitchatFontFamily, + color = palette.textTertiary, + modifier = Modifier.padding( + start = AboutHorizontalPadding + 16.dp, + top = 8.dp + ) ) } } @@ -875,13 +804,15 @@ fun AboutSheet( item(key = "pow_slider") { val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() - + if (powEnabled) { - Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Column(modifier = Modifier.padding(top = 12.dp)) { Surface( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + shape = AboutCardShape ) { Column( modifier = Modifier.padding(16.dp), @@ -893,31 +824,39 @@ fun AboutSheet( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Difficulty", - style = MaterialTheme.typography.bodyMedium, + text = stringResource(R.string.about_difficulty), + fontFamily = BitchatFontFamily, + fontSize = 14.sp, fontWeight = FontWeight.Medium, color = colorScheme.onSurface ) - Text( - text = "$powDifficulty bits • ${NostrProofOfWork.estimateMiningTime(powDifficulty)}", - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) + AnimatedCountLabel( + count = powDifficulty, + text = stringResource( + R.string.about_difficulty_value, + powDifficulty, + NostrProofOfWork.estimateMiningTime(powDifficulty) + ), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = colorScheme.onSurfaceVariant ) } - + Slider( value = powDifficulty.toFloat(), onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, valueRange = 0f..32f, steps = 31, colors = SliderDefaults.colors( - thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), - activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + thumbColor = colorScheme.primary, + activeTrackColor = colorScheme.primary, + inactiveTrackColor = colorScheme.surfaceVariant ) ) - - Text( + + AnimatedCountLabel( + count = powDifficulty, text = when { powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none) powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low) @@ -928,8 +867,8 @@ fun AboutSheet( else -> stringResource(R.string.about_pow_desc_extreme) }, fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f) + fontFamily = BitchatFontFamily, + color = palette.textTertiary ) } } @@ -942,13 +881,15 @@ fun AboutSheet( val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) } val torProvider = remember { ArtiTorManager.getInstance() } val torStatus by torProvider.statusFlow.collectAsState() - + if (torMode.value == TorMode.ON) { - Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Column(modifier = Modifier.padding(top = 12.dp)) { Surface( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + shape = AboutCardShape ) { Column( modifier = Modifier.padding(16.dp), @@ -959,14 +900,19 @@ fun AboutSheet( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { val statusColor = when { - torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - torStatus.running -> Color(0xFFFF9500) - else -> Color(0xFFFF3B30) + torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary + torStatus.running -> palette.accentOrange + else -> colorScheme.error } Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {} Text( - text = if (torStatus.running) "Connected (${torStatus.bootstrapPercent}%)" else "Disconnected", - style = MaterialTheme.typography.bodyMedium, + text = if (torStatus.running) { + stringResource(R.string.about_tor_connected, torStatus.bootstrapPercent) + } else { + stringResource(R.string.about_tor_disconnected) + }, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, fontWeight = FontWeight.Medium, color = colorScheme.onSurface ) @@ -975,8 +921,8 @@ fun AboutSheet( Text( text = torStatus.lastLogLine.take(120), fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f), + fontFamily = BitchatFontFamily, + color = palette.textTertiary, maxLines = 2 ) } @@ -986,67 +932,36 @@ fun AboutSheet( } } - // Emergency Warning - item(key = "warning") { - Surface( - modifier = Modifier - .padding(horizontal = 20.dp) - .fillMaxWidth(), - color = colorScheme.error.copy(alpha = 0.1f), - shape = RoundedCornerShape(16.dp) - ) { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top - ) { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = null, - tint = colorScheme.error, - modifier = Modifier.size(20.dp) - ) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = stringResource(R.string.about_emergency_title), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = colorScheme.error - ) - Text( - text = stringResource(R.string.about_emergency_tip), - fontSize = 13.sp, - color = colorScheme.onSurface.copy(alpha = 0.7f) - ) - } - } - } - } + } // end Settings tab // Footer item(key = "footer") { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp), + .padding( + start = AboutHorizontalPadding, + end = AboutHorizontalPadding, + top = 24.dp + ), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { - if (onShowDebug != null) { + if (selectedTab == AboutTab.Settings && onShowDebug != null) { TextButton(onClick = onShowDebug) { Text( text = stringResource(R.string.about_debug_settings), fontSize = 13.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.primary ) } } Text( text = stringResource(R.string.about_footer), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.4f) + fontSize = 11.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary ) Spacer(modifier = Modifier.height(20.dp)) } @@ -1059,10 +974,11 @@ fun AboutSheet( .align(Alignment.TopCenter) .fillMaxWidth() .height(64.dp) - .background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha)) + .background(colorScheme.background.copy(alpha = topBarAlpha)) ) { + val dismiss = LocalSheetDismiss.current CloseButton( - onClick = onDismiss, + onClick = { dismiss?.invoke() ?: onDismiss() }, modifier = modifier .align(Alignment.CenterEnd) .padding(horizontal = 16.dp), @@ -1112,7 +1028,7 @@ fun PasswordPromptDialog( onValueChange = onPasswordChange, label = { Text(stringResource(R.string.pwd_label), style = MaterialTheme.typography.bodyMedium) }, textStyle = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = colorScheme.primary, diff --git a/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt b/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt new file mode 100644 index 00000000..ecf5eb69 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt @@ -0,0 +1,117 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import com.bitchat.android.ui.theme.BitchatMotion + +/** + * Counts that roll to their new value instead of snapping. + * + * Peer counts change on their own, without the user doing anything, so a hard digit swap is easy + * to miss and looks like a rendering fault when it is noticed. Sliding the digits in the + * direction the number moved — up when someone joins, down when someone leaves — conveys the + * change without needing a separate indicator. + */ +@Composable +fun AnimatedCount( + count: Int, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + prefix: String = "", +) { + AnimatedContent( + targetState = count, + transitionSpec = { + val goingUp = targetState > initialState + ( + slideInVertically( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + initialOffsetY = { height -> if (goingUp) height else -height } + ) + fadeIn(tween(BitchatMotion.STANDARD_MS)) + ).togetherWith( + slideOutVertically( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + targetOffsetY = { height -> if (goingUp) -height else height } + ) + fadeOut(tween(BitchatMotion.QUICK_MS)) + // Clip so the outgoing digit cannot bleed past the text bounds mid-transition. + ) using SizeTransform(clip = true) + }, + modifier = modifier, + label = "animatedCount" + ) { value -> + Text( + text = "$prefix$value", + style = style, + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + fontFamily = fontFamily, + maxLines = 1 + ) + } +} + +/** + * Cross-fades a label whose text embeds a count, e.g. `People (7)` or `3 people`. + * + * Used where the number is not isolated in its own composable and cannot be rolled on its own. + * The transition is keyed on [count] rather than on [text], so a label changing for some other + * reason — a locale switch, say — does not animate. + */ +@Composable +fun AnimatedCountLabel( + count: Int, + text: String, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, +) { + AnimatedContent( + targetState = count, + transitionSpec = { + fadeIn(tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)) + .togetherWith(fadeOut(tween(BitchatMotion.QUICK_MS))) + .using(SizeTransform(clip = false)) + }, + modifier = modifier, + label = "animatedCountLabel" + ) { state -> + // Captured per state, so the outgoing copy keeps rendering the label it entered with. + // Reading `text` directly would show the *new* label on both sides of the cross-fade, + // turning the transition into a flicker between two identical strings. + val stateText = remember(state) { text } + Text( + text = stateText, + style = style, + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + fontFamily = fontFamily, + maxLines = 1 + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt b/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt new file mode 100644 index 00000000..26c8259a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt @@ -0,0 +1,85 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LookaheadScope + +/** + * Motion for a row moving to a new position, or resizing in place. + * + * People lists reorder themselves constantly and without user input — someone sends a DM and jumps + * to the top, a peer drops off the mesh, a favourite comes online. Rows teleporting between + * positions makes the list feel unreliable and costs the reader their place in it. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +private val RowBoundsTransform = BoundsTransform { _, _ -> + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Rect.VisibilityThreshold + ) +} + +/** Entry fade for a row that was not previously in the list. */ +private val RowEnterSpec: FiniteAnimationSpec = + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 900f) + +/** + * A vertical list whose rows animate when they are added, removed, or reordered. + * + * Deliberately not a `LazyColumn`: both people lists live *inside* an outer `LazyColumn` item, where + * nesting another lazy list is not possible. [LookaheadScope] plus [Modifier.animateBounds] gives + * the same reorder-and-resize animation that `LazyItemScope.animateItem` provides, without the list + * needing to be lazy. These lists are bounded (and the geohash one is explicitly capped), so + * nothing is lost by composing every row. + * + * Rows are keyed so identity survives reordering. Without stable keys a row that moved would look + * like a different row appearing, and would fade instead of sliding. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun AnimatedRowColumn( + items: List, + key: (T) -> Any, + modifier: Modifier = Modifier, + row: @Composable (index: Int, item: T) -> Unit +) { + LookaheadScope { + Column(modifier = modifier) { + items.forEachIndexed { index, item -> + key(key(item)) { + // Fades the row in on the composition it first appears, then never again — + // reordering an existing row must slide, not blink. + val enter = remember { Animatable(0f) } + LaunchedEffect(Unit) { enter.animateTo(1f, RowEnterSpec) } + + Box( + modifier = Modifier + .animateBounds( + lookaheadScope = this@LookaheadScope, + boundsTransform = RowBoundsTransform + ) + .graphicsLayer { alpha = enter.value } + ) { + row(index, item) + } + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index e91e30be..af38ea1d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -1,67 +1,277 @@ package com.bitchat.android.ui - -import android.util.Log +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import com.bitchat.android.R import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.foundation.Canvas -import androidx.compose.ui.geometry.Offset import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily +import androidx.annotation.DrawableRes +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.foundation.layout.RowScope +import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.BitChatBrandButton +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.TorMode +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette /** * Header components for ChatScreen * Extracted from ChatScreen.kt for better organization */ +/** Height of the chat top bar. Taller than the old 42.dp so 44.dp tap targets fit properly. */ +val ChatHeaderHeight = 52.dp +/** + * The single visible glyph size used by every icon in the top bar. + * + * The Figma header pairs 16 px icons with compact labels. At our 17sp header scale, 19dp preserves + * that icon-to-cap-height relationship without affecting the surrounding 44dp touch targets. + */ +internal val HeaderIconSize = 19.dp +/** + * Text size for the top bar's labels: nickname, channel name, peer count. + * + * A step up from the 15.sp body scale. The bar is the app's primary status readout and was + * noticeably harder to read than the messages below it; the extra point costs nothing because + * the bar's height is driven by [HeaderTapTarget], not by the text. + */ +private val HeaderTextSize = 17.sp + +/** Minimum tap target for every interactive element in the header. */ +private val HeaderTapTarget = 44.dp + +/** Corner radius for the header's tappable label+icon clusters. */ +private val HeaderClusterShape = RoundedCornerShape(8.dp) + +/** + * Edge insets for the bar. + * + * Asymmetric because the leading glyph sits in a 44.dp tap target whose padding already supplies + * some optical inset, while the trailing action's does the same on the other side. + */ +internal val HeaderInsetStart = 12.dp +internal val HeaderInsetEnd = 8.dp + +/** + * A minimum-48x40 tap target wrapping a small icon. + * + * The old header used bare 16.dp icons with `Modifier.clickable`, which produced tap targets far + * below the accessibility minimum and made the channel/bookmark controls genuinely hard to hit. + */ +@Composable +private fun HeaderIconButton( + onClick: () -> Unit, + contentDescription: String?, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Box( + modifier = modifier + .size(HeaderTapTarget) + .clip(CircleShape) + .pressScaleClickable(onClick = onClick, onClickLabel = contentDescription), + contentAlignment = Alignment.Center + ) { + content() + } +} + +/** + * Tor health for location-channel header glyphs. + * + * Status colours are heavily muted (blended into [normal]) so they read as a soft signal + * rather than an alarm. Connecting / not-yet-running also drives a slow glow pulse. + */ +internal data class TorConnectionVisual( + val tint: Color, + /** True while Tor is enabled but not fully bootstrapped — drives a pulse. */ + val isProgress: Boolean, +) @Composable -fun TorStatusDot( - modifier: Modifier = Modifier +internal fun rememberTorConnectionVisual(normal: Color): TorConnectionVisual { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + val torStatus by remember { ArtiTorManager.getInstance() }.statusFlow.collectAsState() + + // ~28% of the loud accent mixed into the base tint keeps the hue without intensity. + val mutedConnecting = lerp(normal, palette.accentOrange, 0.28f) + val mutedFailed = lerp(normal, colorScheme.error, 0.30f) + + val target = when { + torStatus.mode == TorMode.OFF -> TorConnectionVisual(normal, isProgress = false) + torStatus.running && torStatus.bootstrapPercent >= 100 -> + TorConnectionVisual(normal, isProgress = false) + torStatus.running -> TorConnectionVisual(mutedConnecting, isProgress = true) + else -> TorConnectionVisual(mutedFailed, isProgress = true) + } + + val animatedTint by animateColorAsState( + targetValue = target.tint, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "torConnectionTint" + ) + return TorConnectionVisual(tint = animatedTint, isProgress = target.isProgress) +} + +/** + * Soft, slow brightness pulse used while Tor is connecting. Keeps scale fixed so layout + * does not shift; only opacity / a faint halo breathe. + */ +@Composable +internal fun TorAwareHeaderIcon( + imageVector: ImageVector, + tint: Color, + isProgress: Boolean, + contentDescription: String?, + modifier: Modifier = Modifier, ) { - val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() } - val torStatus by torProvider.statusFlow.collectAsState() - - if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) { - val dotColor = when { - torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) // Orange - bootstrapping - torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) // Green - connected - else -> Color.Red // Red - error/disconnected - } - Canvas( - modifier = modifier - ) { - val radius = size.minDimension / 2 - drawCircle( - color = dotColor, - radius = radius, - center = Offset(size.width / 2, size.height / 2) + val pulse = if (isProgress) { + val transition = rememberInfiniteTransition(label = "torGlow") + transition.animateFloat( + initialValue = 0.42f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "torGlowPulse" + ).value + } else { + 1f + } + + // Fixed layout footprint = icon size. Glow is drawn larger via requiredSize so it never + // pushes neighbouring text when the pulse starts/stops. + Box( + contentAlignment = Alignment.Center, + modifier = modifier.size(HeaderIconSize) + ) { + if (isProgress) { + val glowBrush = remember(tint) { + Brush.radialGradient( + colorStops = arrayOf( + 0.0f to tint.copy(alpha = 0.55f), + 0.45f to tint.copy(alpha = 0.22f), + 1.0f to Color.Transparent, + ) + ) + } + Box( + modifier = Modifier + .requiredSize(HeaderIconSize + 14.dp) + .graphicsLayer { alpha = pulse * 0.85f } + .background(glowBrush) ) } + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = Modifier + .size(HeaderIconSize) + .graphicsLayer { + alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f + }, + tint = tint + ) + } +} + +/** Painter-resource counterpart used by the extracted Figma SVG family. */ +@Composable +internal fun TorAwareHeaderIcon( + painter: Painter, + tint: Color, + isProgress: Boolean, + contentDescription: String?, + modifier: Modifier = Modifier, +) { + val pulse = if (isProgress) { + val transition = rememberInfiniteTransition(label = "torPainterGlow") + transition.animateFloat( + initialValue = 0.42f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "torPainterGlowPulse" + ).value + } else { + 1f + } + + Box( + contentAlignment = Alignment.Center, + modifier = modifier.size(HeaderIconSize) + ) { + if (isProgress) { + val glowBrush = remember(tint) { + Brush.radialGradient( + colorStops = arrayOf( + 0.0f to tint.copy(alpha = 0.55f), + 0.45f to tint.copy(alpha = 0.22f), + 1.0f to Color.Transparent, + ) + ) + } + Box( + modifier = Modifier + .requiredSize(HeaderIconSize + 14.dp) + .graphicsLayer { alpha = pulse * 0.85f } + .background(glowBrush) + ) + } + Icon( + painter = painter, + contentDescription = contentDescription, + modifier = Modifier + .size(HeaderIconSize) + .graphicsLayer { + alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f + }, + tint = tint + ) } } @@ -70,39 +280,156 @@ fun NoiseSessionIcon( sessionState: String?, modifier: Modifier = Modifier ) { - val (icon, color, contentDescription) = when (sessionState) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + // The pre-redesign colours for the first two states were `0x87878700`, i.e. alpha 0x87 with + // an all-but-transparent RGB - the icons were effectively invisible. They now use the + // palette's secondary text colour. + val (iconRes, color, contentDescription) = when (sessionState) { "uninitialized" -> Triple( - Icons.Outlined.NoEncryption, - Color(0x87878700), // Grey - ready to establish + R.drawable.ic_spec_lock_open, + colorScheme.onSurfaceVariant, stringResource(R.string.cd_ready_for_handshake) ) "handshaking" -> Triple( - Icons.Outlined.Sync, - Color(0x87878700), // Grey - in progress + R.drawable.ic_spec_sync, + colorScheme.onSurfaceVariant, stringResource(R.string.cd_handshake_in_progress) ) "established" -> Triple( - Icons.Filled.Lock, - Color(0xFFFF9500), // Orange - secure + R.drawable.ic_spec_lock, + colorScheme.primary, stringResource(R.string.cd_encrypted) ) else -> { // "failed" or any other state Triple( - Icons.Outlined.Warning, - Color(0xFFFF4444), // Red - error + R.drawable.ic_spec_warning, + colorScheme.error, stringResource(R.string.cd_handshake_failed) ) } } - + Icon( - imageVector = icon, + painter = painterResource(iconRes), contentDescription = contentDescription, modifier = modifier, tint = color ) } +/** + * Reachability glyph for a conversation, drawn from the same spec set the main header uses. + * + * Mirrors the main header's channel button: a globe for anything reached over the internet, the + * range mark for the local mesh, and the more specific transport glyph when we know it. + */ +@DrawableRes +internal fun conversationTransportIcon( + isReachedOverInternet: Boolean, + isWifiAware: Boolean, + isDirect: Boolean +): Int = when { + isReachedOverInternet -> R.drawable.ic_spec_globe + isWifiAware -> R.drawable.ic_spec_wifi + isDirect -> R.drawable.ic_spec_bluetooth + else -> R.drawable.ic_spec_routed +} + +/** + * The shared chrome for a conversation header — private chats and channels alike. + * + * Deliberately built from the same tokens as [MainHeader] rather than from `TopAppBar`: identical + * height, identical 12/8.dp edge insets, the leading glyph in a [HeaderTapTarget]-sized slot so it + * lands exactly where the brand mark does, the same -6.dp optical nudge pulling the title toward + * that glyph, and the same [HeaderTextSize]. Anything less and the header visibly shifts as you + * move between the main timeline and a conversation. + * + * Actions are right-aligned and unweighted, so a long title yields space to them rather than + * pushing them off screen. + */ +@Composable +fun ConversationHeader( + @DrawableRes leadingIconRes: Int, + leadingIconTint: Color, + title: String, + modifier: Modifier = Modifier, + onTitleClick: (() -> Unit)? = null, + leadingContentDescription: String? = null, + actions: @Composable RowScope.() -> Unit = {} +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = modifier + .fillMaxWidth() + .height(ChatHeaderHeight) + .padding(start = HeaderInsetStart, end = HeaderInsetEnd), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(HeaderTapTarget), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(leadingIconRes), + contentDescription = leadingContentDescription, + modifier = Modifier.size(HeaderIconSize), + tint = leadingIconTint + ) + } + + // Same optical correction as the main header: the 44.dp tap target leaves more gap + // than the design wants between glyph and label. + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, + fontWeight = FontWeight.Medium, + color = colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .offset(x = (-6).dp) + .then( + if (onTitleClick != null) { + Modifier + .clip(HeaderClusterShape) + .pressScaleClickable(onClick = onTitleClick) + .padding(horizontal = 6.dp, vertical = 4.dp) + } else { + Modifier + } + ) + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + content = actions + ) + } +} + +/** An action slot in a [ConversationHeader], matching the main header's icon buttons. */ +@Composable +fun ConversationHeaderAction( + onClick: () -> Unit, + contentDescription: String?, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) = HeaderIconButton( + onClick = onClick, + contentDescription = contentDescription, + modifier = modifier, + content = content +) + @Composable fun NicknameEditor( value: String, @@ -125,15 +452,17 @@ fun NicknameEditor( Text( text = stringResource(R.string.at_symbol), style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary.copy(alpha = 0.8f) + fontSize = HeaderTextSize, + color = colorScheme.primary.copy(alpha = 0.7f) ) - + BasicTextField( value = value, onValueChange = onValueChange, textStyle = MaterialTheme.typography.bodyMedium.copy( color = colorScheme.primary, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily, + fontSize = HeaderTextSize ), cursorBrush = SolidColor(colorScheme.primary), singleLine = true, @@ -144,7 +473,7 @@ fun NicknameEditor( } ), modifier = Modifier - .widthIn(max = 120.dp) + .widthIn(max = 150.dp) .horizontalScroll(scrollState) ) } @@ -161,54 +490,68 @@ fun PeerCounter( onClick: () -> Unit, modifier: Modifier = Modifier ) { + val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme - + // Compute channel-aware people count and color (matches iOS logic exactly) val (peopleCount, countColor) = when (selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> { // Geohash channel: show geohash participants val count = geohashPeople.size - val green = Color(0xFF00C851) // Standard green - Pair(count, if (count > 0) green else Color.Gray) + Pair(count, if (count > 0) colorScheme.primary else palette.textTertiary) } is com.bitchat.android.geohash.ChannelID.Mesh, null -> { // Mesh channel: show Bluetooth-connected peers (excluding self) val count = connectedPeers.size - val meshBlue = Color(0xFF007AFF) // iOS-style blue for mesh - Pair(count, if (isConnected && count > 0) meshBlue else Color.Gray) + Pair(count, if (isConnected && count > 0) colorScheme.secondary else palette.textTertiary) } } - + + // Peers come and go constantly; fading the tint avoids a flicker every time the count + // crosses zero. + val animatedCountColor by animateColorAsState( + targetValue = countColor, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "peerCountColor" + ) + Row( verticalAlignment = Alignment.CenterVertically, - modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier + .clip(HeaderClusterShape) + .pressScaleClickable(onClick = onClick) + .height(HeaderTapTarget) + .padding(horizontal = 6.dp) ) { Icon( - imageVector = Icons.Default.Group, + // The extracted people glyph stays legible at the compact header scale; the number + // beside it carries the precise count. + painter = painterResource(R.drawable.ic_spec_people), contentDescription = when (selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants) else -> stringResource(R.string.cd_connected_peers) }, - modifier = Modifier.size(16.dp), - tint = countColor + modifier = Modifier.size(HeaderIconSize), + tint = animatedCountColor ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "$peopleCount", + AnimatedCount( + count = peopleCount, style = MaterialTheme.typography.bodyMedium, - color = countColor, - fontSize = 16.sp, + fontSize = HeaderTextSize, + color = animatedCountColor, fontWeight = FontWeight.Medium ) - + if (joinedChannels.isNotEmpty()) { - Text( - text = stringResource(R.string.channel_count_prefix) + "${joinedChannels.size}", + AnimatedCount( + count = joinedChannels.size, + prefix = stringResource(R.string.channel_count_prefix), style = MaterialTheme.typography.bodyMedium, - color = if (isConnected) Color(0xFF00C851) else Color.Red, - fontSize = 16.sp, + fontSize = HeaderTextSize, + color = if (isConnected) colorScheme.primary else colorScheme.error, fontWeight = FontWeight.Medium ) } @@ -266,57 +609,26 @@ private fun ChannelHeader( onSidebarClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - - Box(modifier = Modifier.fillMaxWidth()) { - // Back button - positioned all the way to the left with minimal margin - Button( + + // No back affordance: the close action on the right is the way out, exactly as in a private + // chat. Leaving the channel outright lives on its row in the network sheet, so it does not + // need a second, easily-mistaken home next to the exit. + ConversationHeader( + leadingIconRes = R.drawable.ic_spec_chat_bubbles, + leadingIconTint = colorScheme.primary, + leadingContentDescription = null, + title = "#$channel", + onTitleClick = onSidebarClick + ) { + ConversationHeaderAction( onClick = onBackClick, - colors = ButtonDefaults.buttonColors( - containerColor = Color.Transparent, - contentColor = colorScheme.primary - ), - contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding - modifier = Modifier - .align(Alignment.CenterStart) - .offset(x = (-8).dp) // Move even further left to minimize margin + contentDescription = stringResource(R.string.close_plain) ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), - modifier = Modifier.size(16.dp), - tint = colorScheme.primary - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(R.string.chat_back), - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary - ) - } - } - - // Title - perfectly centered regardless of other elements - Text( - text = stringResource(R.string.chat_channel_prefix, channel), - style = MaterialTheme.typography.titleMedium, - color = Color(0xFFFF9500), // Orange to match input field - modifier = Modifier - .align(Alignment.Center) - .clickable { onSidebarClick() } - ) - - // Leave button - positioned on the right - TextButton( - onClick = onLeaveChannel, - modifier = Modifier.align(Alignment.CenterEnd) - ) { - Text( - text = stringResource(R.string.chat_leave), - style = MaterialTheme.typography.bodySmall, - color = Color.Red + Icon( + painter = painterResource(R.drawable.ic_spec_close), + contentDescription = stringResource(R.string.close_plain), + modifier = Modifier.size(HeaderIconSize), + tint = colorScheme.onSurfaceVariant ) } } @@ -334,6 +646,7 @@ private fun MainHeader( viewModel: ChatViewModel ) { val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle() val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle() @@ -342,109 +655,98 @@ private fun MainHeader( val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() - // Bookmarks store for current geohash toggle (iOS parity) - val context = androidx.compose.ui.platform.LocalContext.current - val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) } - val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle() - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .height(ChatHeaderHeight) + .padding(start = HeaderInsetStart, end = HeaderInsetEnd), verticalAlignment = Alignment.CenterVertically ) { + // MARK: - Identity cluster. + // + // Weighted so it yields space to the status cluster rather than pushing it off screen. + // Compose measures unweighted children first, so the icons on the right always get the + // width they need and a long nickname simply scrolls within what is left. Row( - modifier = Modifier.fillMaxHeight(), + modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { BitChatBrandButton( onClick = onTitleClick, onTripleClick = onTripleTitleClick, contentDescription = stringResource(R.string.cd_open_about), + modifier = Modifier.size(HeaderTapTarget), ) - Text( - text = "/", - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary, - ) + // Nudge toward the brand glyph: the 44.dp tap target leaves more optical gap than + // spacing between the mark and the path label. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.offset(x = (-6).dp) + ) { + Text( + text = "/", + style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, + // Dimmed: the slash is a separator, not content. At full brightness it competed + // with the nickname beside it. + color = colorScheme.primary.copy(alpha = 0.45f), + modifier = Modifier.padding(end = 2.dp) + ) - Spacer(modifier = Modifier.width(2.dp)) - - NicknameEditor( - value = nickname, - onValueChange = onNicknameChange - ) - } - - // Right section with location channels button and peer counter - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp) - ) { - - // Unread private messages badge (click to open most recent DM) - if (hasUnreadPrivateMessages.isNotEmpty()) { - // Render icon directly to avoid symbol resolution issues - Icon( - imageVector = Icons.Filled.Email, - contentDescription = stringResource(R.string.cd_unread_private_messages), - modifier = Modifier - .size(16.dp) - .clickable { viewModel.openLatestUnreadPrivateChat() }, - tint = Color(0xFFFF9500) + NicknameEditor( + value = nickname, + onValueChange = onNicknameChange ) } + } + + // MARK: - Status cluster. + // + // Order, left to right: unread DMs, notes, channel, people. + // Tor health is read from the location channel / notes icon colour rather than a + // dedicated status dot. + Row( + verticalAlignment = Alignment.CenterVertically, + // Tight, because every child below is its own >=44.dp tap target. + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + // Unread private messages badge (click to open most recent DM) + if (hasUnreadPrivateMessages.isNotEmpty()) { + HeaderIconButton( + onClick = { viewModel.openLatestUnreadPrivateChat() }, + contentDescription = stringResource(R.string.cd_unread_private_messages) + ) { + Icon( + painter = painterResource(R.drawable.ic_spec_envelope), + contentDescription = stringResource(R.string.cd_unread_private_messages), + modifier = Modifier.size(HeaderIconSize), + tint = palette.accentOrange + ) + } + } + + // Location notes + channel badge: one tight unit so the document glyph and the + // bluetooth/globe glyph sit at the same visual pitch as other header pairings. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(0.dp) + ) { + LocationNotesButton( + viewModel = viewModel, + onClick = onLocationNotesClick + ) + + // Bookmarking lives in the Location Channels sheet, one tap away via the channel + // button. Duplicating it here bought a shortcut for a rare action at the cost + // of a slot in the app's most crowded row. - // Location channels button (matching iOS implementation) and bookmark grouped tightly - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 4.dp)) { LocationChannelsButton( viewModel = viewModel, onClick = onLocationChannelsClick ) - - // Bookmark toggle for current geohash (not shown for mesh) - val currentGeohash: String? = when (val sc = selectedLocationChannel) { - is com.bitchat.android.geohash.ChannelID.Location -> sc.channel.geohash - else -> null - } - if (currentGeohash != null) { - val isBookmarked = bookmarks.contains(currentGeohash) - Box( - modifier = Modifier - .padding(start = 2.dp) // minimal gap between geohash and bookmark - .size(20.dp) - .clickable { bookmarksStore.toggle(currentGeohash) }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, - contentDescription = stringResource(R.string.cd_toggle_bookmark), - tint = if (isBookmarked) Color(0xFF00C851) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f), - modifier = Modifier.size(16.dp) - ) - } - } } - // Location Notes button (extracted to separate component) - LocationNotesButton( - viewModel = viewModel, - onClick = onLocationNotesClick - ) - - // Tor status dot when Tor is enabled - TorStatusDot( - modifier = Modifier - .size(8.dp) - .padding(start = 0.dp, end = 2.dp) - ) - - // PoW status indicator - PoWStatusIndicator( - modifier = Modifier, - style = PoWIndicatorStyle.COMPACT - ) - Spacer(modifier = Modifier.width(2.dp)) PeerCounter( connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, joinedChannels = joinedChannels, @@ -458,56 +760,72 @@ private fun MainHeader( } } +/** + * Current channel indicator: a globe for geohash channels, a mesh glyph for the local mesh. + * + * The design brief asked for the "addition of globe icon to represent channels". Previously this + * was a text-only badge wrapped in an M3 [Button], which imposed a hidden 58.dp minimum width + * and 40.dp minimum height that fought the header's explicit sizing. + */ @Composable private fun LocationChannelsButton( viewModel: ChatViewModel, onClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - + // Get current channel selection from location manager val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() - val teleported by viewModel.isTeleported.collectAsStateWithLifecycle() - - val (badgeText, badgeColor) = when (selectedChannel) { - is com.bitchat.android.geohash.ChannelID.Mesh -> { - "#mesh" to Color(0xFF007AFF) // iOS blue for mesh - } - is com.bitchat.android.geohash.ChannelID.Location -> { - val geohash = (selectedChannel as com.bitchat.android.geohash.ChannelID.Location).channel.geohash - "#$geohash" to Color(0xFF00C851) // Green for location - } - null -> "#mesh" to Color(0xFF007AFF) // Default to mesh + + val isLocation = selectedChannel is com.bitchat.android.geohash.ChannelID.Location + val badgeText = when (val channel = selectedChannel) { + // Geohashes keep the '#' because that is how they are written and typed everywhere else. + is com.bitchat.android.geohash.ChannelID.Location -> "#${channel.channel.geohash}" + // The local mesh is not a hashtag channel, and the mesh glyph already says what it is, + // so it is plain "mesh". + else -> stringResource(R.string.mesh_label) } - - Button( - onClick = onClick, - colors = ButtonDefaults.buttonColors( - containerColor = Color.Transparent, - contentColor = badgeColor - ), - contentPadding = PaddingValues(start = 4.dp, end = 0.dp, top = 2.dp, bottom = 2.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = badgeText, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace - ), - color = badgeColor, - maxLines = 1 + val channelColor = if (isLocation) colorScheme.primary else colorScheme.secondary + // Tor status only tints the globe (location channels). The local mesh stays blue. + val torVisual = if (isLocation) { + rememberTorConnectionVisual(normal = channelColor) + } else { + TorConnectionVisual(tint = channelColor, isProgress = false) + } + val badgeIconRes = if (isLocation) { + R.drawable.ic_spec_globe + } else { + R.drawable.ic_spec_range + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .clip(HeaderClusterShape) + .pressScaleClickable( + onClick = onClick, + onClickLabel = stringResource(R.string.location_channels_title) ) - - // Teleportation indicator (like iOS) - if (teleported) { - Spacer(modifier = Modifier.width(2.dp)) - Icon( - imageVector = Icons.Default.PinDrop, - contentDescription = stringResource(R.string.cd_teleported), - modifier = Modifier.size(12.dp), - tint = badgeColor - ) - } - } + .height(HeaderTapTarget) + // No start padding: the notes icon is paired directly to the left; keep end + // padding so the gap to PeerCounter matches other cluster separations. + .padding(start = 0.dp, end = 6.dp) + ) { + TorAwareHeaderIcon( + painter = painterResource(badgeIconRes), + tint = torVisual.tint, + isProgress = torVisual.isProgress, + contentDescription = stringResource(R.string.cd_tor_status) + ) + + Text( + text = badgeText, + style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, + fontWeight = FontWeight.Medium, + color = channelColor, + maxLines = 1 + ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 0514217d..e9c8e343 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -1,33 +1,36 @@ package com.bitchat.android.ui + +import com.bitchat.android.ui.theme.BitchatFontFamily // [Goose] Bridge file share events to ViewModel via dispatcher is installed in ChatScreen composition // [Goose] Installing FileShareDispatcher handler in ChatScreen to forward file sends to ViewModel +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.animation.* +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalContext -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.IconButton +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.zIndex import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle @@ -42,6 +45,7 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.nostr.LocationNotesManager import com.bitchat.android.nostr.NearbyNotesController import com.bitchat.android.ui.media.FullScreenImageViewer +import com.bitchat.android.ui.theme.BitchatMotion /** * Main ChatScreen - REFACTORED to use component-based architecture @@ -58,6 +62,8 @@ fun ChatScreen(viewModel: ChatViewModel) { val colorScheme = MaterialTheme.colorScheme val messages by viewModel.messages.collectAsStateWithLifecycle() val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() val nickname by viewModel.nickname.collectAsStateWithLifecycle() val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle() @@ -179,6 +185,47 @@ fun ChatScreen(viewModel: ChatViewModel) { } } + // Identity of the timeline on screen, derived exactly like displayMessages above. Drives the + // per-conversation scroll position and animation state in MessagesList. + val conversationKey = when { + currentChannel != null -> "channel:$currentChannel" + else -> { + val locationChannel = selectedLocationChannel + if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) { + "geo:${locationChannel.channel.geohash}" + } else { + "mesh" + } + } + } + + val mentionPeerIdentities = remember( + displayMessages, + currentChannel, + selectedLocationChannel, + connectedPeers, + peerNicknames, + geohashPeople, + ) { + val knownPeers = if ( + currentChannel == null && selectedLocationChannel is ChannelID.Location + ) { + val duplicateNames = duplicateGeohashBaseNames(geohashPeople) + geohashPeople.mapNotNull { person -> + if (isUnannouncedNickname(person.displayName)) return@mapNotNull null + val displayName = disambiguatedGeohashDisplayName(person, duplicateNames) + displayName to PeerIdentity.nostr(person.id) + } + } else { + connectedPeers.mapNotNull { peerID -> + peerNicknames[peerID]?.let { displayName -> + displayName to PeerIdentity.mesh(peerID) + } + } + } + buildMentionPeerIdentityMap(displayMessages, knownPeers) + } + // Determine whether to show media buttons (only hide in geohash location chats) val showMediaButtons = when { currentChannel != null -> true @@ -191,8 +238,15 @@ fun ChatScreen(viewModel: ChatViewModel) { .fillMaxSize() .background(colorScheme.background) // Extend background to fill entire screen including status bar ) { - val headerHeight = 42.dp - + val headerHeight = ChatHeaderHeight + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + + // Both bars are translucent and the conversation scrolls underneath them, so their + // heights are reserved as list padding instead of as layout space. The composer's height + // varies (suggestion rows, wrapped lines), so it is measured rather than assumed. + var composerHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + // Main content area that responds to keyboard/window insets Column( modifier = Modifier @@ -200,100 +254,92 @@ fun ChatScreen(viewModel: ChatViewModel) { .windowInsetsPadding(WindowInsets.ime) // This handles keyboard insets .windowInsetsPadding(WindowInsets.navigationBars) // Add bottom padding when keyboard is not expanded ) { - // Header spacer - creates exact space for the floating header (status bar + compact header) - Spacer( - modifier = Modifier - .windowInsetsPadding(WindowInsets.statusBars) - .height(headerHeight) + Box(modifier = Modifier.weight(1f)) { + // Messages area - takes up available space, will compress when keyboard appears + // Nearby-notes strip and the reveal hint both live in this Box alongside the + // list, rather than in a Column above it, because the conversation has to scroll + // underneath the translucent bars. Their heights are reserved as list padding. + var notesStripHeight by remember { mutableStateOf(0.dp) } + val showNotesStrip = + isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty() + + MessagesList( + messages = displayMessages, + currentUserNickname = nickname, + meshService = viewModel.meshServiceFacade, + mentionPeerIdentities = mentionPeerIdentities, + modifier = Modifier.fillMaxSize(), + conversationKey = conversationKey, + contentPadding = PaddingValues( + top = statusBarHeight + headerHeight + + (if (showNotesStrip) notesStripHeight else 0.dp), + bottom = composerHeight + ), + forceScrollToBottom = forceScrollToBottom, + onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, + onNicknameClick = { fullSenderName -> + // Single click - mention user in text input + val currentText = messageText.text + + // Extract base nickname and hash suffix from full sender name + val (baseName, hashSuffix) = splitSuffix(fullSenderName) + + // Check if we're in a geohash channel to include hash suffix + val selectedLocationChannel = viewModel.selectedLocationChannel.value + val mentionText = if ( + selectedLocationChannel is ChannelID.Location && + hashSuffix.isNotEmpty() + ) { + // In geohash chat - include the hash suffix from the full display name + "@$baseName$hashSuffix" + } else { + // Regular chat - just the base nickname + "@$baseName" + } + + val newText = when { + currentText.isEmpty() -> "$mentionText " + currentText.endsWith(" ") -> "$currentText$mentionText " + else -> "$currentText $mentionText " + } + + messageText = TextFieldValue( + text = newText, + selection = TextRange(newText.length) + ) + }, + onMessageLongPress = { message -> + // Message long press - open user action sheet with message context + // Extract base nickname from message sender (contains all necessary info) + val (baseName, _) = splitSuffix(message.sender) + selectedUserForSheet = baseName + selectedMessageForSheet = message + showUserSheet = true + }, + onCancelTransfer = { msg -> + viewModel.cancelMediaSend(msg.id) + }, + onImageClick = { currentPath, allImagePaths, initialIndex -> + viewerImagePaths = allImagePaths + initialViewerIndex = initialIndex + showFullScreenImageViewer = true + } ) - // Messages area - takes up available space, will compress when keyboard appears - Column(modifier = Modifier.weight(1f)) { - if (isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty()) { - NearbyNotesStrip( - noteCount = nearbyNotes.size, - onClick = { showLocationNotesSheet = true }, - ) - } - - Box( + if (showNotesStrip) { + NearbyNotesStrip( + noteCount = nearbyNotes.size, + onClick = { showLocationNotesSheet = true }, modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) { - MessagesList( - messages = displayMessages, - currentUserNickname = nickname, - meshService = viewModel.meshServiceFacade, - modifier = Modifier.fillMaxSize(), - forceScrollToBottom = forceScrollToBottom, - onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, - onNicknameClick = { fullSenderName -> - // Single click - mention user in text input - val currentText = messageText.text - - // Extract base nickname and hash suffix from full sender name - val (baseName, hashSuffix) = splitSuffix(fullSenderName) - - // Check if we're in a geohash channel to include hash suffix - val selectedLocationChannel = viewModel.selectedLocationChannel.value - val mentionText = if ( - selectedLocationChannel is ChannelID.Location && - hashSuffix.isNotEmpty() - ) { - // In geohash chat - include the hash suffix from the full display name - "@$baseName$hashSuffix" - } else { - // Regular chat - just the base nickname - "@$baseName" - } - - val newText = when { - currentText.isEmpty() -> "$mentionText " - currentText.endsWith(" ") -> "$currentText$mentionText " - else -> "$currentText $mentionText " - } - - messageText = TextFieldValue( - text = newText, - selection = TextRange(newText.length), - ) + .align(Alignment.TopCenter) + .padding(top = statusBarHeight + headerHeight) + .onSizeChanged { size -> + notesStripHeight = with(density) { size.height.toDp() } }, - onMessageLongPress = { message -> - // Message long press - open user action sheet with message context - // Extract base nickname from message sender (contains all necessary info) - val (baseName, _) = splitSuffix(message.sender) - selectedUserForSheet = baseName - selectedMessageForSheet = message - showUserSheet = true - }, - onCancelTransfer = { msg -> - viewModel.cancelMediaSend(msg.id) - }, - onImageClick = { currentPath, allImagePaths, initialIndex -> - viewerImagePaths = allImagePaths - initialViewerIndex = initialIndex - showFullScreenImageViewer = true - }, - ) - - if ( - displayMessages.isEmpty() && - isMeshTimeline && - !nearbyNotesRevealed && - locationEnabled && - locationPermissionState == - LocationChannelManager.PermissionState.AUTHORIZED && - buildingGeohash != null - ) { - NearbyNotesRevealHint( - onClick = nearbyNotesController::reveal, - modifier = Modifier.align(Alignment.Center), - ) - } - } + ) } - // Input area - stays at bottom + + // Input area - overlays the bottom of the conversation // Bridge file share from lower-level input to ViewModel androidx.compose.runtime.LaunchedEffect(Unit) { com.bitchat.android.ui.events.FileShareDispatcher.setHandler { peer, channel, path -> @@ -302,6 +348,11 @@ fun ChatScreen(viewModel: ChatViewModel) { } ChatInputSection( + modifier = Modifier + .align(Alignment.BottomCenter) + .onSizeChanged { size -> + composerHeight = with(density) { size.height.toDp() } + }, messageText = messageText, onMessageTextChange = { newText: TextFieldValue -> messageText = newText @@ -329,6 +380,7 @@ fun ChatScreen(viewModel: ChatViewModel) { commandSuggestions = commandSuggestions, showMentionSuggestions = showMentionSuggestions, mentionSuggestions = mentionSuggestions, + mentionPeerIdentities = mentionPeerIdentities, onCommandSuggestionClick = { suggestion: CommandSuggestion -> val commandText = viewModel.selectCommandSuggestion(suggestion) messageText = TextFieldValue( @@ -349,11 +401,11 @@ fun ChatScreen(viewModel: ChatViewModel) { colorScheme = colorScheme, showMediaButtons = showMediaButtons ) + } } // Floating header - positioned absolutely at top, ignores keyboard ChatFloatingHeader( - headerHeight = headerHeight, selectedPrivatePeer = null, currentChannel = currentChannel, nickname = nickname, @@ -369,40 +421,39 @@ fun ChatScreen(viewModel: ChatViewModel) { } ) - // Divider under header - positioned after status bar + header height - HorizontalDivider( - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.statusBars) - .offset(y = headerHeight) - .zIndex(1f), - color = colorScheme.outline.copy(alpha = 0.3f) - ) - // Scroll-to-bottom floating button AnimatedVisibility( visible = isScrolledUp, - enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), + // Short and eased: the button appears mid-scroll, so a slow entrance draws the eye + // away from the messages the user is actually reading. + enter = slideInVertically( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + initialOffsetY = { it / 2 } + ) + fadeIn(tween(BitchatMotion.STANDARD_MS)), + exit = slideOutVertically( + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + targetOffsetY = { it / 2 } + ) + fadeOut(tween(BitchatMotion.QUICK_MS)), modifier = Modifier .align(Alignment.BottomEnd) - .padding(end = 16.dp, bottom = 64.dp) + .padding(end = 16.dp, bottom = composerHeight + 8.dp) .zIndex(1.5f) .windowInsetsPadding(WindowInsets.navigationBars) .windowInsetsPadding(WindowInsets.ime) ) { Surface( shape = CircleShape, - color = colorScheme.background, + color = colorScheme.surface, tonalElevation = 3.dp, shadowElevation = 6.dp, - border = BorderStroke(2.dp, Color(0xFF00C851)) + border = BorderStroke(1.dp, colorScheme.primary) ) { IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) { Icon( imageVector = Icons.Filled.ArrowDownward, contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom), - tint = Color(0xFF00C851) + modifier = Modifier.size(22.dp), + tint = colorScheme.primary ) } } @@ -487,30 +538,6 @@ fun ChatScreen(viewModel: ChatViewModel) { } } -@Composable -private fun NearbyNotesRevealHint( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val actionLabel = stringResource(R.string.nearby_notes_reveal) - TextButton( - onClick = onClick, - modifier = modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .padding(horizontal = 24.dp) - .semantics { contentDescription = actionLabel }, - ) { - Text( - text = "📍 $actionLabel", - modifier = Modifier.clearAndSetSemantics { }, - color = MaterialTheme.colorScheme.primary, - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - ) - } -} - @Composable private fun NearbyNotesStrip( noteCount: Int, @@ -537,7 +564,7 @@ private fun NearbyNotesStrip( }, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.primary, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, ) Text( @@ -561,58 +588,115 @@ fun ChatInputSection( commandSuggestions: List, showMentionSuggestions: Boolean, mentionSuggestions: List, + mentionPeerIdentities: Map = emptyMap(), onCommandSuggestionClick: (CommandSuggestion) -> Unit, onMentionSuggestionClick: (String) -> Unit, selectedPrivatePeer: String?, currentChannel: String?, nickname: String, colorScheme: ColorScheme, - showMediaButtons: Boolean + showMediaButtons: Boolean, + modifier: Modifier = Modifier ) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.background + Column( + // Flat, slightly translucent screen background — the same treatment as the top bar, so the + // two bars are visibly the same kind of surface. No gradient: a soft ramp here just looked + // like a smudge above a crisp hairline. The rule is inside the background so the whole bar + // is one surface with a top border, rather than a line floating over the conversation. + modifier = modifier + .fillMaxWidth() + .background(colorScheme.background.copy(alpha = BarBackgroundAlpha)) ) { - Column { - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) - // Command suggestions box - if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { - CommandSuggestionsBox( - suggestions = commandSuggestions, - onSuggestionClick = onCommandSuggestionClick, - modifier = Modifier.fillMaxWidth() - ) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f)) + // Hairline marking where chrome begins. Faint on purpose — it is a hint, not a border. + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) + + // Command suggestions box + if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { + CommandSuggestionsBox( + suggestions = commandSuggestions, + onSuggestionClick = onCommandSuggestionClick, + modifier = Modifier.fillMaxWidth() + ) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) + } + // Retain the final populated list while the picker exits. The state layer clears + // suggestions together with visibility; without this snapshot the panel would empty and + // snap shut before its shrink/fade animation had a frame to run. + var retainedMentionSuggestions by remember { mutableStateOf(emptyList()) } + LaunchedEffect(mentionSuggestions) { + if (mentionSuggestions.isNotEmpty()) { + retainedMentionSuggestions = mentionSuggestions } - // Mention suggestions box - if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) { + } + val mentionPickerVisible = showMentionSuggestions && mentionSuggestions.isNotEmpty() + val displayedMentionSuggestions = mentionSuggestions.ifEmpty { + retainedMentionSuggestions + } + + AnimatedVisibility( + visible = mentionPickerVisible, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandVertically( + animationSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ), + expandFrom = Alignment.Bottom + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkVertically( + animationSpec = tween( + BitchatMotion.QUICK_MS, + easing = FastOutSlowInEasing + ), + shrinkTowards = Alignment.Bottom + ) + ) { + Column { MentionSuggestionsBox( - suggestions = mentionSuggestions, + suggestions = displayedMentionSuggestions, + mentionPeerIdentities = mentionPeerIdentities, onSuggestionClick = onMentionSuggestionClick, modifier = Modifier.fillMaxWidth() ) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f)) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) } - MessageInput( - value = messageText, - onValueChange = onMessageTextChange, - onSend = onSend, - onSendVoiceNote = onSendVoiceNote, - onSendImageNote = onSendImageNote, - onSendFileNote = onSendFileNote, - selectedPrivatePeer = selectedPrivatePeer, - currentChannel = currentChannel, - nickname = nickname, - showMediaButtons = showMediaButtons, - modifier = Modifier.fillMaxWidth() - ) } + MessageInput( + value = messageText, + onValueChange = onMessageTextChange, + onSend = onSend, + onSendVoiceNote = onSendVoiceNote, + onSendImageNote = onSendImageNote, + onSendFileNote = onSendFileNote, + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + showMediaButtons = showMediaButtons, + mentionPeerIdentities = mentionPeerIdentities, + modifier = Modifier.fillMaxWidth() + ) } } -@OptIn(ExperimentalMaterial3Api::class) + +/** + * Opacity shared by both bars. + * + * Slight, so the conversation scrolling underneath stays faintly perceptible and the chrome reads + * as sitting over the content rather than boxing it in — without ever costing legibility. + */ +private const val BarBackgroundAlpha = 0.88f + +/** + * Fraction of the header that stays fully opaque, measured from the top. + * + * The header is the one place a gradient earns its keep: the status bar is transparent, so the + * header has to be the true background colour where the two meet or the system bar stops looking + * like part of the app. Everything below that stop matches the composer's flat translucency. + */ +private const val HeaderOpaqueStop = 0.72f @Composable private fun ChatFloatingHeader( - headerHeight: Dp, selectedPrivatePeer: String?, currentChannel: String?, nickname: String, @@ -626,42 +710,48 @@ private fun ChatFloatingHeader( ) { val context = androidx.compose.ui.platform.LocalContext.current val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) } - - Surface( + + Box( modifier = Modifier .fillMaxWidth() .zIndex(1f) - .windowInsetsPadding(WindowInsets.statusBars), // Extend into status bar area - color = colorScheme.background // Solid background color extending into status bar - ) { - TopAppBar( - title = { - ChatHeaderContent( - selectedPrivatePeer = selectedPrivatePeer, - currentChannel = currentChannel, - nickname = nickname, - viewModel = viewModel, - onBackClick = { - when { - selectedPrivatePeer != null -> viewModel.endPrivateChat() - currentChannel != null -> viewModel.switchToChannel(null) - } - }, - onSidebarClick = onSidebarToggle, - onTripleClick = onPanicClear, - onShowAppInfo = onShowAppInfo, - onLocationChannelsClick = onLocationChannelsClick, - onLocationNotesClick = { - // Ensure location is loaded before showing sheet - locationManager.refreshChannels() - onLocationNotesClick() - } + // Fully opaque where it meets the system status bar, fading to translucent at its + // lower edge. The status bar itself is transparent, so anything less than opaque at + // the top would let the wallpaper or a light system-bar scrim bleed through and the + // header would stop reading as part of the app. + .background( + Brush.verticalGradient( + 0f to colorScheme.background, + HeaderOpaqueStop to colorScheme.background, + 1f to colorScheme.background.copy(alpha = BarBackgroundAlpha) ) + ) + .windowInsetsPadding(WindowInsets.statusBars) // Extend into status bar area + ) { + // No TopAppBar: it silently injects a 4.dp horizontal pad plus a 12.dp title inset and + // applies its own minimum heights, which made the header's spacing impossible to specify + // exactly. Height and edge insets belong to each header variant, so that a conversation + // header rendered here and one rendered in a sheet are laid out identically. + ChatHeaderContent( + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + viewModel = viewModel, + onBackClick = { + when { + selectedPrivatePeer != null -> viewModel.endPrivateChat() + currentChannel != null -> viewModel.switchToChannel(null) + } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent - ), - modifier = Modifier.height(headerHeight) // Ensure compact header height + onSidebarClick = onSidebarToggle, + onTripleClick = onPanicClear, + onShowAppInfo = onShowAppInfo, + onLocationChannelsClick = onLocationChannelsClick, + onLocationNotesClick = { + // Ensure location is loaded before showing sheet + locationManager.refreshChannels() + onLocationNotesClick() + } ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index d227eb41..d2ab06d3 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -7,9 +7,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.sp import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.mesh.MeshService -import androidx.compose.material3.ColorScheme import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.BitchatPalette +import com.bitchat.android.ui.theme.ChatVisualTokens +import com.bitchat.android.ui.theme.colorForPeer import java.text.SimpleDateFormat import java.util.* @@ -18,6 +19,27 @@ import java.util.* * Extracted from ChatScreen.kt for better organization */ +/** Opacity applied to the `#abcd` disambiguation suffix so the readable name dominates. */ +internal const val SUFFIX_ALPHA = ChatVisualTokens.SenderSuffixAlpha + +/** Compact transcript timestamp; seconds add noise without helping conversation scanning. */ +internal const val CHAT_TIMESTAMP_PATTERN = "HH:mm" + +/** Background opacity for a mention chip referring to somebody else. */ +internal const val MENTION_CHIP_ALPHA = ChatVisualTokens.HighlightAlpha + +/** Background opacity for a mention chip referring to you. Slightly stronger to catch the eye. */ +internal const val MENTION_CHIP_ALPHA_SELF = ChatVisualTokens.HighlightAlpha + +/** + * Mention token grammar shared by rendered messages and the composer. + * + * The optional `#abcd` suffix is part of the mention because it disambiguates peers that use the + * same nickname. Keeping one regex prevents the composer from styling only `@name` while the + * rendered transcript styles the complete token. + */ +internal val MENTION_TOKEN_REGEX = Regex("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)") + /** * Get RSSI-based color for signal strength visualization */ @@ -32,152 +54,31 @@ fun getRSSIColor(rssi: Int): Color { } /** - * Format message as annotated string with iOS-style formatting - * Timestamp at END, peer colors, hashtag suffix handling - */ -fun formatMessageAsAnnotatedString( - message: BitchatMessage, - currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) -): AnnotatedString { - val builder = AnnotatedString.Builder() - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - - // Determine if this message was sent by self - val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) - - if (message.sender != "system") { - // Get base color for this peer (iOS-style color assignment) - val baseColor = if (isSelf) { - Color(0xFFFF9500) // Orange for self (iOS orange) - } else { - getPeerColor(message, isDark) - } - - // Split sender into base name and hashtag suffix - val (baseName, suffix) = splitSuffix(message.sender) - - // Sender prefix "<@" - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append("<@") - builder.pop() - - // Base name (clickable) - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - val nicknameStart = builder.length - val truncatedBase = truncateNickname(baseName) - builder.append(truncatedBase) - val nicknameEnd = builder.length - - // Add click annotation for nickname (store canonical sender name with hash if available) - if (!isSelf) { - builder.addStringAnnotation( - tag = "nickname_click", - annotation = (message.originalSender ?: message.sender), - start = nicknameStart, - end = nicknameEnd - ) - } - builder.pop() - - // Hashtag suffix in lighter color (iOS style) - if (suffix.isNotEmpty()) { - builder.pushStyle(SpanStyle( - color = baseColor.copy(alpha = 0.6f), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append(suffix) - builder.pop() - } - - // Sender suffix "> " - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append("> ") - builder.pop() - - // Message content with iOS-style hashtag and mention highlighting - appendIOSFormattedContent( - builder, - message.content, - message.mentions, - currentUserNickname, - baseColor, - isSelf, - ) - - // iOS-style timestamp at the END (smaller, grey) - // Timestamp (and optional PoW badge) - builder.pushStyle(SpanStyle( - color = Color.Gray.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 4).sp - )) - builder.append(" [${timeFormatter.format(message.timestamp)}]") - // If message has valid PoW difficulty, append bits immediately after timestamp with minimal spacing - message.powDifficulty?.let { bits -> - if (bits > 0) { - builder.append(" ⛨${bits}b") - } - } - builder.pop() - - } else { - // System message - iOS style - builder.pushStyle(SpanStyle( - color = Color.Gray, - fontSize = (BASE_FONT_SIZE - 2).sp, - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic - )) - builder.append("* ${message.content} *") - builder.pop() - - // Timestamp for system messages too - builder.pushStyle(SpanStyle( - color = Color.Gray.copy(alpha = 0.5f), - fontSize = (BASE_FONT_SIZE - 4).sp - )) - builder.append(" [${timeFormatter.format(message.timestamp)}]") - builder.pop() - } - - return builder.toAnnotatedString() -} - -/** - * Build the sender label used by the two-row text-message layout. + * Build the sender label shown above the first message of a group. + * + * Renders `@name` plus a dimmed `#abcd` suffix. The name carries a `nickname_click` + * annotation for everyone except yourself. */ fun formatTextMessageSender( message: BitchatMessage, currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme + myPeerID: String, + palette: BitchatPalette ): AnnotatedString { val builder = AnnotatedString.Builder() - val isDark = - colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) - val senderColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) - val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium + val isSelf = message.isFromSelf(currentUserNickname, myPeerID) + val senderColor = if (isSelf) { + palette.accentOrange + } else { + colorForPeer(peerIdentityForMessage(message), palette) + } + val senderWeight = FontWeight.SemiBold val (baseName, suffix) = splitSuffix(message.sender) builder.pushStyle( SpanStyle( color = senderColor, - fontSize = BASE_FONT_SIZE.sp, + fontSize = ChatVisualTokens.SenderFontSize, fontWeight = senderWeight ) ) @@ -198,9 +99,9 @@ fun formatTextMessageSender( if (suffix.isNotEmpty()) { builder.pushStyle( SpanStyle( - color = senderColor.copy(alpha = 0.6f), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = senderWeight + color = senderColor.copy(alpha = SUFFIX_ALPHA), + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.Normal ) ) builder.append(suffix) @@ -212,10 +113,13 @@ fun formatTextMessageSender( /** * Build the compact timestamp and optional proof-of-work label. + * + * Used standalone by media rows; text messages get the same span appended inline to the end of + * their body via [appendBodyTimestamp]. */ fun formatTextMessageMetadata( message: BitchatMessage, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) ): AnnotatedString { val builder = AnnotatedString.Builder() builder.pushStyle( @@ -233,66 +137,159 @@ fun formatTextMessageMetadata( } /** - * Build only the message body while retaining mention, URL and geohash styling. + * Append the timestamp (and optional PoW difficulty) directly after the message body so it + * trails the final words rather than occupying its own column. + * + * Deliberately carries no click annotation: the timestamp is decoration, and making it + * tappable would create dead zones inside the message body. + */ +private fun appendTimestampText( + builder: AnnotatedString.Builder, + message: BitchatMessage, + timeFormatter: SimpleDateFormat +) { + builder.append(" ") + builder.append(timeFormatter.format(message.timestamp)) + message.powDifficulty?.takeIf { it > 0 }?.let { bits -> + builder.append(" ⛨${bits}b") + } +} + +private fun appendBodyTimestamp( + builder: AnnotatedString.Builder, + message: BitchatMessage, + palette: BitchatPalette, + timeFormatter: SimpleDateFormat, +) { + builder.pushStyle( + SpanStyle( + color = palette.textTertiary, + fontSize = ChatVisualTokens.SystemTimeFontSize, + fontWeight = FontWeight.Normal, + ) + ) + appendTimestampText(builder, message, timeFormatter) + builder.pop() +} + +private fun appendMutedTimestamp( + builder: AnnotatedString.Builder, + message: BitchatMessage, + contentColor: Color, + timeFormatter: SimpleDateFormat, +) { + builder.pushStyle( + SpanStyle( + color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha), + fontSize = ChatVisualTokens.SystemTimeFontSize, + fontWeight = FontWeight.Normal, + ) + ) + appendTimestampText(builder, message, timeFormatter) + builder.pop() +} + +/** + * Build the message body: neutral text with mention/URL/geohash accents, followed by an inline + * trailing timestamp. + * + * Body text is intentionally neutral rather than peer-colored. Colour is reserved for + * `@names`, which is what makes a busy channel scannable. */ fun formatTextMessageBody( message: BitchatMessage, currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme + palette: BitchatPalette, + contentColor: Color, + linkColor: Color, + mentionPeerIdentities: Map = emptyMap(), + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), + includeTimestamp: Boolean = true ): AnnotatedString { val builder = AnnotatedString.Builder() - val isDark = - colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) - val accentColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) appendIOSFormattedContent( builder = builder, content = message.content, - mentions = message.mentions, currentUserNickname = currentUserNickname, - baseColor = accentColor, - isSelf = isSelf, - contentColor = colorScheme.onSurface + palette = palette, + contentColor = contentColor, + linkColor = linkColor, + mentionPeerIdentities = mentionPeerIdentities, ) + + if (includeTimestamp) { + appendBodyTimestamp(builder, message, palette, timeFormatter) + } return builder.toAnnotatedString() } /** - * Build only the nickname + timestamp header line for a message, matching styles of normal messages. + * Build a system / background-action line, e.g. `// Tor started. Routing all chats… 11:09:56`. + * + * The `//` prefix reads as machine narration in a monospace context and is far quieter than + * the previous `* italic asterisk *` treatment, which competed with real messages. + */ +fun formatSystemMessage( + message: BitchatMessage, + contentColor: Color, + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) +): AnnotatedString { + val builder = AnnotatedString.Builder() + builder.pushStyle( + SpanStyle( + color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha), + fontSize = ChatVisualTokens.SystemActionFontSize, + fontWeight = FontWeight.Medium, + ) + ) + builder.append("// ") + builder.append(message.content) + builder.pop() + + appendMutedTimestamp(builder, message, contentColor, timeFormatter) + return builder.toAnnotatedString() +} + +/** + * Header line for media (image / audio / file) rows. + * + * Matches the text-message treatment: `@name#abcd` with no angle brackets, followed by an + * inline trailing timestamp. Media rows have no body text to trail, so the timestamp sits on + * the same line as the name. */ fun formatMessageHeaderAnnotatedString( message: BitchatMessage, currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + myPeerID: String, + palette: BitchatPalette, + contentColor: Color, + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), + includeSender: Boolean = true ): AnnotatedString { val builder = AnnotatedString.Builder() - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + val isSelf = message.isFromSelf(currentUserNickname, myPeerID) - val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) + if (message.sender == "system") { + return formatSystemMessage(message, contentColor, timeFormatter) + } - if (message.sender != "system") { - val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) + if (includeSender) { + val baseColor = if (isSelf) { + palette.accentOrange + } else { + colorForPeer(peerIdentityForMessage(message), palette) + } val (baseName, suffix) = splitSuffix(message.sender) - // "<@" - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append("<@") - builder.pop() - - // Base name (clickable when not self) - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) + builder.pushStyle( + SpanStyle( + color = baseColor, + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.SemiBold, + ) + ) + builder.append("@") val nicknameStart = builder.length builder.append(truncateNickname(baseName)) val nicknameEnd = builder.length @@ -306,112 +303,23 @@ fun formatMessageHeaderAnnotatedString( } builder.pop() - // Hashtag suffix if (suffix.isNotEmpty()) { - builder.pushStyle(SpanStyle( - color = baseColor.copy(alpha = 0.6f), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) + builder.pushStyle( + SpanStyle( + color = baseColor.copy(alpha = SUFFIX_ALPHA), + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.Normal, + ) + ) builder.append(suffix) builder.pop() } - - // Sender suffix ">" - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append(">") - builder.pop() - - // Timestamp and optional PoW bits, matching normal message appearance - builder.pushStyle(SpanStyle( - color = Color.Gray.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 4).sp - )) - builder.append(" [${timeFormatter.format(message.timestamp)}]") - message.powDifficulty?.let { bits -> - if (bits > 0) builder.append(" ⛨${bits}b") - } - builder.pop() - } else { - // System message header (should rarely apply to voice) - builder.pushStyle(SpanStyle( - color = Color.Gray, - fontSize = (BASE_FONT_SIZE - 2).sp, - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic - )) - builder.append("* ${message.content} *") - builder.pop() - builder.pushStyle(SpanStyle( - color = Color.Gray.copy(alpha = 0.5f), - fontSize = (BASE_FONT_SIZE - 4).sp - )) - builder.append(" [${timeFormatter.format(message.timestamp)}]") - builder.pop() } + appendMutedTimestamp(builder, message, contentColor, timeFormatter) return builder.toAnnotatedString() } -/** - * iOS-style peer color assignment using djb2 hash algorithm - * Avoids orange (~30°) reserved for self messages - */ -fun getPeerColor(message: BitchatMessage, isDark: Boolean): Color { - // Create seed from peer identifier (prioritizing stable keys) - val seed = when { - message.senderPeerID?.startsWith("nostr:") == true || message.senderPeerID?.startsWith("nostr_") == true -> { - // For Nostr peers, use the full key if available, otherwise the peer ID - "nostr:${message.senderPeerID.lowercase()}" - } - message.senderPeerID?.length == 16 -> { - // For ephemeral peer IDs, try to get stable Noise key, fallback to peer ID - "noise:${message.senderPeerID.lowercase()}" - } - message.senderPeerID?.length == 64 -> { - // This is already a stable Noise key - "noise:${message.senderPeerID.lowercase()}" - } - else -> { - // Fallback to sender name - message.sender.lowercase() - } - } - - return colorForPeerSeed(seed, isDark) -} - -/** - * Generate consistent peer color using djb2 hash (matches iOS algorithm exactly) - */ -fun colorForPeerSeed(seed: String, isDark: Boolean): Color { - // djb2 hash algorithm (matches iOS implementation) - var hash = 5381UL - for (byte in seed.toByteArray()) { - hash = ((hash shl 5) + hash) + byte.toUByte().toULong() - } - - var hue = (hash % 360UL).toDouble() / 360.0 - - // Avoid orange (~30°) reserved for self (matches iOS logic) - val orange = 30.0 / 360.0 - if (kotlin.math.abs(hue - orange) < 0.05) { - hue = (hue + 0.12) % 1.0 - } - - val saturation = if (isDark) 0.50 else 0.70 - val brightness = if (isDark) 0.85 else 0.35 - - return Color.hsv( - hue = (hue * 360).toFloat(), - saturation = saturation.toFloat(), - value = brightness.toFloat() - ) -} - /** * Split a name into base and a '#abcd' suffix if present (matches iOS splitSuffix exactly) */ @@ -430,23 +338,106 @@ fun splitSuffix(name: String): Pair { } /** - * iOS-style content formatting with proper hashtag and mention handling + * Build a case-insensitive mention-token lookup from canonical peer identities. + * + * Suffixed names such as `alice#04af` resolve exactly. Their unsuffixed base is only retained when + * it identifies one peer; ambiguous bases are deliberately omitted rather than coloring a mention + * as the wrong person. + */ +internal fun buildMentionPeerIdentityMap( + messages: List, + knownPeers: List> = emptyList(), +): Map { + val candidates = linkedMapOf>() + + fun add(displayName: String, identity: PeerIdentity) { + val normalizedName = displayName.trim().removePrefix("@") + if (normalizedName.isEmpty()) return + + val (baseName, suffix) = splitSuffix(normalizedName) + val exactKey = normalizedName.lowercase(Locale.ROOT) + candidates.getOrPut(exactKey) { linkedSetOf() }.add(identity) + + if (suffix.isNotEmpty()) { + val baseKey = baseName.lowercase(Locale.ROOT) + candidates.getOrPut(baseKey) { linkedSetOf() }.add(identity) + } + } + + messages + .asSequence() + .filterNot { it.sender == "system" } + .forEach { add(it.sender, peerIdentityForMessage(it)) } + knownPeers.forEach { (displayName, identity) -> add(displayName, identity) } + + return candidates.mapNotNull { (token, identities) -> + identities.singleOrNull()?.let { token to it } + }.toMap() +} + +internal fun resolveMentionPeerIdentity( + mention: String, + mentionPeerIdentities: Map, +): PeerIdentity? { + val mentionWithoutAt = mention.trim().removePrefix("@") + val baseName = splitSuffix(mentionWithoutAt).first + return mentionPeerIdentities[mentionWithoutAt.lowercase(Locale.ROOT)] + ?: mentionPeerIdentities[baseName.lowercase(Locale.ROOT)] +} + +/** + * Resolve the deterministic color for a mention on every surface that displays one. + * + * Exact suffixed tokens win; an unsuffixed nickname is only present in the identity map when it is + * unambiguous. The nickname fallback preserves the legacy behavior for peers with no stable ID. + */ +internal fun colorForMention( + mention: String, + mentionPeerIdentities: Map, + palette: BitchatPalette, +): Color { + val mentionWithoutAt = mention.trim().removePrefix("@") + val identity = resolveMentionPeerIdentity(mentionWithoutAt, mentionPeerIdentities) + ?: PeerIdentity.nickname(mentionWithoutAt) + return colorForPeer(identity, palette) +} + +/** + * A bare `anon` label means the geohash heartbeat has not announced a username yet. The transport + * may append a `#abcd` disambiguator, which does not turn it into an announced name. Names such as + * `anon1234`, `anonymous`, and `anonracer` are real announced usernames. + */ +internal fun isUnannouncedNickname(displayName: String): Boolean { + val base = splitSuffix(displayName.trim()).first + return base.equals("anon", ignoreCase = true) +} + +/** + * iOS-style content formatting with proper hashtag and mention handling. + * + * Redesign notes: + * - Plain text renders in Material `onSurface`; colour is reserved for `@mentions`, + * links and geohashes. + * - Mentions get a tinted background chip so they read as a distinct token inside a sentence. + * The chip is tinted by *the mentioned peer's* colour, not the sender's, so `@alice` looks + * the same everywhere she is referenced. + * - Neither "self" nor "you were mentioned" bolds the whole body any more. Bolding entire + * paragraphs was the single biggest source of visual noise in the old layout; the mention + * chip carries that emphasis instead. */ private fun appendIOSFormattedContent( builder: AnnotatedString.Builder, content: String, - mentions: List?, currentUserNickname: String, - baseColor: Color, - isSelf: Boolean, - contentColor: Color = baseColor, + palette: BitchatPalette, + contentColor: Color, + linkColor: Color, + mentionPeerIdentities: Map, ) { - // iOS-style patterns: allow optional '#abcd' suffix in mentions val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex() - val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex() val hashtagMatches = hashtagPattern.findAll(content).toList() - val mentionMatches = mentionPattern.findAll(content).toList() + val mentionMatches = MENTION_TOKEN_REGEX.findAll(content).toList() // Combine and sort matches, but exclude hashtags that overlap with mentions val mentionRanges = mentionMatches.map { it.range } @@ -507,28 +498,28 @@ private fun appendIOSFormattedContent( } allMatches.sortBy { it.first.first } - + + val plainStyle = SpanStyle( + color = contentColor, + fontSize = BASE_FONT_SIZE.sp, + fontWeight = FontWeight.Normal + ) + val linkStyle = SpanStyle( + color = linkColor, + fontSize = BASE_FONT_SIZE.sp, + fontWeight = FontWeight.Normal, + textDecoration = TextDecoration.Underline + ) + var lastEnd = 0 - val isMentioned = mentions?.contains(currentUserNickname) == true - + for ((range, type) in allMatches) { // Add text before match if (lastEnd < range.first) { val beforeText = content.substring(lastEnd, range.first) if (beforeText.isNotEmpty()) { - builder.pushStyle(SpanStyle( - color = contentColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) - if (isMentioned) { - // Make entire message bold if user is mentioned - builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - builder.append(beforeText) - builder.pop() - } else { - builder.append(beforeText) - } + builder.pushStyle(plainStyle) + builder.append(beforeText) builder.pop() } } @@ -540,65 +531,58 @@ private fun appendIOSFormattedContent( // iOS-style mention with hashtag suffix support val mentionWithoutAt = matchText.removePrefix("@") val (mBase, mSuffix) = splitSuffix(mentionWithoutAt) - - // Check if this mention targets current user + + // Mentions targeting you are the one thing worth shouting about. val isMentionToMe = mBase == currentUserNickname - val mentionColor = if (isMentionToMe) Color(0xFFFF9500) else baseColor - - // "@" symbol + val mentionColor = if (isMentionToMe) { + palette.accentOrange + } else { + colorForMention( + mention = mentionWithoutAt, + mentionPeerIdentities = mentionPeerIdentities, + palette = palette, + ) + } + val chipAlpha = if (isMentionToMe) MENTION_CHIP_ALPHA_SELF else MENTION_CHIP_ALPHA + val mentionWeight = if (isMentionToMe) FontWeight.Bold else FontWeight.SemiBold + + // A single outer span carrying the background makes the chip render as one + // continuous rectangle. Pushing the background per-token would leave hairline + // seams between "@", the name and the "#abcd" suffix. + builder.pushStyle(SpanStyle(background = mentionColor.copy(alpha = chipAlpha))) + builder.pushStyle(SpanStyle( color = mentionColor, fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold + fontWeight = mentionWeight )) builder.append("@") - builder.pop() - - // Base name (truncate for rendering) - builder.pushStyle(SpanStyle( - color = mentionColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold - )) builder.append(truncateNickname(mBase)) builder.pop() - + // Hashtag suffix in lighter color if (mSuffix.isNotEmpty()) { builder.pushStyle(SpanStyle( - color = mentionColor.copy(alpha = 0.6f), + color = mentionColor.copy(alpha = SUFFIX_ALPHA), fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold + fontWeight = mentionWeight )) builder.append(mSuffix) builder.pop() } + + builder.pop() // background chip } "hashtag" -> { // Render general hashtags like normal content - builder.pushStyle(SpanStyle( - color = contentColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) - if (isMentioned) { - builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - builder.append(matchText) - builder.pop() - } else { - builder.append(matchText) - } + builder.pushStyle(plainStyle) + builder.append(matchText) builder.pop() } else -> { if (type == "geohash") { - // Style geohash in blue, underlined, and add click annotation - builder.pushStyle(SpanStyle( - color = Color(0xFF007AFF), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold, - textDecoration = TextDecoration.Underline - )) + // Style geohash as a link and add click annotation + builder.pushStyle(linkStyle) val start = builder.length builder.append(matchText) val end = builder.length @@ -611,13 +595,8 @@ private fun appendIOSFormattedContent( ) builder.pop() } else if (type == "url") { - // Style URL in blue, underlined, and add click annotation with the raw text - builder.pushStyle(SpanStyle( - color = Color(0xFF007AFF), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold, - textDecoration = TextDecoration.Underline - )) + // Style URL as a link and add click annotation with the raw text + builder.pushStyle(linkStyle) val start = builder.length builder.append(matchText) val end = builder.length @@ -630,11 +609,7 @@ private fun appendIOSFormattedContent( builder.pop() } else { // Fallback: treat as normal text - builder.pushStyle(SpanStyle( - color = contentColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) + builder.pushStyle(plainStyle) builder.append(matchText) builder.pop() } @@ -647,18 +622,8 @@ private fun appendIOSFormattedContent( // Add remaining text if (lastEnd < content.length) { val remainingText = content.substring(lastEnd) - builder.pushStyle(SpanStyle( - color = contentColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) - if (isMentioned) { - builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - builder.append(remainingText) - builder.pop() - } else { - builder.append(remainingText) - } + builder.pushStyle(plainStyle) + builder.append(remainingText) builder.pop() } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt index 0cfa91ba..f0647e6c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt @@ -6,11 +6,12 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.LocalBitchatPalette import androidx.compose.ui.res.stringResource import com.bitchat.android.R import androidx.compose.ui.platform.LocalClipboardManager @@ -35,14 +36,13 @@ fun ChatUserSheet( val coroutineScope = rememberCoroutineScope() val clipboardManager = LocalClipboardManager.current - // iOS system colors (matches LocationChannelsSheet exactly) val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green - val standardBlue = Color(0xFF007AFF) // iOS blue - val standardPurple = if (isDark) Color(0xFFBF5AF2) else Color(0xFFAF52DE) // iOS purple - val standardRed = Color(0xFFFF3B30) // iOS red - val standardGrey = if (isDark) Color(0xFF8E8E93) else Color(0xFF6D6D70) // iOS grey + val palette = LocalBitchatPalette.current + val standardGreen = colorScheme.primary + val standardBlue = colorScheme.secondary + val standardPurple = palette.accentPurple + val standardRed = colorScheme.error + val standardGrey = colorScheme.onSurfaceVariant if (isPresented) { BitchatBottomSheet( @@ -59,7 +59,7 @@ fun ChatUserSheet( Text( text = stringResource(R.string.at_nickname, targetNickname), fontSize = 18.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) @@ -67,7 +67,7 @@ fun ChatUserSheet( Text( text = if (selectedMessage != null) stringResource(R.string.choose_action_message_or_user) else stringResource(R.string.choose_action_user), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -183,7 +183,7 @@ fun ChatUserSheet( Text( text = stringResource(R.string.cancel_lower), fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } @@ -214,7 +214,7 @@ private fun UserActionRow( Text( text = title, fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, color = titleColor ) @@ -222,7 +222,7 @@ private fun UserActionRow( Text( text = subtitle, fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index c62a39e5..3017b20c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1168,22 +1168,17 @@ class ChatViewModel( } } - // MARK: - iOS-Compatible Color System + // MARK: - Canonical peer identities /** - * Get consistent color for a mesh peer by ID (iOS-compatible) + * Return the stable identity used by every UI surface to color a mesh peer. */ - fun colorForMeshPeer(peerID: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - // Try to get stable Noise key, fallback to peer ID - val seed = "noise:${peerID.lowercase()}" - return colorForPeerSeed(seed, isDark).copy() - } + fun peerIdentityForMeshPeer(peerID: String): PeerIdentity = PeerIdentity.mesh(peerID) /** - * Get consistent color for a Nostr pubkey (iOS-compatible) + * Return the stable identity used by every UI surface to color a Nostr peer. */ - fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark) -} + fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity = + geohashViewModel.peerIdentityForNostrPubkey(pubkeyHex) } diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index a92d2d60..34b205af 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import java.util.Date +import java.util.Locale /** * Handles processing of IRC-style commands @@ -448,18 +449,28 @@ class CommandProcessor( is com.bitchat.android.geohash.ChannelID.Mesh, null -> { // Mesh channel: use Bluetooth mesh peer nicknames - meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] } + val peerNicknames = meshService.getPeerNicknames() + peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] } } is com.bitchat.android.geohash.ChannelID.Location -> { // Location channel: use geohash participants with collision-resistant suffixes - val geohashPeople = viewModel.geohashPeople.value ?: emptyList() + val geohashPeople = viewModel.geohashPeople.value val currentNickname = state.getNicknameValue() + val duplicateNames = duplicateGeohashBaseNames(geohashPeople) geohashPeople.mapNotNull { person -> - val displayName = person.displayName - // Exclude self from suggestions - if (displayName.startsWith("${currentNickname}#")) { + val baseName = splitSuffix(person.displayName).first + val hasNicknameCollision = + baseName.lowercase(Locale.ROOT) in duplicateNames + val displayName = disambiguatedGeohashDisplayName(person, duplicateNames) + // A unique local nickname can be excluded directly. If it collides, the + // nickname alone cannot identify which row is self, so keep the suffixed + // rows rather than accidentally hiding the other user. + if ( + !hasNicknameCollision && + baseName.equals(currentNickname, ignoreCase = true) + ) { null } else { displayName @@ -469,13 +480,11 @@ class CommandProcessor( } } else { // Fallback to mesh peers if no viewModel available - meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] } + val peerNicknames = meshService.getPeerNicknames() + peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] } } - // Filter nicknames based on the text after @ - val filteredNicknames = peerCandidates.filter { nickname -> - nickname.startsWith(textAfterAt, ignoreCase = true) - }.sorted() + val filteredNicknames = filterMentionCandidates(peerCandidates, textAfterAt) if (filteredNicknames.isNotEmpty()) { state.setMentionSuggestions(filteredNicknames) @@ -533,3 +542,22 @@ class CommandProcessor( } } } + +/** + * Keep mention autocomplete useful in crowded channels: a bare `anon` identity has not announced + * a username and is not actionable. Names such as `anon1234` are announced usernames and remain + * valid mention targets. + */ +internal fun filterMentionCandidates( + candidates: List, + query: String +): List { + return candidates.asSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .filterNot(::isUnannouncedNickname) + .filter { nickname -> nickname.startsWith(query, ignoreCase = true) } + .distinctBy { nickname -> nickname.lowercase(Locale.ROOT) } + .sortedWith(String.CASE_INSENSITIVE_ORDER) + .toList() +} diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 0bdc85ee..0cf88b74 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -1,39 +1,34 @@ package com.bitchat.android.ui -import android.util.Log -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.Explore -import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material.icons.filled.Email +import android.util.Log +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.bitchat.android.ui.theme.BASE_FONT_SIZE -import java.util.* import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.painterResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.colorForPeer import com.bitchat.android.R +import com.bitchat.android.ui.theme.LocalBitchatPalette +import java.util.* /** - * GeohashPeopleList - iOS-compatible component for displaying geohash participants - * Shows peers discovered through Nostr ephemeral events instead of Bluetooth peers + * Geohash people list — card groups matching location / settings sheet rows. */ -/** - * GeoPerson data class - matches iOS GeoPerson structure exactly - */ data class GeoPerson( val id: String, // pubkey hex (lowercased) - matches iOS - val displayName: String, // nickname with #suffix - matches iOS + val displayName: String, // nickname with #suffix - matches iOS val lastSeen: Date // activity timestamp - matches iOS ) @@ -43,116 +38,227 @@ fun GeohashPeopleList( onTapPerson: () -> Unit, modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme - - // Observe geohash people from ChatViewModel val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle() + val teleportedGeo by viewModel.teleportedGeo.collectAsStateWithLifecycle() val nickname by viewModel.nickname.collectAsStateWithLifecycle() val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() - - Column { - // Header matching iOS style - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.LocationOn, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(R.string.geohash_people_header), - style = MaterialTheme.typography.labelSmall.copy( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold - ), - color = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - - if (geohashPeople.isEmpty()) { - // Empty state - matches iOS "nobody around..." - Text( - text = stringResource(R.string.nobody_around), - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = colorScheme.onSurface.copy(alpha = 0.5f), - modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp) - ) - } else { - // Get current geohash identity for "me" detection - val myHex = remember(selectedLocationChannel) { - when (val channel = selectedLocationChannel) { - is com.bitchat.android.geohash.ChannelID.Location -> { - try { - val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity( - forGeohash = channel.channel.geohash, - context = viewModel.getApplication() - ) - identity.publicKeyHex.lowercase() - } catch (e: Exception) { - Log.e("GeohashPeopleList", "Failed to derive identity: ${e.message}") - null - } - } - else -> null - } - } - - // Sort people: me first, then by lastSeen (matches iOS exactly) - val orderedPeople = remember(geohashPeople, myHex) { - geohashPeople.sortedWith { a, b -> - when { - myHex != null && a.id == myHex && b.id != myHex -> -1 - myHex != null && b.id == myHex && a.id != myHex -> 1 - else -> b.lastSeen.compareTo(a.lastSeen) // Most recent first - } - } - } - // Compute base name collisions to decide whether to show hash suffix - val baseNameCounts = remember(geohashPeople) { - val counts = mutableMapOf() - geohashPeople.forEach { person -> - val (b, _) = com.bitchat.android.ui.splitSuffix(person.displayName) - counts[b] = (counts[b] ?: 0) + 1 + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + val myHex = remember(selectedLocationChannel) { + when (val channel = selectedLocationChannel) { + is com.bitchat.android.geohash.ChannelID.Location -> { + try { + val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity( + forGeohash = channel.channel.geohash, + context = viewModel.getApplication() + ) + identity.publicKeyHex.lowercase(Locale.ROOT) + } catch (e: Exception) { + Log.e("GeohashPeopleList", "Failed to derive identity: ${e.message}") + null } - counts } - - val firstID = orderedPeople.firstOrNull()?.id - - orderedPeople.forEach { person -> + else -> null + } + } + val peopleIncludingSelf = remember(geohashPeople, myHex, nickname) { + if (myHex != null && geohashPeople.none { it.id.equals(myHex, ignoreCase = true) }) { + listOf( + GeoPerson( + id = myHex, + displayName = nickname.ifBlank { "anon" }, + lastSeen = Date(0) + ) + ) + geohashPeople + } else { + geohashPeople + } + } + val sections = remember(peopleIncludingSelf, myHex, isTeleported, teleportedGeo) { + sectionGeohashPeople( + people = peopleIncludingSelf, + myId = myHex, + selfIsTeleported = isTeleported, + teleportedIds = teleportedGeo + ) + } + val displayedPeople = remember(sections) { + sections.onLocation + sections.teleportedIn + } + val teleportedPersonIds = remember(sections.teleportedIn) { + sections.teleportedIn.mapTo(mutableSetOf()) { it.id.lowercase(Locale.ROOT) } + } + val duplicateBaseNames = remember(displayedPeople) { + duplicateGeohashBaseNames(displayedPeople) + } + + Column(modifier = modifier) { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_people, + title = stringResource(R.string.people_count_title, displayedPeople.size) + ) + + if (displayedPeople.isEmpty()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Text( + text = stringResource(R.string.nobody_around), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = palette.textTertiary, + modifier = Modifier.padding( + horizontal = SheetRowHorizontal, + vertical = SheetRowVertical + ) + ) + } + } else { + @Composable + fun personRow(person: GeoPerson) { + val isMe = myHex != null && person.id.equals(myHex, ignoreCase = true) + val personIsTeleported = if (isMe) { + isTeleported + } else { + person.id.lowercase(Locale.ROOT) in teleportedPersonIds + } GeohashPersonItem( person = person, - isFirst = person.id == firstID, - isMe = myHex != null && person.id == myHex, + isMe = isMe, hasUnreadDM = unreadPrivateMessages.contains("nostr_${person.id.take(16)}"), - isTeleported = person.id != myHex && viewModel.isPersonTeleported(person.id), - isMyTeleported = person.id == myHex && isTeleported, - nickname = nickname, - colorScheme = colorScheme, + isTeleported = personIsTeleported, viewModel = viewModel, - showHashSuffix = (baseNameCounts[com.bitchat.android.ui.splitSuffix(person.displayName).first] ?: 0) > 1, + showHashSuffix = splitSuffix(person.displayName) + .first + .lowercase(Locale.ROOT) in duplicateBaseNames, onTap = { - if (person.id != myHex) { - // TODO: Re-enable when NIP-17 geohash DM issues are fixed - // Start geohash DM (iOS-compatible) + if (!isMe) { viewModel.startGeohashDM(person.id) onTapPerson() } } ) } + + if (sections.onLocation.isNotEmpty()) { + AboutSectionLabel(text = stringResource(R.string.section_on_location)) + PeopleCard( + people = sections.onLocation, + row = { personRow(it) } + ) + } + + if (sections.teleportedIn.isNotEmpty()) { + AboutSectionLabel(text = stringResource(R.string.section_teleported_in)) + PeopleCard( + people = sections.teleportedIn, + row = { personRow(it) } + ) + } + } + } +} + +internal data class GeohashPeopleSections( + val onLocation: List, + val teleportedIn: List +) + +/** + * Names that require a short identity suffix, calculated across both people sections. + * + * Matching is case-insensitive to mirror geohash chat's nickname collision handling. + */ +internal fun duplicateGeohashBaseNames(people: List): Set = + people + .groupingBy { splitSuffix(it.displayName).first.lowercase(Locale.ROOT) } + .eachCount() + .filterValues { it > 1 } + .keys + +/** + * The same `#abcd` disambiguator used by geohash chat. + * + * Presence rows normally carry only a base nickname, so derive the suffix from the full Nostr + * public key when a collision exists. Preserve an already-announced suffix for compatibility. + */ +internal fun geohashIdentitySuffix(person: GeoPerson, showHashSuffix: Boolean): String { + if (!showHashSuffix) return "" + val announcedSuffix = splitSuffix(person.displayName).second + return announcedSuffix.ifEmpty { "#${person.id.takeLast(4)}" } +} + +internal fun disambiguatedGeohashDisplayName( + person: GeoPerson, + duplicateBaseNames: Set, +): String { + val baseName = splitSuffix(person.displayName).first + val showSuffix = baseName.lowercase(Locale.ROOT) in duplicateBaseNames + return baseName + geohashIdentitySuffix(person, showSuffix) +} + +/** + * Split announced identities by how they entered this geohash. Bare `anon` heartbeat identities + * are omitted, while announced names such as `anon1234` remain ordinary participants. Self is + * retained even before a nickname announcement and is always first in the matching section. + */ +internal fun sectionGeohashPeople( + people: List, + myId: String?, + selfIsTeleported: Boolean, + teleportedIds: Set +): GeohashPeopleSections { + val normalizedMyId = myId?.lowercase(Locale.ROOT) + val normalizedTeleportedIds = teleportedIds + .mapTo(mutableSetOf()) { it.lowercase(Locale.ROOT) } + fun isSelf(person: GeoPerson): Boolean = + normalizedMyId != null && person.id.lowercase(Locale.ROOT) == normalizedMyId + fun isTeleported(person: GeoPerson): Boolean = + if (isSelf(person)) selfIsTeleported + else person.id.lowercase(Locale.ROOT) in normalizedTeleportedIds + + val displayedPeople = people.filter { person -> + isSelf(person) || !isUnannouncedNickname(person.displayName) + } + val ordered = displayedPeople.sortedWith( + compareByDescending(::isSelf) + .thenByDescending { it.lastSeen } + ) + return GeohashPeopleSections( + onLocation = ordered.filterNot(::isTeleported), + teleportedIn = ordered.filter(::isTeleported) + ) +} + +/** One uncapped card of people. The enclosing sheet owns scrolling. */ +@Composable +private fun PeopleCard( + people: List, + row: @Composable (GeoPerson) -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + AnimatedRowColumn(items = people, key = { it.id }) { index, person -> + Column { + if (index > 0) SheetCardDivider() + row(person) + } } } } @@ -160,113 +266,97 @@ fun GeohashPeopleList( @Composable private fun GeohashPersonItem( person: GeoPerson, - isFirst: Boolean, isMe: Boolean, hasUnreadDM: Boolean, isTeleported: Boolean, - isMyTeleported: Boolean, - nickname: String, - colorScheme: ColorScheme, viewModel: ChatViewModel, showHashSuffix: Boolean, onTap: () -> Unit ) { + val palette = LocalBitchatPalette.current + + val statusIconRes = + if (isTeleported) R.drawable.ic_spec_teleport + else R.drawable.ic_spec_on_location_person + + val (baseNameRaw, _) = splitSuffix(person.displayName) + val baseName = truncateNickname(baseNameRaw) + val suffix = geohashIdentitySuffix(person, showHashSuffix) + val assignedColor = colorForPeer( + viewModel.peerIdentityForNostrPubkey(person.id), + palette + ) + val baseColor = if (isMe) palette.accentOrange else assignedColor + Row( modifier = Modifier .fillMaxWidth() - .clickable { onTap() } - .padding(horizontal = 24.dp, vertical = 4.dp) - .padding(top = if (isFirst) 10.dp else 0.dp), + // Exact height, not padding: a row that sizes to its content makes the card change + // height whenever the list reorders. + .height(SheetRowHeight) + .clickable(onClick = onTap) + .padding(horizontal = SheetRowHorizontal), verticalAlignment = Alignment.CenterVertically ) { - // Icon logic matching iOS exactly - if (hasUnreadDM) { - // Unread DM indicator (orange envelope) - Icon( - imageVector = Icons.Filled.Email, - contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(12.dp), - tint = Color(0xFFFF9500) // iOS orange - ) - } else { - // Face icon with teleportation state - val (iconName, iconColor) = when { - isMe && isMyTeleported -> "face.dashed" to Color(0xFFFF9500) // Orange for teleported me - isTeleported -> "face.dashed" to colorScheme.onSurface // Regular color for teleported others - isMe -> "face.smiling" to Color(0xFFFF9500) // Orange for me - else -> "face.smiling" to colorScheme.onSurface // Regular color for others + Box( + modifier = Modifier.size(SheetRowLeadingSlot), + contentAlignment = Alignment.Center + ) { + if (hasUnreadDM) { + Icon( + imageVector = Icons.Filled.Email, + contentDescription = stringResource(R.string.cd_unread_message), + modifier = Modifier.size(22.dp), + tint = palette.accentOrange + ) + } else { + Icon( + painter = painterResource(statusIconRes), + contentDescription = if (isTeleported) { + stringResource(R.string.cd_teleported) + } else { + stringResource(R.string.section_on_location) + }, + modifier = Modifier.size(22.dp), + tint = baseColor + ) } - - // Use appropriate Material icon (closest match to iOS SF Symbols) - val icon = when (iconName) { - "face.dashed" -> Icons.Outlined.Explore - else -> Icons.Outlined.LocationOn - } - - Icon( - imageVector = icon, - contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User", - modifier = Modifier.size(12.dp), - tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) // Make dashed faces slightly transparent - ) } - - Spacer(modifier = Modifier.width(8.dp)) - - // Display name with suffix handling - val (baseNameRaw, suffixRaw) = com.bitchat.android.ui.splitSuffix(person.displayName) - val baseName = truncateNickname(baseNameRaw) - val suffix = if (showHashSuffix) suffixRaw else "" - - // Get consistent peer color (matches iOS color assignment exactly) - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val assignedColor = viewModel.colorForNostrPubkey(person.id, isDark) - val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor - + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { - // Base name with peer-specific color Text( text = baseName, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal - ), + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, color = baseColor, maxLines = 1, overflow = TextOverflow.Ellipsis ) - - // Suffix (collision-resistant #abcd) in lighter shade + if (suffix.isNotEmpty()) { Text( text = suffix, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = baseColor.copy(alpha = 0.6f) + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + color = baseColor.copy(alpha = SUFFIX_ALPHA) ) } - - // "You" indicator for current user + if (isMe) { Text( text = stringResource(R.string.you_suffix), - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), + fontFamily = BitchatFontFamily, + fontSize = 14.sp, color = baseColor ) } } - - Spacer(modifier = Modifier.width(8.dp)) } } - - diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt index e15f9930..9565c384 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -1,5 +1,9 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Remove import android.annotation.SuppressLint import android.app.Activity import android.content.Intent @@ -15,10 +19,6 @@ import android.webkit.WebViewClient import androidx.activity.compose.setContent import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -30,14 +30,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.bitchat.android.ui.theme.BitchatTheme import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import androidx.core.view.updateLayoutParams import com.bitchat.android.geohash.Geohash @@ -87,15 +87,13 @@ class GeohashPickerActivity : OrientationAwareActivity() { val initialPrecision = geohashToFocus?.length ?: 5 setContent { - MaterialTheme { + BitchatTheme { var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") } var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) } var webViewRef by remember { mutableStateOf(null) } - // iOS system-like colors used across app val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + val standardGreen = colorScheme.primary Scaffold { padding -> Box(Modifier.fillMaxSize()) { @@ -186,7 +184,7 @@ class GeohashPickerActivity : OrientationAwareActivity() { text = stringResource(R.string.pan_zoom_instruction), fontSize = 12.sp, textAlign = TextAlign.Center, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier .padding(horizontal = 14.dp, vertical = 10.dp) @@ -211,7 +209,7 @@ class GeohashPickerActivity : OrientationAwareActivity() { Text( text = if (currentGeohash.isNotEmpty()) "#${currentGeohash}" else "select location", fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier @@ -277,7 +275,7 @@ class GeohashPickerActivity : OrientationAwareActivity() { Text( text = stringResource(R.string.select), fontSize = (BASE_FONT_SIZE - 2).sp, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 9d949e54..8a2e31d8 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -269,29 +269,27 @@ 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 = 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 - ) - } 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}") } @@ -438,10 +436,8 @@ class GeohashViewModel( 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 diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 91555b38..7302379b 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -1,27 +1,55 @@ package com.bitchat.android.ui + +import com.bitchat.android.ui.theme.BitchatFontFamily // [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.expandVertically +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue @@ -36,8 +64,10 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.text.withStyle import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.BitchatPalette +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.features.voice.normalizeAmplitudeSample import com.bitchat.android.features.voice.AudioWaveformExtractor import com.bitchat.android.ui.media.RealtimeScrollingWaveform @@ -53,41 +83,28 @@ import com.bitchat.android.ui.media.FilePickerButton * VisualTransformation that styles slash commands with background and color * while preserving cursor positioning and click handling */ -class SlashCommandVisualTransformation : VisualTransformation { +class SlashCommandVisualTransformation( + private val commandColor: Color, + private val commandBackground: Color, +) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { val slashCommandRegex = Regex("(/\\w+)(?=\\s|$)") - val annotatedString = buildAnnotatedString { - var lastIndex = 0 - - slashCommandRegex.findAll(text.text).forEach { match -> - // Add text before the match - if (match.range.first > lastIndex) { - append(text.text.substring(lastIndex, match.range.first)) - } - - // Add the styled slash command - withStyle( - style = SpanStyle( - color = Color(0xFF00FF7F), // Bright green - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - background = Color(0xFF2D2D2D) // Dark gray background - ) - ) { - append(match.value) - } - - lastIndex = match.range.last + 1 - } - - // Add remaining text - if (lastIndex < text.text.length) { - append(text.text.substring(lastIndex)) - } + val builder = AnnotatedString.Builder(text) + slashCommandRegex.findAll(text.text).forEach { match -> + builder.addStyle( + style = SpanStyle( + color = commandColor, + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + background = commandBackground + ), + start = match.range.first, + end = match.range.last + 1, + ) } return TransformedText( - text = annotatedString, + text = builder.toAnnotatedString(), offsetMapping = OffsetMapping.Identity ) } @@ -97,40 +114,56 @@ class SlashCommandVisualTransformation : VisualTransformation { * VisualTransformation that styles mentions with background and color * while preserving cursor positioning and click handling */ -class MentionVisualTransformation : VisualTransformation { +class MentionVisualTransformation( + private val mentionPeerIdentities: Map, + private val palette: BitchatPalette, +) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { - val mentionRegex = Regex("@([a-zA-Z0-9_]+)") - val annotatedString = buildAnnotatedString { - var lastIndex = 0 - - mentionRegex.findAll(text.text).forEach { match -> - // Add text before the match - if (match.range.first > lastIndex) { - append(text.text.substring(lastIndex, match.range.first)) - } - - // Add the styled mention - withStyle( + val builder = AnnotatedString.Builder(text) + + MENTION_TOKEN_REGEX.findAll(text.text).forEach { match -> + val start = match.range.first + val end = match.range.last + 1 + val suffixOffset = match.value.lastIndexOf('#').takeIf { it > 0 } + val suffixStart = suffixOffset?.let(start::plus) ?: end + val mentionColor = colorForMention( + mention = match.value, + mentionPeerIdentities = mentionPeerIdentities, + palette = palette, + ) + + // Keep the whole token on one continuous color-derived chip. + builder.addStyle( + style = SpanStyle( + background = mentionColor.copy(alpha = MENTION_CHIP_ALPHA), + ), + start = start, + end = end, + ) + builder.addStyle( + style = SpanStyle( + color = mentionColor, + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + ), + start = start, + end = suffixStart, + ) + if (suffixStart < end) { + builder.addStyle( style = SpanStyle( - color = Color(0xFFFF9500), // Orange - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold - ) - ) { - append(match.value) - } - - lastIndex = match.range.last + 1 - } - - // Add remaining text - if (lastIndex < text.text.length) { - append(text.text.substring(lastIndex)) + color = mentionColor.copy(alpha = SUFFIX_ALPHA), + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + ), + start = suffixStart, + end = end, + ) } } return TransformedText( - text = annotatedString, + text = builder.toAnnotatedString(), offsetMapping = OffsetMapping.Identity ) } @@ -159,6 +192,107 @@ class CombinedVisualTransformation(private val transformations: List Unit +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + val accent = if (activeColor == Color.Unspecified) colorScheme.primary else activeColor + + val container by animateColorAsState( + // A tint rather than a fill. A solid accent disc next to the text you are typing was the + // loudest thing on the screen; at 20% it still reads as "armed" without competing. + targetValue = if (isActive) accent.copy(alpha = 0.20f) else palette.inputButton, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerButtonContainer" + ) + val tint by animateColorAsState( + targetValue = if (isActive) accent else colorScheme.onSurfaceVariant, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerButtonTint" + ) + // A small dip on press. Spring rather than tween so the release overshoots very slightly and + // the button feels physical instead of merely animated. + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.88f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "composerButtonScale" + ) + + Box( + modifier = modifier.size(ComposerButtonSize), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(ComposerButtonDisc) + .scale(scale) + .background(container, CircleShape) + .semantics { contentDescription?.let { this.contentDescription = it } }, + contentAlignment = Alignment.Center + ) { + content(tint) + } + } +} + @Composable fun MessageInput( value: TextFieldValue, @@ -171,192 +305,276 @@ fun MessageInput( currentChannel: String?, nickname: String, showMediaButtons: Boolean, + mentionPeerIdentities: Map = emptyMap(), modifier: Modifier = Modifier ) { + val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme val isFocused = remember { mutableStateOf(false) } - val hasText = value.text.isNotBlank() // Check if there's text for send button state - val keyboard = LocalSoftwareKeyboardController.current + val hasText = value.text.isNotBlank() val focusRequester = remember { FocusRequester() } var isRecording by remember { mutableStateOf(false) } var elapsedMs by remember { mutableStateOf(0L) } var amplitude by remember { mutableStateOf(0) } + // Recording is the one state worth shouting about, so it overrides focus. + val borderColor by animateColorAsState( + targetValue = when { + isRecording -> colorScheme.error.copy(alpha = 0.7f) + isFocused.value -> palette.inputOutlineFocused + else -> palette.inputOutline + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerBorder" + ) + // A barely-there lift on focus. Enough to register, not enough to look like a different + // component. Slightly translucent so the messages scrolling underneath stay faintly visible. + val containerColor by animateColorAsState( + targetValue = (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface) + .copy(alpha = ComposerFillAlpha), + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerContainer" + ) + Row( - modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + modifier = modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.Bottom ) { - // Text input with placeholder OR visualizer when recording - Box( - modifier = Modifier.weight(1f) - ) { - // Always keep the text field mounted to retain focus and avoid IME collapse - BasicTextField( - value = value, - onValueChange = onValueChange, - textStyle = MaterialTheme.typography.bodyMedium.copy( - color = colorScheme.primary, - fontFamily = FontFamily.Monospace - ), - cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { - if (hasText) onSend() // Only send if there's text - }), - visualTransformation = CombinedVisualTransformation( - listOf(SlashCommandVisualTransformation(), MentionVisualTransformation()) - ), - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - .onFocusChanged { focusState -> - isFocused.value = focusState.isFocused - } - ) - - // Show placeholder when there's no text and not recording - if (value.text.isEmpty() && !isRecording) { - Text( - text = stringResource(R.string.type_a_message_placeholder), - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace - ), - color = colorScheme.onSurface.copy(alpha = 0.5f), // Muted grey - modifier = Modifier.fillMaxWidth() + // MARK: - The pill. Field and action buttons are one visual object. + Row( + modifier = Modifier + .weight(1f) + .heightIn(min = ComposerMinHeight) + // Grow smoothly as the field wraps to more lines rather than jumping a line at + // a time. + .animateContentSize( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ) + .background(containerColor, ComposerShape) + .border(1.dp, borderColor, ComposerShape), + verticalAlignment = Alignment.Bottom + ) { + Box( + modifier = Modifier + .weight(1f) + .padding(start = 18.dp, end = 4.dp, top = 15.dp, bottom = 15.dp) + ) { + // Always keep the text field mounted to retain focus and avoid IME collapse + BasicTextField( + value = value, + onValueChange = onValueChange, + // Near-white, not terminal green: this is the one place in the app where the + // user is composing rather than reading, and green-on-black is tiring to + // type into. + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = colorScheme.onSurface, + fontFamily = BitchatFontFamily + ), + cursorBrush = SolidColor( + if (isRecording) Color.Transparent else colorScheme.onSurface + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { + if (hasText) onSend() + }), + // Cap the growth so a pasted wall of text cannot swallow the message list. + maxLines = 6, + visualTransformation = remember( + palette, + colorScheme.primary, + mentionPeerIdentities, + ) { + CombinedVisualTransformation( + listOf( + SlashCommandVisualTransformation( + commandColor = colorScheme.primary, + commandBackground = colorScheme.primary.copy(alpha = 0.14f), + ), + MentionVisualTransformation( + mentionPeerIdentities = mentionPeerIdentities, + palette = palette, + ), + ) + ) + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + isFocused.value = focusState.isFocused + } ) - } - // Overlay the real-time scrolling waveform while recording - if (isRecording) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - RealtimeScrollingWaveform( - modifier = Modifier.weight(1f).height(32.dp), - amplitudeNorm = normalizeAmplitudeSample(amplitude) - ) - Spacer(Modifier.width(20.dp)) - val secs = (elapsedMs / 1000).toInt() - val mm = secs / 60 - val ss = secs % 60 - val maxSecs = 10 // 10 second max recording time - val maxMm = maxSecs / 60 - val maxSs = maxSecs % 60 + // Placeholder fades rather than blinking, which matters because it reappears + // every time a message is sent. + val placeholderAlpha by animateFloatAsState( + targetValue = if (value.text.isEmpty() && !isRecording) 1f else 0f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "placeholderAlpha" + ) + if (placeholderAlpha > 0f) { Text( - text = String.format("%02d:%02d / %02d:%02d", mm, ss, maxMm, maxSs), - fontFamily = FontFamily.Monospace, - color = colorScheme.primary, - fontSize = (BASE_FONT_SIZE - 4).sp + text = stringResource(R.string.type_a_message_placeholder), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily + ), + color = palette.textTertiary, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .alpha(placeholderAlpha) ) } - } - } - - Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing - - // Voice and image buttons when no text (only visible in Mesh chat) - if (value.text.isEmpty() && showMediaButtons) { - // Hold-to-record microphone - val bg = if (colorScheme.background == Color.Black) Color(0xFF00FF00).copy(alpha = 0.75f) else Color(0xFF008000).copy(alpha = 0.75f) - // Ensure latest values are used when finishing recording + // Recording visualiser, layered over the (empty) field. + val waveformAlpha by animateFloatAsState( + targetValue = if (isRecording) 1f else 0f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "waveformAlpha" + ) + if (isRecording) { + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(waveformAlpha), + verticalAlignment = Alignment.CenterVertically + ) { + RealtimeScrollingWaveform( + modifier = Modifier.weight(1f).height(22.dp), + amplitudeNorm = normalizeAmplitudeSample(amplitude) + ) + Spacer(Modifier.width(12.dp)) + val secs = (elapsedMs / 1000).toInt() + val maxSecs = 10 // 10 second max recording time + Text( + text = String.format( + "%02d:%02d / %02d:%02d", + secs / 60, secs % 60, maxSecs / 60, maxSecs % 60 + ), + fontFamily = BitchatFontFamily, + color = colorScheme.error, + fontSize = (BASE_FONT_SIZE - 4).sp + ) + } + } + } + + // MARK: - Action cluster, inside the pill. + // + // Swaps between the auxiliary buttons and send. AnimatedContent cross-fades and + // scales between the two, and SizeTransform animates the width change, so typing the + // first character morphs camera+mic into send instead of snapping. val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer) val latestChannel = rememberUpdatedState(currentChannel) val latestOnSendVoiceNote = rememberUpdatedState(onSendVoiceNote) - // Image button (image picker) - hide during recording - if (!isRecording) { - // Revert to original separate buttons: round File button (left) and the old Image plus button (right) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - // DISABLE FILE PICKER - //FilePickerButton( - // onFileReady = { path -> - // onSendFileNote(latestSelectedPeer.value, latestChannel.value, path) - // } - //) - ImagePickerButton( - onImageReady = { outPath -> - onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath) - } - ) - } - } - - Spacer(Modifier.width(1.dp)) - - VoiceRecordButton( - backgroundColor = bg, - onStart = { - isRecording = true - elapsedMs = 0L - // Keep existing focus to avoid IME collapse, but do not force-show keyboard - if (isFocused.value) { - try { focusRequester.requestFocus() } catch (_: Exception) {} + AnimatedContent( + targetState = hasText, + transitionSpec = { + ( + fadeIn(tween(BitchatMotion.STANDARD_MS)) + + scaleIn( + initialScale = 0.7f, + animationSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ) + ) + ).togetherWith( + fadeOut(tween(BitchatMotion.QUICK_MS)) + + scaleOut( + targetScale = 0.7f, + animationSpec = tween( + BitchatMotion.QUICK_MS, + easing = FastOutSlowInEasing + ) + ) + ) using SizeTransform(clip = false) { _, _ -> + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) } }, - onAmplitude = { amp, ms -> - amplitude = amp - elapsedMs = ms - }, - onFinish = { path -> - isRecording = false - // Extract and cache waveform from the actual audio file to match receiver rendering - AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr -> - if (arr != null) { - try { com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr) } catch (_: Exception) {} - } - } - // BLE path (private or public) — use latest values to avoid stale captures - latestOnSendVoiceNote.value( - latestSelectedPeer.value, - latestChannel.value, - path + modifier = Modifier.padding(end = 6.dp, bottom = 6.dp), + label = "composerActions" + ) { showSend -> + if (showSend) { + SendButton( + isAccented = latestSelectedPeer.value != null || latestChannel.value != null, + onSend = onSend ) - } - ) - - } else { - // Send button with enabled/disabled state - IconButton( - onClick = { if (hasText) onSend() }, // Only execute if there's text - enabled = hasText, // Enable only when there's text - modifier = Modifier.size(32.dp) - ) { - // Update send button to match input field colors - Box( - modifier = Modifier - .size(30.dp) - .background( - color = if (!hasText) { - // Disabled state - muted grey - colorScheme.onSurface.copy(alpha = 0.3f) - } else if (selectedPrivatePeer != null || currentChannel != null) { - // Orange for both private messages and channels when enabled - Color(0xFFFF9500).copy(alpha = 0.75f) - } else if (colorScheme.background == Color.Black) { - Color(0xFF00FF00).copy(alpha = 0.75f) // Bright green for dark theme - } else { - Color(0xFF008000).copy(alpha = 0.75f) // Dark green for light theme - }, - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Filled.ArrowUpward, - contentDescription = stringResource(id = R.string.send_message), - modifier = Modifier.size(20.dp), - tint = if (!hasText) { - // Disabled state - muted grey icon - colorScheme.onSurface.copy(alpha = 0.5f) - } else if (selectedPrivatePeer != null || currentChannel != null) { - // Black arrow on orange for both private and channel modes - Color.Black - } else if (colorScheme.background == Color.Black) { - Color.Black // Black arrow on bright green in dark theme + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + if (showMediaButtons) { + // The camera steps aside while recording so the microphone is the + // only thing that can be released. + AnimatedVisibility( + visible = !isRecording, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandHorizontally( + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkHorizontally( + tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ) + ) { + ImagePickerButton( + onImageReady = { outPath -> + onSendImageNote( + latestSelectedPeer.value, + latestChannel.value, + outPath + ) + } + ) + } + + VoiceRecordButton( + isRecording = isRecording, + onStart = { + isRecording = true + elapsedMs = 0L + // Keep existing focus to avoid IME collapse, but do not + // force-show the keyboard. + if (isFocused.value) { + try { focusRequester.requestFocus() } catch (_: Exception) {} + } + }, + onAmplitude = { amp, ms -> + amplitude = amp + elapsedMs = ms + }, + onFinish = { path -> + isRecording = false + // Extract and cache the waveform from the actual audio file + // so it matches the receiver's rendering. + AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr -> + if (arr != null) { + try { + com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr) + } catch (_: Exception) {} + } + } + latestOnSendVoiceNote.value( + latestSelectedPeer.value, + latestChannel.value, + path + ) + }, + // Any capture that ends without a file must clear the recording + // state here too, otherwise the pill stays red with a live + // waveform over an empty field. + onCancel = { + isRecording = false + amplitude = 0 + elapsedMs = 0L + } + ) } else { - Color.White // White arrow on dark green in light theme + // No media in this context, so keep an inert send button rather than + // leaving a hole where the action cluster should be. + SendButton(isAccented = false, onSend = {}, enabled = false) } - ) + } } } } @@ -365,6 +583,42 @@ fun MessageInput( // Auto-stop handled inside VoiceRecordButton } +/** + * Send affordance. Only rendered when there is something to send, so its mere presence is the + * signal; it does not need to shout in the terminal's full-brightness green as well. + */ +@Composable +private fun SendButton( + isAccented: Boolean, + onSend: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + ComposerActionSurface( + isActive = enabled, + isPressed = isPressed, + // Private chats and channels keep their orange identity, disc and glyph together. + activeColor = if (isAccented) palette.accentOrange else colorScheme.primary, + modifier = modifier.clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled + ) { onSend() } + ) { tint -> + Icon( + imageVector = Icons.Filled.ArrowUpward, + contentDescription = stringResource(id = R.string.send_message), + modifier = Modifier.size(ComposerIconSize), + tint = tint + ) + } +} + @Composable fun CommandSuggestionsBox( suggestions: List, @@ -377,8 +631,8 @@ fun CommandSuggestionsBox( modifier = modifier .verticalScroll(rememberScrollState()) .background(colorScheme.surface) - .border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp)) - .padding(vertical = 8.dp) + .border(1.dp, colorScheme.outlineVariant, RoundedCornerShape(8.dp)) + .padding(vertical = 6.dp) ) { suggestions.forEach { suggestion: CommandSuggestion -> CommandSuggestionItem( @@ -395,13 +649,14 @@ fun CommandSuggestionItem( onClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current Row( modifier = Modifier .fillMaxWidth() .clickable { onClick() } - .padding(horizontal = 12.dp, vertical = 3.dp) - .background(Color.Gray.copy(alpha = 0.1f)), + // Roomier rows: at 3.dp vertical these were below a comfortable tap height. + .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -415,11 +670,11 @@ fun CommandSuggestionItem( Text( text = allCommands.joinToString(", "), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium ), color = colorScheme.primary, - fontSize = (BASE_FONT_SIZE - 4).sp + fontSize = (BASE_FONT_SIZE - 2).sp ) // Show syntax if any @@ -427,10 +682,10 @@ fun CommandSuggestionItem( Text( text = syntax, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), - color = colorScheme.onSurface.copy(alpha = 0.8f), - fontSize = (BASE_FONT_SIZE - 5).sp + color = colorScheme.onSurfaceVariant, + fontSize = (BASE_FONT_SIZE - 4).sp ) } @@ -438,34 +693,64 @@ fun CommandSuggestionItem( Text( text = suggestion.description, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 5).sp, + color = palette.textTertiary, + fontSize = (BASE_FONT_SIZE - 4).sp, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun MentionSuggestionsBox( suggestions: List, + mentionPeerIdentities: Map, onSuggestionClick: (String) -> Unit, modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - - Column( + val palette = LocalBitchatPalette.current + + LazyColumn( modifier = modifier + .heightIn(max = MentionSuggestionsMaxHeight) + .animateContentSize( + animationSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ) + ) + .clip(MentionSuggestionsShape) .background(colorScheme.surface) - .border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp)) - .padding(vertical = 8.dp) + .border(1.dp, colorScheme.outlineVariant, MentionSuggestionsShape), + contentPadding = PaddingValues(vertical = MentionSuggestionsVerticalPadding) ) { - suggestions.forEach { suggestion: String -> + items( + items = suggestions, + key = { suggestion -> suggestion.lowercase() } + ) { suggestion -> MentionSuggestionItem( suggestion = suggestion, - onClick = { onSuggestionClick(suggestion) } + userColor = colorForMention( + mention = suggestion, + mentionPeerIdentities = mentionPeerIdentities, + palette = palette, + ), + onClick = { onSuggestionClick(suggestion) }, + modifier = Modifier.animateItem( + fadeInSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ), + placementSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ), + fadeOutSpec = tween(BitchatMotion.QUICK_MS) + ) ) } } @@ -474,37 +759,76 @@ fun MentionSuggestionsBox( @Composable fun MentionSuggestionItem( suggestion: String, - onClick: () -> Unit + userColor: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme - + val palette = LocalBitchatPalette.current + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val pressedBackground by animateColorAsState( + targetValue = if (isPressed) { + userColor.copy(alpha = 0.10f) + } else { + Color.Transparent + }, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + label = "mentionSuggestionPressedBackground" + ) + val pressedScale by animateFloatAsState( + targetValue = if (isPressed) 0.985f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "mentionSuggestionPressedScale" + ) + Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .clickable { onClick() } - .padding(horizontal = 12.dp, vertical = 3.dp) - .background(Color.Gray.copy(alpha = 0.1f)), + .height(MentionSuggestionRowHeight) + .scale(pressedScale) + .background(pressedBackground) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick + ) + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource(R.string.mention_suggestion_at, suggestion), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.SemiBold ), - color = Color(0xFFFF9500), // Orange like mentions - fontSize = (BASE_FONT_SIZE - 4).sp + color = userColor, + fontSize = (BASE_FONT_SIZE - 2).sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) - - Spacer(modifier = Modifier.weight(1f)) - + + Spacer(modifier = Modifier.width(12.dp)) + Text( text = stringResource(R.string.mention), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 5).sp + color = palette.textTertiary, + fontSize = (BASE_FONT_SIZE - 4).sp, + maxLines = 1 ) } } + +/** Mention autocomplete stays compact even in crowded channels. */ +internal const val MaxVisibleMentionSuggestions = 5 +private val MentionSuggestionRowHeight = 48.dp +private val MentionSuggestionsVerticalPadding = 6.dp +private val MentionSuggestionsMaxHeight = + (48 * MaxVisibleMentionSuggestions + 12).dp +private val MentionSuggestionsShape = RoundedCornerShape(8.dp) diff --git a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt index 4896e345..57710171 100644 --- a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt +++ b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Link import android.content.Intent import android.net.Uri import androidx.compose.foundation.background @@ -7,8 +9,6 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Link import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -17,11 +17,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BASE_FONT_SIZE import java.net.URL @@ -43,11 +43,8 @@ fun LinkPreviewPill( ) { val context = LocalContext.current val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - - // iOS-style colors - val textColor = if (isDark) Color.Green else Color(red = 0f, green = 0.5f, blue = 0f) - val backgroundColor = if (isDark) Color.Gray.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.08f) + val textColor = colorScheme.secondary + val backgroundColor = colorScheme.secondaryContainer.copy(alpha = 0.35f) val borderColor = textColor.copy(alpha = 0.3f) // Parse URL for host extraction @@ -87,7 +84,7 @@ fun LinkPreviewPill( Surface( modifier = Modifier.size(60.dp), shape = RoundedCornerShape(8.dp), - color = Color.Blue.copy(alpha = 0.1f) + color = colorScheme.secondaryContainer ) { Box( modifier = Modifier.fillMaxSize(), @@ -97,7 +94,7 @@ fun LinkPreviewPill( imageVector = Icons.Outlined.Link, contentDescription = stringResource(com.bitchat.android.R.string.cd_link), modifier = Modifier.size(24.dp), - tint = Color.Blue + tint = colorScheme.secondary ) } } @@ -110,7 +107,7 @@ fun LinkPreviewPill( // Title - matches iOS styling Text( text = displayTitle, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = BASE_FONT_SIZE.sp, fontWeight = FontWeight.SemiBold, color = textColor, @@ -121,7 +118,7 @@ fun LinkPreviewPill( // Host - matches iOS styling Text( text = displayHost, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = textColor.copy(alpha = 0.6f), maxLines = 1, diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 2480ead0..19156485 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -1,53 +1,105 @@ package com.bitchat.android.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.Hub +import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.PinDrop +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.outlined.BookmarkBorder +import androidx.compose.material.icons.outlined.Public import android.content.Intent import android.net.Uri import android.provider.Settings +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Map -import androidx.compose.material.icons.filled.PinDrop -import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import com.bitchat.android.geohash.ChannelID -import kotlinx.coroutines.launch -import com.bitchat.android.geohash.GeohashChannel -import com.bitchat.android.geohash.GeohashChannelLevel -import com.bitchat.android.geohash.LocationChannelManager -import com.bitchat.android.geohash.GeohashBookmarksStore +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.nostr.NearbyNotesController import com.bitchat.android.nostr.geohashesForSampling import com.bitchat.android.ui.theme.BASE_FONT_SIZE -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet -import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashBookmarksStore +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.TorMode +import com.bitchat.android.net.TorPreferenceManager +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.wifiaware.WifiAwareController +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull /** - * Location Channels Sheet for selecting geohash-based location channels - * Direct port from iOS LocationChannelsSheet for 100% compatibility + * Leading column width matching settings rows: 22.dp glyph + 16.dp gutter before title text. + * Selection dots and row icons sit in this column so every option lines up with About settings. + */ +private val ChannelLeadingSlot = SheetRowLeadingSlot +private val ChannelLeadingGutter = SheetRowLeadingGutter +private val ChannelRowHorizontal = SheetRowHorizontal +private val ChannelRowVertical = SheetRowVertical +private val ChannelDividerInset = SheetRowDividerInset +/** 2× the previous 6.dp selected indicator; sits centered in [ChannelLeadingSlot]. */ +private val ChannelSelectedDot = SheetRowSelectedDot + +/** + * Pause between applying a channel selection and dismissing the sheet. + * + * Just enough for the active dot to land on the chosen row, so the tap is acknowledged rather than + * answered by the sheet simply disappearing. + */ +private const val SelectionConfirmDelayMs = 180L + +/** + * Location Channels sheet: grouped card rows matching About → Settings. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -61,7 +113,6 @@ fun LocationChannelsSheet( val locationManager = LocationChannelManager.getInstance(context) val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) } - // Observe location manager state val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() val notesRevealed by NearbyNotesController.shared.revealed.collectAsStateWithLifecycle() @@ -71,25 +122,17 @@ fun LocationChannelsSheet( val systemLocationEnabled by locationManager.systemLocationEnabled.collectAsStateWithLifecycle() val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle() - // Observe bookmarks state val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle() val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle() - - // Observe reactive participant counts val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle() + val wifiAwareEnabled by WifiAwareController.enabled.collectAsStateWithLifecycle() - // UI state var customGeohash by remember { mutableStateOf("") } var customError by remember { mutableStateOf(null) } - var isInputFocused by remember { mutableStateOf(false) } - // Bottom sheet state - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val coroutineScope = rememberCoroutineScope() - // Scroll state for LazyColumn with animated top bar val listState = rememberLazyListState() val isScrolled by remember { derivedStateOf { @@ -97,7 +140,8 @@ fun LocationChannelsSheet( } } val topBarAlpha by animateFloatAsState( - targetValue = if (isScrolled) 0.95f else 0f, + targetValue = if (isScrolled) 0.98f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), label = "topBarAlpha" ) @@ -113,11 +157,20 @@ fun LocationChannelsSheet( } } - // iOS system colors (matches iOS exactly) val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green - val standardBlue = Color(0xFF007AFF) // iOS blue + val palette = LocalBitchatPalette.current + val standardGreen = colorScheme.primary + val standardBlue = colorScheme.secondary + + val nearbyChannels = remember(availableChannels) { + availableChannels.filter { it.level != GeohashChannelLevel.BUILDING } + } + val selectedChannelOutsideNearby = remember(selectedChannel, nearbyChannels) { + selectedLocationChannelOutsideNearby(selectedChannel, nearbyChannels) + } + val showNearbyLoading = nearbyChannels.isEmpty() && + permissionState == LocationChannelManager.PermissionState.AUTHORIZED && + locationServicesEnabled if (isPresented) { BitchatBottomSheet( @@ -125,389 +178,413 @@ fun LocationChannelsSheet( onDismissRequest = onDismiss, sheetState = sheetState, ) { + // Selection is applied immediately so the active dot snaps to the new row, then the + // sheet slides away after a beat. Long enough to register the change, short enough + // that it never feels like waiting. + val animatedDismiss = LocalSheetDismiss.current + val confirmSelectionThenDismiss: () -> Unit = { + coroutineScope.launch { + delay(SelectionConfirmDelayMs) + animatedDismiss?.invoke() ?: onDismiss() + } + } + Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 64.dp, bottom = 16.dp) + // Edge-to-edge + adjustResize reports the keyboard as WindowInsets.ime — + // without consuming it here the list stays full-height and the teleport + // field can't scroll above the keyboard. + modifier = Modifier + .fillMaxSize() + .imePadding(), + contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) ) { - // Header Section - item(key = "header") { - Text( - text = stringResource(R.string.location_channels_desc), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier - .padding(horizontal = 24.dp) + // Mesh section: icon + title header, offline subtitle, then selection card + item(key = "mesh_card") { + Column { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_range, + title = stringResource(R.string.mesh_title), + subtitle = stringResource(R.string.mesh_section_subtitle), + modifier = Modifier.padding(top = 8.dp) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + ChannelOptionRow( + title = meshTitleWithCount(viewModel), + subtitle = stringResource( + if (wifiAwareEnabled) { + R.string.location_bluetooth_wifi_subtitle + } else { + R.string.location_bluetooth_subtitle + }, + meshRangeString() + ), + isSelected = selectedChannel is ChannelID.Mesh, + participantCount = meshCount(viewModel), + titleColor = standardBlue, + titleBold = meshCount(viewModel) > 0, + onClick = { + locationManager.select(ChannelID.Mesh) + onDismiss() + } + ) + } + } + } + + item(key = "location_channels_header") { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_globe, + title = stringResource(R.string.location_channels_heading), + subtitle = stringResource(R.string.location_channels_desc), + modifier = Modifier.padding(top = 20.dp) ) } - // Permission controls if services enabled - if (locationServicesEnabled) { + selectedChannelOutsideNearby?.let { channel -> + item(key = "teleported_card") { + val coverage = coverageString(channel.geohash.length) + val name = bookmarkNames[channel.geohash] + val subtitle = "#${channel.geohash} • $coverage" + + (name?.let { " • ${formattedNamePrefix(channel.level)}$it" } ?: "") + val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 + val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) + + Column { + AboutSectionLabel(text = stringResource(R.string.cd_teleported)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + ChannelOptionRow( + title = geohashHashTitleWithCount( + channel.geohash, + participantCount + ), + subtitle = subtitle, + isSelected = true, + participantCount = participantCount, + titleColor = standardGreen, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = isBookmarked, + onClick = { + bookmarksStore.toggle(channel.geohash) + } + ) + }, + onClick = { + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } + ) + } + } + } + } + + if (bookmarks.isNotEmpty()) { + item(key = "bookmarks_card") { + Column { + AboutSectionLabel(text = stringResource(R.string.bookmarked)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column { + bookmarks.forEachIndexed { index, gh -> + if (index > 0) SheetCardDivider() + val level = levelForLength(gh.length) + val channel = GeohashChannel(level = level, geohash = gh) + val coverage = coverageString(gh.length) + val name = bookmarkNames[gh] + val subtitle = "#$gh • $coverage" + + (name?.let { " • ${formattedNamePrefix(level)}$it" } ?: "") + val participantCount = geohashParticipantCounts[gh] ?: 0 + + ChannelOptionRow( + title = geohashHashTitleWithCount(gh, participantCount), + subtitle = subtitle, + isSelected = isChannelSelected(channel, selectedChannel), + participantCount = participantCount, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = true, + onClick = { bookmarksStore.toggle(gh) } + ) + }, + onClick = { + val inRegional = + availableChannels.any { it.geohash == gh } + locationManager.selectManual( + channel = channel, + teleported = !appLocationEnabled || + availableChannels.isEmpty() || + !inRegional + ) + onDismiss() + } + ) + LaunchedEffect(gh) { + bookmarksStore.resolveNameIfNeeded(gh) + } + } + } + } + } + } + } + + if (locationServicesEnabled && + permissionState == LocationChannelManager.PermissionState.DENIED + ) { item(key = "permissions") { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 8.dp), + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - when (permissionState) { - LocationChannelManager.PermissionState.DENIED -> { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = stringResource(R.string.location_permission_denied), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = Color.Red.copy(alpha = 0.8f) - ) - TextButton( - onClick = { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - } - context.startActivity(intent) - } - ) { - Text( - text = stringResource(R.string.open_settings), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace + Text( + text = stringResource(R.string.location_permission_denied), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.error + ) + TextButton( + onClick = { + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts( + "package", + context.packageName, + null ) } - } - } - LocationChannelManager.PermissionState.AUTHORIZED -> { - Text( - text = stringResource(R.string.location_permission_granted), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = standardGreen - ) - } - } - } - } - } - - // Mesh option first - item(key = "mesh") { - ChannelRow( - title = meshTitleWithCount(viewModel), - subtitle = stringResource(R.string.location_bluetooth_subtitle, bluetoothRangeString()), - isSelected = selectedChannel is ChannelID.Mesh, - titleColor = standardBlue, - titleBold = meshCount(viewModel) > 0, - trailingContent = null, - onClick = { - locationManager.select(ChannelID.Mesh) - onDismiss() - } - ) - } - - // Nearby options (only show if location services are enabled) - // CRITICAL: Filter out .building level (precision 8) - iOS pattern - // iOS: let nearby = manager.availableChannels.filter { $0.level != .building } - if (availableChannels.isNotEmpty() && locationServicesEnabled) { - val nearbyChannels = availableChannels.filter { it.level != GeohashChannelLevel.BUILDING } - items(nearbyChannels) { channel -> - val coverage = coverageString(channel.geohash.length) - val nameBase = locationNames[channel.level] - val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } - val subtitlePrefix = "#${channel.geohash} • $coverage" - val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 - val highlight = participantCount > 0 - val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) - - ChannelRow( - title = geohashTitleWithCount(channel, participantCount), - subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""), - isSelected = isChannelSelected(channel, selectedChannel), - titleColor = standardGreen, - titleBold = highlight, - trailingContent = { - IconButton(onClick = { bookmarksStore.toggle(channel.geohash) }) { - Icon( - imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, - contentDescription = if (isBookmarked) stringResource(R.string.cd_remove_bookmark) else stringResource(R.string.cd_add_bookmark), - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), - ) - } - }, - onClick = { - if (locationManager.selectNearby(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 = { - val inRegional = availableChannels.any { it.geohash == gh } - locationManager.selectManual( - channel = channel, - teleported = !appLocationEnabled || - availableChannels.isEmpty() || - !inRegional - ) - onDismiss() - } - ) - LaunchedEffect(gh) { bookmarksStore.resolveNameIfNeeded(gh) } - } - } - - // Custom geohash teleport (iOS-style inline form) - item(key = "custom_geohash") { - Surface( - color = Color.Transparent, - shape = MaterialTheme.shapes.medium, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.hash_symbol), - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - - BasicTextField( - value = customGeohash, - onValueChange = { newValue -> - // iOS-style geohash validation (base32 characters only) - val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() - val filtered = newValue - .lowercase() - .replace("#", "") - .filter { it in allowed } - .take(12) - - customGeohash = filtered - customError = null + context.startActivity(intent) }, - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface - ), - modifier = Modifier - .weight(1f) - .onFocusChanged { focusState -> - isInputFocused = focusState.isFocused - if (focusState.isFocused) { - coroutineScope.launch { - sheetState.expand() - // Scroll to bottom to show input and remove button - listState.animateScrollToItem( - index = listState.layoutInfo.totalItemsCount - 1 - ) - } - } - }, - singleLine = true, - decorationBox = { innerTextField -> - if (customGeohash.isEmpty()) { - Text( - text = stringResource(R.string.geohash_placeholder), - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) - ) - } - innerTextField() - } - ) - - val normalized = customGeohash.trim().lowercase().replace("#", "") - - // Map picker button - IconButton(onClick = { - val initial = when { - normalized.isNotBlank() -> normalized - selectedChannel is ChannelID.Location -> (selectedChannel as ChannelID.Location).channel.geohash - else -> "" - } - val intent = Intent(context, GeohashPickerActivity::class.java).apply { - putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial) - } - mapPickerLauncher.launch(intent) - }) { - Icon( - imageVector = Icons.Filled.Map, - contentDescription = stringResource(R.string.cd_open_map), - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) - ) - } - - val isValid = validateGeohash(normalized) - - // iOS-style teleport button - Button( - onClick = { - if (isValid) { - val level = levelForLength(normalized.length) - val channel = GeohashChannel(level = level, geohash = normalized) - locationManager.selectManual(channel) - onDismiss() - } else { - customError = context.getString(R.string.invalid_geohash) - } - }, - enabled = isValid, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), - contentColor = MaterialTheme.colorScheme.onSurface - ) + contentPadding = PaddingValues(0.dp) ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.teleport), - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace - ) - Icon( - imageVector = Icons.Filled.PinDrop, - contentDescription = stringResource(R.string.cd_teleport), - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurface - ) - } + Text( + text = stringResource(R.string.open_settings), + fontSize = 12.sp, + fontFamily = BitchatFontFamily + ) } } } } - // Error message for custom geohash - if (customError != null) { - item(key = "geohash_error") { - Text( - text = customError!!, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = Color.Red, + // Nearby location channels and the control for teleporting somewhere new. + item(key = "channels_card") { + Column { + AboutSectionLabel( + text = stringResource(R.string.location_channels_nearby) + ) + Surface( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) - ) - } - } - - // Location services toggle button - item(key = "location_toggle") { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 8.dp) - ) { - Button( - onClick = { - if (appLocationEnabled) { - locationManager.disableLocationServices() - } else { - locationManager.enableLocationServices() - } - }, - colors = ButtonDefaults.buttonColors( - containerColor = if (appLocationEnabled) { - Color.Red.copy(alpha = 0.08f) - } else { - standardGreen.copy(alpha = 0.12f) - }, - contentColor = if (appLocationEnabled) { - Color(0xFFBF1A1A) - } else { - standardGreen - } - ), - modifier = Modifier.fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape ) { + Column { + if (locationServicesEnabled) { + if (nearbyChannels.isNotEmpty()) { + nearbyChannels.forEachIndexed { index, channel -> + if (index > 0) SheetCardDivider() + val coverage = coverageString(channel.geohash.length) + val nameBase = locationNames[channel.level] + val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } + val subtitlePrefix = "#${channel.geohash} • $coverage" + val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 + val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) + + ChannelOptionRow( + title = geohashTitleWithCount(channel, participantCount), + subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""), + isSelected = isChannelSelected(channel, selectedChannel), + participantCount = participantCount, + titleColor = standardGreen, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = isBookmarked, + onClick = { bookmarksStore.toggle(channel.geohash) } + ) + }, + onClick = { + if (locationManager.selectNearby(channel)) { + onDismiss() + } + } + ) + } + SheetCardDivider() + } else if (showNearbyLoading) { + ChannelLoadingRow() + SheetCardDivider() + } + } + + CustomGeohashRow( + customGeohash = customGeohash, + onGeohashChange = { value -> + val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() + customGeohash = value + .lowercase() + .replace("#", "") + .filter { it in allowed } + .take(12) + customError = null + }, + onFocusGained = { + coroutineScope.launch { sheetState.expand() } + }, + onOpenMap = { + val normalized = customGeohash.trim().lowercase().replace("#", "") + val initial = when { + normalized.isNotBlank() -> normalized + selectedChannel is ChannelID.Location -> + (selectedChannel as ChannelID.Location).channel.geohash + else -> "" + } + val intent = Intent(context, GeohashPickerActivity::class.java).apply { + putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial) + } + mapPickerLauncher.launch(intent) + }, + onTeleport = { + val normalized = customGeohash.trim().lowercase().replace("#", "") + if (validateGeohash(normalized)) { + val level = levelForLength(normalized.length) + val channel = GeohashChannel(level = level, geohash = normalized) + locationManager.selectManual(channel) + onDismiss() + } else { + customError = context.getString(R.string.invalid_geohash) + } + } + ) + } + } + + AnimatedVisibility( + visible = customError != null, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandVertically( + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkVertically( + tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ) + ) { + // Held across the exit animation: by the time it plays, the error + // itself has already been cleared. + val shownError = remember(customError) { customError ?: "" } Text( - text = if (appLocationEnabled) stringResource(R.string.disable_location_services) else stringResource(R.string.enable_location_services), + text = shownError, fontSize = 12.sp, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily, + color = colorScheme.error, + modifier = Modifier.padding( + start = AboutHorizontalPadding + ChannelRowHorizontal, + top = 8.dp + ) ) } } } + + item(key = "tor_routing") { + val torProvider = remember { ArtiTorManager.getInstance() } + val torAvailable = remember { torProvider.isTorAvailable() } + var torMode by remember { mutableStateOf(TorPreferenceManager.get(context)) } + + Column { + AboutSectionLabel(text = stringResource(R.string.about_network)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + ChannelSettingsToggleRow( + icon = Icons.Filled.Security, + title = stringResource(R.string.location_tor_routing_title), + subtitle = stringResource(R.string.location_tor_routing_desc), + checked = torMode == TorMode.ON, + enabled = torAvailable, + statusIndicator = { + BitchatBadge(text = stringResource(R.string.badge_recommended)) + }, + onCheckedChange = { enabled -> + if (torAvailable) { + torMode = if (enabled) TorMode.ON else TorMode.OFF + TorPreferenceManager.set(context, torMode) + } + } + ) + } + if (!torAvailable) { + Text( + text = stringResource(R.string.tor_not_available_in_this_build), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary, + modifier = Modifier.padding( + start = AboutHorizontalPadding + ChannelRowHorizontal, + top = 8.dp + ) + ) + } + } + } + + item(key = "location_toggle") { + SheetDestructiveButton( + text = if (appLocationEnabled) { + stringResource(R.string.disable_location_services) + } else { + stringResource(R.string.enable_location_services) + }, + isDestructive = appLocationEnabled, + onClick = { + if (appLocationEnabled) { + locationManager.disableLocationServices() + } else { + locationManager.enableLocationServices() + } + }, + modifier = Modifier.padding( + start = AboutHorizontalPadding, + end = AboutHorizontalPadding, + top = 24.dp + ) + ) + } } - // TopBar (animated) BitchatSheetTopBar( onClose = onDismiss, modifier = modifier.align(Alignment.TopCenter), @@ -521,7 +598,6 @@ fun LocationChannelsSheet( } } - // Lifecycle management: when presented, manage location updates LifecycleResumeEffect(isPresented, appLocationEnabled, systemLocationEnabled) { if (isPresented) { val currentPermission = locationManager.syncPermissionState() @@ -529,6 +605,9 @@ fun LocationChannelsSheet( systemLocationEnabled && currentPermission == LocationChannelManager.PermissionState.AUTHORIZED ) { + // Retain the redesign branch's immediate refresh when the sheet resumes while + // honoring main's independent app/system privacy gates. + locationManager.enableLocationChannels() locationManager.beginLiveRefresh() } } @@ -561,104 +640,388 @@ fun LocationChannelsSheet( } } - // Ensure cleanup when the composable is destroyed (e.g. removed from parent composition) DisposableEffect(Unit) { - onDispose { - viewModel.endGeohashSampling() + onDispose { viewModel.endGeohashSampling() } + } +} + +/** + * Single channel option — settings-row geometry: 22.dp leading slot, title + subtitle, trailing. + * Selected state is a 12.dp green dot centered in the leading slot (icon-sized footprint). + */ +@Composable +private fun ChannelOptionRow( + title: String, + subtitle: String, + isSelected: Boolean, + participantCount: Int, + titleColor: Color? = null, + titleBold: Boolean = false, + leadingIcon: ImageVector? = null, + trailingContent: (@Composable (() -> Unit))? = null, + onClick: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val (baseTitle, countSuffix) = splitTitleAndCount(title) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center + ) { + when { + isSelected -> { + Box( + modifier = Modifier + .size(ChannelSelectedDot) + .background(colorScheme.primary, CircleShape) + ) + } + leadingIcon != null -> { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } + } + } + + Spacer(modifier = Modifier.width(ChannelLeadingGutter)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = baseTitle, + fontSize = 14.sp, + fontFamily = BitchatFontFamily, + fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium, + color = titleColor ?: colorScheme.onSurface + ) + countSuffix?.let { count -> + AnimatedCountLabel( + count = participantCount, + text = count, + fontSize = 11.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onSurfaceVariant + ) + } + } + Text( + text = subtitle, + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + lineHeight = 17.sp, + color = colorScheme.onSurfaceVariant + ) + } + + if (trailingContent != null) { + Spacer(modifier = Modifier.width(8.dp)) + trailingContent() } } } @Composable -private fun ChannelRow( - title: String, - subtitle: String, - isSelected: Boolean, - titleColor: Color? = null, - titleBold: Boolean = false, - trailingContent: (@Composable (() -> Unit))? = null, +private fun ChannelBookmarkButton( + bookmarked: Boolean, onClick: () -> Unit ) { - // iOS-style list row (plain button, no card background) - Surface( - onClick = onClick, - color = if (isSelected) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - Color.Transparent - }, - shape = MaterialTheme.shapes.medium, + val colorScheme = MaterialTheme.colorScheme + Box( + modifier = Modifier + .size(36.dp) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource( + if (bookmarked) { + R.drawable.ic_spec_bookmark_filled + } else { + R.drawable.ic_spec_bookmark_outline + } + ), + contentDescription = stringResource( + if (bookmarked) R.string.cd_remove_bookmark else R.string.cd_add_bookmark + ), + tint = if (bookmarked) colorScheme.primary else colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } +} + +@Composable +private fun ChannelLoadingRow() { + val colorScheme = MaterialTheme.colorScheme + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + horizontalArrangement = Arrangement.spacedBy(ChannelLeadingGutter), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - // Split title to handle count part with smaller font (iOS style) - val (baseTitle, countSuffix) = splitTitleAndCount(title) + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = colorScheme.onSurfaceVariant + ) + } + Text( + text = stringResource(R.string.finding_nearby_channels), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onSurfaceVariant + ) + } +} - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = baseTitle, - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - fontWeight = if (titleBold) FontWeight.Bold else FontWeight.Normal, - color = titleColor ?: MaterialTheme.colorScheme.onSurface - ) +/** + * Teleport / custom geohash control unified into the same card as channel options. + */ +@Composable +private fun CustomGeohashRow( + customGeohash: String, + onGeohashChange: (String) -> Unit, + onFocusGained: () -> Unit, + onOpenMap: () -> Unit, + onTeleport: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + val density = LocalDensity.current + val imeInsets = WindowInsets.ime + val coroutineScope = rememberCoroutineScope() + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val normalized = customGeohash.trim().lowercase().replace("#", "") + val isValid = validateGeohash(normalized) + // Typing the last character of a valid geohash arms the button; cross-fading both the label + // and its container makes that the moment the row confirms the input is usable. + val teleportColor by animateColorAsState( + targetValue = if (isValid) colorScheme.primary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "teleportLabel" + ) + val teleportContainer by animateColorAsState( + targetValue = if (isValid) { + colorScheme.primary.copy(alpha = 0.16f) + } else { + colorScheme.surfaceVariant + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "teleportContainer" + ) + val teleportInteraction = remember { MutableInteractionSource() } + val teleportScale = rememberPressScale(teleportInteraction, pressedScale = 0.92f) - countSuffix?.let { count -> - Text( - text = count, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) + Row( + modifier = Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester) + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.PinDrop, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(ChannelLeadingGutter)) + + Text( + text = stringResource(R.string.hash_symbol), + fontSize = 14.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary + ) + + Spacer(modifier = Modifier.width(4.dp)) + + BasicTextField( + value = customGeohash, + onValueChange = onGeohashChange, + textStyle = TextStyle( + fontSize = 14.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.primary + ), + cursorBrush = SolidColor(colorScheme.primary), + singleLine = true, + modifier = Modifier + .weight(1f) + .onFocusChanged { focusState -> + if (!focusState.isFocused) return@onFocusChanged + onFocusGained() + // Wait until IME insets have landed so the LazyColumn has shrunk; bringIntoView + // against the full-height viewport leaves the field tucked under the keyboard. + // Re-request once the IME animation settles — early insets are still growing. + coroutineScope.launch { + withTimeoutOrNull(750) { + snapshotFlow { imeInsets.getBottom(density) } + .first { it > 0 } + } + bringIntoViewRequester.bringIntoView() + delay(BitchatMotion.STANDARD_MS.toLong()) + bringIntoViewRequester.bringIntoView() } - } - - Text( - text = subtitle, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - - Row(verticalAlignment = Alignment.CenterVertically) { - if (isSelected) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = stringResource(R.string.cd_selected), - tint = Color(0xFF32D74B), // iOS green for checkmark - modifier = Modifier.size(20.dp) + }, + decorationBox = { inner -> + if (customGeohash.isEmpty()) { + Text( + text = stringResource(R.string.geohash_placeholder), + fontSize = 14.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary ) } - - if (trailingContent != null) { - trailingContent() - } + inner() } + ) + + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .pressScaleClickable(onClick = onOpenMap), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Map, + contentDescription = stringResource(R.string.cd_open_map), + tint = colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } + + Surface( + onClick = onTeleport, + enabled = isValid, + shape = RoundedCornerShape(8.dp), + color = teleportContainer, + interactionSource = teleportInteraction, + modifier = Modifier.scale(teleportScale) + ) { + Text( + text = stringResource(R.string.teleport).uppercase(), + fontSize = 11.sp, + letterSpacing = 0.8.sp, + fontWeight = FontWeight.Medium, + fontFamily = BitchatFontFamily, + color = teleportColor, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp) + ) } } } -// MARK: - Helper Functions (matching iOS implementation) +/** Settings-toggle row geometry (icon + title/subtitle + switch), local copy for this sheet. */ +@Composable +private fun ChannelSettingsToggleRow( + icon: ImageVector, + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, + statusIndicator: (@Composable () -> Unit)? = null +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (enabled) colorScheme.primary else palette.textTertiary, + modifier = Modifier.size(ChannelLeadingSlot) + ) + Spacer(modifier = Modifier.width(ChannelLeadingGutter)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (enabled) colorScheme.onSurface else palette.textTertiary + ) + statusIndicator?.invoke() + } + Text( + text = subtitle, + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + lineHeight = 17.sp, + color = if (enabled) colorScheme.onSurfaceVariant else palette.textTertiary + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = checked, + onCheckedChange = { if (enabled) onCheckedChange(it) }, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = colorScheme.primary, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = colorScheme.surfaceVariant + ) + ) + } +} + +// MARK: - Helper Functions + +/** Separates the channel name from the animated people-count label in composed titles. */ +private const val TITLE_COUNT_SEP = '\u001F' private fun splitTitleAndCount(title: String): Pair { + val sep = title.indexOf(TITLE_COUNT_SEP) + if (sep != -1) { + return Pair(title.substring(0, sep), title.substring(sep + 1).ifEmpty { null }) + } + // Legacy "[n people]" form val lastBracketIndex = title.lastIndexOf('[') return if (lastBracketIndex != -1) { - val prefix = title.substring(0, lastBracketIndex).trim() - val suffix = title.substring(lastBracketIndex) - Pair(prefix, suffix) + val count = title.substring(lastBracketIndex + 1).removeSuffix("]") + Pair(title.substring(0, lastBracketIndex).trim(), count.ifEmpty { null }) } else { Pair(title, null) } @@ -667,56 +1030,48 @@ private fun splitTitleAndCount(title: String): Pair { @Composable private fun meshTitleWithCount(viewModel: ChatViewModel): String { val meshCount = meshCount(viewModel) - val ctx = androidx.compose.ui.platform.LocalContext.current - val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, meshCount, meshCount) - val meshLabel = stringResource(com.bitchat.android.R.string.mesh_label) - return "$meshLabel [$peopleText]" + val ctx = LocalContext.current + val peopleText = ctx.resources.getQuantityString(R.plurals.people_count, meshCount, meshCount) + val meshLabel = stringResource(R.string.mesh_title) + return "$meshLabel$TITLE_COUNT_SEP$peopleText" } private fun meshCount(viewModel: ChatViewModel): Int { val myID = viewModel.myPeerID - return viewModel.connectedPeers.value?.count { peerID -> - peerID != myID - } ?: 0 + return viewModel.connectedPeers.value?.count { it != myID } ?: 0 } @Composable private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String { - val ctx = androidx.compose.ui.platform.LocalContext.current - - // For high precision channels (Neighborhood, Block) where we don't broadcast presence, - // show "? people" instead of "0 people" to avoid misleading "nobody is here" indication. + val ctx = LocalContext.current val isHighPrecision = channel.level.precision > 5 val peopleText = if (isHighPrecision && participantCount == 0) { - ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?") + ctx.resources.getQuantityString(R.plurals.people_count, 0, 0).replace("0", "?") } else { - ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) + ctx.resources.getQuantityString(R.plurals.people_count, participantCount, participantCount) } - val levelName = when (channel.level) { - com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes - com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block) - com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD -> stringResource(com.bitchat.android.R.string.location_level_neighborhood) - com.bitchat.android.geohash.GeohashChannelLevel.CITY -> stringResource(com.bitchat.android.R.string.location_level_city) - com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE -> stringResource(com.bitchat.android.R.string.location_level_province) - com.bitchat.android.geohash.GeohashChannelLevel.REGION -> stringResource(com.bitchat.android.R.string.location_level_region) + GeohashChannelLevel.BUILDING -> "Building" + GeohashChannelLevel.BLOCK -> stringResource(R.string.location_level_block) + GeohashChannelLevel.NEIGHBORHOOD -> stringResource(R.string.location_level_neighborhood) + GeohashChannelLevel.CITY -> stringResource(R.string.location_level_city) + GeohashChannelLevel.PROVINCE -> stringResource(R.string.location_level_province) + GeohashChannelLevel.REGION -> stringResource(R.string.location_level_region) } - return "$levelName [$peopleText]" + return "$levelName$TITLE_COUNT_SEP$peopleText" } @Composable private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String { - val ctx = androidx.compose.ui.platform.LocalContext.current + val ctx = LocalContext.current val level = levelForLength(geohash.length) val isHighPrecision = level.precision > 5 - val peopleText = if (isHighPrecision && participantCount == 0) { - ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?") + ctx.resources.getQuantityString(R.plurals.people_count, 0, 0).replace("0", "?") } else { - ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) + ctx.resources.getQuantityString(R.plurals.people_count, participantCount, participantCount) } - - return "#$geohash [$peopleText]" + return "#$geohash$TITLE_COUNT_SEP$peopleText" } private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean { @@ -726,6 +1081,24 @@ private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelI } } +/** + * Returns the active location channel when it has no row in the nearby-channel list. + * + * This commonly happens after teleporting to a remote geohash. Keeping the selected channel in the + * main channel card makes its selection and bookmark action available even before it is bookmarked. + */ +internal fun selectedLocationChannelOutsideNearby( + selectedChannel: ChannelID?, + nearbyChannels: List +): GeohashChannel? { + val selected = (selectedChannel as? ChannelID.Location)?.channel ?: return null + return selected.takeUnless { active -> + nearbyChannels.any { nearby -> + nearby.geohash.equals(active.geohash, ignoreCase = true) + } + } +} + private fun validateGeohash(geohash: String): Boolean { if (geohash.isEmpty() || geohash.length > 12) return false val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() @@ -739,13 +1112,12 @@ private fun levelForLength(length: Int): GeohashChannelLevel { 5 -> GeohashChannelLevel.CITY 6 -> GeohashChannelLevel.NEIGHBORHOOD 7 -> GeohashChannelLevel.BLOCK - 8 -> GeohashChannelLevel.BUILDING // iOS: precision 8 for building-level + 8 -> GeohashChannelLevel.BUILDING else -> if (length > 8) GeohashChannelLevel.BUILDING else GeohashChannelLevel.BLOCK } } private fun coverageString(precision: Int): String { - // Approximate max cell dimension at equator for a given geohash length val maxMeters = when (precision) { 2 -> 1_250_000.0 3 -> 156_000.0 @@ -758,10 +1130,7 @@ private fun coverageString(precision: Int): String { 10 -> 1.19 else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble()) } - - // Use metric system for simplicity (could be made locale-aware) - val km = maxMeters / 1000.0 - return "~${formatDistance(km)} km" + return "~${formatDistance(maxMeters / 1000.0)} km" } private fun formatDistance(value: Double): String { @@ -772,11 +1141,6 @@ private fun formatDistance(value: Double): String { } } -private fun bluetoothRangeString(): String { - // Approximate Bluetooth LE range for typical mobile devices - return "~10–50 m" -} +private fun meshRangeString(): String = "~10–50m" -private fun formattedNamePrefix(level: GeohashChannelLevel): String { - return "~" -} +private fun formattedNamePrefix(level: GeohashChannelLevel): String = "~" diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 6ad9bdd8..489ddaf4 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -1,19 +1,21 @@ package com.bitchat.android.ui +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Description -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.bitchat.android.R import com.bitchat.android.geohash.ChannelID @@ -21,9 +23,9 @@ import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.nostr.LocationNotesManager /** - * Location Notes button component for MainHeader - * Shows in mesh mode when location permission granted AND services enabled - * Icon turns primary color when notes exist, gray otherwise + * Location Notes button for MainHeader. + * Mesh-only with location authorized. Tor health tints the glyph with muted colours + * and a slow glow while connecting (via [rememberTorConnectionVisual]). */ @Composable fun LocationNotesButton( @@ -33,18 +35,15 @@ fun LocationNotesButton( ) { val colorScheme = MaterialTheme.colorScheme val context = LocalContext.current - - // Get channel and permission state + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val locationManager = remember { LocationChannelManager.getInstance(context) } val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false) - // Check both permission AND location services enabled val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED val locationEnabled = locationPermissionGranted && locationServicesEnabled - - // Get notes count from LocationNotesManager + val notesManager = remember { LocationNotesManager.getInstance() } val notes by notesManager.notes.collectAsStateWithLifecycle() val notesCount = notes.size @@ -52,15 +51,22 @@ fun LocationNotesButton( // Only show in mesh mode when location is authorized (iOS pattern) if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) { val hasNotes = notesCount > 0 - IconButton( - onClick = onClick, - modifier = modifier.size(24.dp) + val contentDescription = stringResource(R.string.cd_location_notes) + val normalTint = if (hasNotes) colorScheme.primary else colorScheme.onSurfaceVariant + val torVisual = rememberTorConnectionVisual(normal = normalTint) + + Box( + modifier = modifier + .size(44.dp) + .clip(CircleShape) + .pressScaleClickable(onClick = onClick, onClickLabel = contentDescription), + contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.Outlined.Description, // "long.text.page.and.pencil" equivalent - contentDescription = stringResource(R.string.cd_location_notes), - modifier = Modifier.size(16.dp), - tint = if (hasNotes) colorScheme.primary else Color.Gray + TorAwareHeaderIcon( + painter = painterResource(R.drawable.ic_spec_chat_bubbles), + tint = torVisual.tint, + isProgress = torVisual.isProgress, + contentDescription = contentDescription ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index 6a224cf2..f3c49480 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -1,16 +1,15 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.getValue @@ -18,9 +17,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.pluralStringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -51,10 +50,8 @@ fun LocationNotesSheet( modifier: Modifier = Modifier ) { val context = LocalContext.current - val isDark = isSystemInDarkTheme() - - // iOS color scheme - val accentGreen = if (isDark) Color.Green else Color(0xFF008000) // dark: green, light: dark green (0, 0.5, 0) + val colorScheme = MaterialTheme.colorScheme + val accentGreen = colorScheme.primary // Managers val notesManager = remember { LocationNotesManager.getInstance() } @@ -251,7 +248,7 @@ private fun LocationNotesHeader( if (name.isNotEmpty()) { Text( text = name, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = accentGreen ) @@ -262,7 +259,7 @@ private fun LocationNotesHeader( // Description Text( text = stringResource(R.string.location_notes_description), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -272,7 +269,7 @@ private fun LocationNotesHeader( Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.location_notes_relays_unavailable), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -303,7 +300,7 @@ private fun NoteRow(note: LocationNotesManager.Note) { ) { Text( text = "@$baseName", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface @@ -312,7 +309,7 @@ private fun NoteRow(note: LocationNotesManager.Note) { Spacer(modifier = Modifier.width(6.dp)) Text( text = ts, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -324,7 +321,7 @@ private fun NoteRow(note: LocationNotesManager.Note) { // Second row: content Text( text = note.content, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface ) @@ -343,7 +340,7 @@ private fun NoRelaysRow(onRetry: () -> Unit) { ) { Text( text = stringResource(R.string.location_notes_no_relays_title), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface @@ -351,14 +348,14 @@ private fun NoRelaysRow(onRetry: () -> Unit) { Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.location_notes_no_relays_desc), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.retry), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, modifier = Modifier.clickable(onClick = onRetry) @@ -385,7 +382,7 @@ private fun LoadingRow() { Spacer(modifier = Modifier.width(10.dp)) Text( text = stringResource(R.string.loading_location_notes), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -404,7 +401,7 @@ private fun EmptyRow() { ) { Text( text = stringResource(R.string.location_notes_empty_title), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface @@ -412,7 +409,7 @@ private fun EmptyRow() { Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.location_notes_empty_desc), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -440,7 +437,7 @@ private fun ErrorRow(message: String, onDismiss: () -> Unit) { Spacer(modifier = Modifier.width(6.dp)) Text( text = message, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface ) @@ -448,7 +445,7 @@ private fun ErrorRow(message: String, onDismiss: () -> Unit) { Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.dismiss), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, modifier = Modifier.clickable(onClick = onDismiss) @@ -468,7 +465,6 @@ private fun LocationNotesInputSection( nickname: String?, onSend: () -> Unit ) { - val isDark = isSystemInDarkTheme() val colorScheme = MaterialTheme.colorScheme Column( @@ -482,7 +478,7 @@ private fun LocationNotesInputSection( Row(verticalAlignment = Alignment.CenterVertically) { Text( text = "@$baseName", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colorScheme.onSurface @@ -506,7 +502,7 @@ private fun LocationNotesInputSection( onValueChange = onDraftChange, textStyle = MaterialTheme.typography.bodyMedium.copy( color = colorScheme.primary, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( @@ -523,7 +519,7 @@ private fun LocationNotesInputSection( Text( text = stringResource(R.string.location_notes_input_placeholder), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = colorScheme.onSurface.copy(alpha = 0.5f), modifier = Modifier.fillMaxWidth() @@ -556,10 +552,8 @@ private fun LocationNotesInputSection( modifier = Modifier.size(20.dp), tint = if (!sendButtonEnabled) { colorScheme.onSurface.copy(alpha = 0.5f) - } else if (isDark) { - Color.Black // Black arrow on green in dark theme } else { - Color.White // White arrow on green in light theme + colorScheme.onPrimary } ) } diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt deleted file mode 100644 index 4ca8674f..00000000 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ /dev/null @@ -1,179 +0,0 @@ -package com.bitchat.android.ui - -import androidx.compose.material3.ColorScheme -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bitchat.android.mesh.MeshService -import com.bitchat.android.model.BitchatMessage -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import kotlin.random.Random - -/** - * Animation state for individual characters - */ -private enum class CharacterAnimationState { - ENCRYPTED, // Showing random encrypted characters - FINAL // Showing final decrypted character -} - -/** - * Check if a message should be animated based on its mining state - */ -@Composable -fun shouldAnimateMessage(messageId: String): Boolean { - val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle() - return miningMessages.contains(messageId) -} - -/** - * Tracks which messages are currently being mined for PoW - * Provides reactive state for UI animations - */ -object PoWMiningTracker { - private val _miningMessages = MutableStateFlow>(emptySet()) - val miningMessages: StateFlow> = _miningMessages.asStateFlow() - - /** - * Start tracking a message as mining - */ - fun startMiningMessage(messageId: String) { - _miningMessages.value = _miningMessages.value + messageId - } - - /** - * Stop tracking a message as mining - */ - fun stopMiningMessage(messageId: String) { - _miningMessages.value = _miningMessages.value - messageId - } - - /** - * Check if a message is currently mining - */ - fun isMiningMessage(messageId: String): Boolean { - return _miningMessages.value.contains(messageId) - } - - /** - * Clear all mining messages (for cleanup) - */ - fun clearAllMining() { - _miningMessages.value = emptySet() - } -} - -/** - * Shows the active PoW animation inside the same two-row layout used by static text messages. - */ -@Composable -fun MessageWithMatrixAnimation( - message: BitchatMessage, - currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme, - timeFormatter: SimpleDateFormat, - onNicknameClick: ((String) -> Unit)?, - onMessageLongPress: ((BitchatMessage) -> Unit)?, - modifier: Modifier = Modifier, -) { - AnimatedMessageDisplay( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress, - modifier = modifier, - ) -} - -/** - * Animates only the body content; sender, metadata, gestures, and spacing remain stable. - */ -@Composable -private fun AnimatedMessageDisplay( - message: BitchatMessage, - currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme, - timeFormatter: SimpleDateFormat, - onNicknameClick: ((String) -> Unit)?, - onMessageLongPress: ((BitchatMessage) -> Unit)?, - modifier: Modifier = Modifier, -) { - var animatedContent by remember(message.id, message.content) { - mutableStateOf(message.content) - } - - // Character-by-character animation state like the JavaScript version - var characterStates by remember(message.id, message.content) { - mutableStateOf(message.content.map { char -> - if (char == ' ') CharacterAnimationState.FINAL else CharacterAnimationState.ENCRYPTED - }) - } - - LaunchedEffect(message.id, message.content) { - if (message.content.isEmpty()) return@LaunchedEffect - - val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray() - - // Start character animations with staggered delays (like JS version). - message.content.forEachIndexed { index, targetChar -> - if (targetChar != ' ') { - launch { - delay(index * 50L) - - while (true) { - while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) { - val newContent = animatedContent.toCharArray() - if (index < newContent.size) { - newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)] - animatedContent = String(newContent) - } - - delay(100L) - - if (Random.nextFloat() < 0.1f) { - val finalContent = animatedContent.toCharArray() - if (index < finalContent.size) { - finalContent[index] = targetChar - animatedContent = String(finalContent) - } - - val finalStates = characterStates.toMutableList() - finalStates[index] = CharacterAnimationState.FINAL - characterStates = finalStates - break - } - } - - delay(2000L) - - val resetStates = characterStates.toMutableList() - resetStates[index] = CharacterAnimationState.ENCRYPTED - characterStates = resetStates - } - } - } - } - } - - TextMessageLayout( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress, - modifier = modifier, - bodyContent = animatedContent, - ) -} diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index ec137437..d9a8d035 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -1,26 +1,35 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -29,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.core.ui.component.button.CloseButton import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.favorites.FavoriteRelationship @@ -36,6 +46,9 @@ import com.bitchat.android.favorites.FavoritesPersistenceService import com.bitchat.android.geohash.ChannelID import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.ui.theme.colorForPeer import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.services.ContactDirectory @@ -68,6 +81,8 @@ fun MeshPeerListSheet( val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() + val geohashPeopleCount = geohashPeople.size val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } @@ -98,74 +113,83 @@ fun MeshPeerListSheet( LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp) + contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp) ) { + val peopleCount = when (selectedLocationChannel) { + is ChannelID.Location -> geohashPeopleCount + else -> connectedPeers.count { it != viewModel.myPeerID } + } + // Channels section if (joinedChannels.isNotEmpty()) { - item(key = "channels_header") { - Text( - text = stringResource(id = R.string.channels).uppercase(), - style = MaterialTheme.typography.labelLarge, - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontWeight = FontWeight.Bold, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 8.dp, bottom = 4.dp) - ) - } - - items( - items = joinedChannels.toList(), - key = { "channel_$it" } - ) { channel -> - val isSelected = channel == currentChannel - val unreadCount = unreadChannelMessages[channel] ?: 0 - - ChannelRow( - channel = channel, - isSelected = isSelected, - unreadCount = unreadCount, - colorScheme = colorScheme, - onChannelClick = { - // Check if this is a DM channel (starts with @) - if (channel.startsWith("@")) { - // Extract peer name and find the peer ID - val peerName = channel.removePrefix("@") - val peerID = - peerNicknames.entries.firstOrNull { it.value == peerName }?.key - if (peerID != null) { - viewModel.showPrivateChatSheet(peerID) - onDismiss() + item(key = "channels_section") { + Column { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_chat_bubbles, + title = stringResource(R.string.channels), + modifier = Modifier.padding(top = 8.dp) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = MaterialTheme.colorScheme.surface, + shape = AboutCardShape + ) { + Column { + joinedChannels.toList().forEachIndexed { index, channel -> + if (index > 0) SheetCardDivider() + val isSelected = channel == currentChannel + val unreadCount = unreadChannelMessages[channel] ?: 0 + ChannelRow( + channel = channel, + isSelected = isSelected, + unreadCount = unreadCount, + colorScheme = colorScheme, + onChannelClick = { + if (channel.startsWith("@")) { + val peerName = channel.removePrefix("@") + val peerID = + peerNicknames.entries.firstOrNull { it.value == peerName }?.key + if (peerID != null) { + viewModel.showPrivateChatSheet(peerID) + onDismiss() + } + } else { + viewModel.switchToChannel(channel) + onDismiss() + } + }, + onLeaveChannel = { + viewModel.leaveChannel(channel) + }, + ) } - } else { - // Regular channel switch - viewModel.switchToChannel(channel) - onDismiss() } - }, - onLeaveChannel = { - viewModel.leaveChannel(channel) - }, - ) + } + } } } - // People section - switch between mesh and geohash lists (iOS-compatible) + // People / geohash participants item(key = "people_section") { when (selectedLocationChannel) { is ChannelID.Location -> { - // Show geohash people list when in location channel GeohashPeopleList( viewModel = viewModel, - onTapPerson = onDismiss + onTapPerson = onDismiss, + modifier = Modifier.padding( + top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp + ) ) } else -> { - // Show mesh peer list when in mesh channel (default) PeopleSection( - modifier = Modifier.padding(top = if (joinedChannels.isNotEmpty()) 16.dp else 0.dp), + modifier = Modifier.padding( + top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp + ), connectedPeers = connectedPeers, peerNicknames = peerNicknames, peerRSSI = peerRSSI, @@ -173,6 +197,7 @@ fun MeshPeerListSheet( colorScheme = colorScheme, selectedPrivatePeer = selectedPrivatePeer, wifiAwarePeerIDs = wifiAwarePeerIDs, + peopleCount = peopleCount, viewModel = viewModel, onPrivateChatStart = { peerID -> viewModel.showPrivateChatSheet(peerID) @@ -194,13 +219,13 @@ fun MeshPeerListSheet( if (selectedLocationChannel !is ChannelID.Location) { IconButton( onClick = onShowVerification, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(44.dp) ) { Icon( imageVector = Icons.Outlined.QrCode, contentDescription = stringResource(R.string.verify_title), - tint = colorScheme.onSurface.copy(alpha = 0.8f), - modifier = Modifier.size(18.dp) + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) ) } } @@ -213,6 +238,9 @@ fun MeshPeerListSheet( } } +/** Icon size for trailing actions on peer rows (matches settings glyph scale). */ +private val PeerRowIconSize = 22.dp + @Composable private fun ChannelRow( channel: String, @@ -222,59 +250,52 @@ private fun ChannelRow( onChannelClick: () -> Unit, onLeaveChannel: () -> Unit, ) { - Surface( - onClick = onChannelClick, - color = if (isSelected) { - colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - Color.Transparent - }, - shape = MaterialTheme.shapes.medium, + val palette = LocalBitchatPalette.current + + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .clickable(onClick = onChannelClick) + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Box( + modifier = Modifier.size(SheetRowLeadingSlot), + contentAlignment = Alignment.Center ) { - Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Unread badge - if (unreadCount > 0) { - UnreadBadge( - count = unreadCount, - colorScheme = colorScheme - ) - } - - Text( - text = channel, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = if (isSelected) colorScheme.primary else colorScheme.onSurface, - fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal + if (isSelected) { + Box( + modifier = Modifier + .size(SheetRowSelectedDot) + .background(colorScheme.primary, CircleShape) ) - } - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Leave channel button - CloseButton( - onClick = onLeaveChannel, + } else if (unreadCount > 0) { + UnreadBadge(count = unreadCount, colorScheme = colorScheme) + } else { + Text( + text = "#", + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = palette.textTertiary ) } } + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + + Text( + text = channel, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + color = if (isSelected) colorScheme.primary else colorScheme.onSurface, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + CloseButton(onClick = onLeaveChannel) } } @@ -290,6 +311,7 @@ fun PeopleSection( colorScheme: ColorScheme, selectedPrivatePeer: String?, wifiAwarePeerIDs: Set = emptySet(), + peopleCount: Int = 0, viewModel: ChatViewModel, onPrivateChatStart: (String) -> Unit ) { @@ -298,31 +320,34 @@ fun PeopleSection( SecureIdentityStateManager(context.applicationContext) } + val palette = LocalBitchatPalette.current + Column(modifier = modifier) { - Text( - text = stringResource(id = R.string.people).uppercase(), - style = MaterialTheme.typography.labelLarge, - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontWeight = FontWeight.Bold, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 8.dp, bottom = 4.dp) + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_people, + title = stringResource(R.string.people_count_title, peopleCount) ) - if (connectedPeers.isEmpty()) { - Text( - text = stringResource(id = R.string.no_one_connected), - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = 12.sp - ), - color = colorScheme.onSurface.copy(alpha = 0.5f), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 40.dp, vertical = 12.dp) - ) - } + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column { + if (connectedPeers.isEmpty()) { + Text( + text = stringResource(id = R.string.no_one_connected), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = palette.textTertiary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical) + ) + } // Observe reactive state for favorites and fingerprints val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() @@ -426,7 +451,21 @@ fun PeopleSection( if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 } - sortedPeers.forEach { peerID -> + // Every row this card will show, in final order, so the animated list can key on identity + // and animate reordering. Offline favourites are appended after the connected peers. + // Collected once for the whole card rather than once per row. + val directMap by viewModel.peerDirect.collectAsStateWithLifecycle() + + val offlineFavoriteRows = offlineFavorites.filterNot { isFavoriteMappedToConnected(it) } + val rowKeys: List = sortedPeers + + offlineFavoriteRows.map { ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) } + + AnimatedRowColumn(items = rowKeys, key = { it }) { rowIndex, rowKey -> + Column { + if (rowIndex > 0) SheetCardDivider() + val connectedPeerForRow = sortedPeers.firstOrNull { it == rowKey } + if (connectedPeerForRow != null) { + val peerID = connectedPeerForRow val conversationID = ContactDirectory.canonicalConversationId(peerID) val isFavorite = peerFavoriteStates[peerID] ?: false val isVerified = peerVerifiedStates[peerID] ?: false @@ -446,7 +485,6 @@ fun PeopleSection( val (bName, _) = splitSuffix(displayName) val showHash = (baseNameCounts[bName] ?: 0) > 1 - val directMap by viewModel.peerDirect.collectAsStateWithLifecycle() val isDirectLive = directMap[peerID] ?: try { viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false } PeerItem( peerID = peerID, @@ -468,12 +506,12 @@ fun PeopleSection( showNostrGlobe = false, showHashSuffix = showHash ) - } - - // Append offline favorites we actively favorite (and not currently connected) - offlineFavorites.forEach { fav -> - val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey) - if (isFavoriteMappedToConnected(fav)) return@forEach + } else { + // Offline favourite: still worth showing, reachable over Nostr. + val fav = offlineFavoriteRows.first { + ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) == rowKey + } + val favPeerID = rowKey val nostrConvKey: String? = try { FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey) @@ -518,6 +556,10 @@ fun PeopleSection( showHashSuffix = showHash ) } + } + } + } + } } } @@ -547,143 +589,123 @@ private fun PeerItem( val isMe = displayName == "You" || peerID == currentNickname // Get consistent peer color (iOS-compatible) - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val assignedColor = viewModel.colorForMeshPeer(peerID, isDark) - val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor + val palette = LocalBitchatPalette.current + val assignedColor = colorForPeer( + viewModel.peerIdentityForMeshPeer(peerID), + palette + ) + val baseColor = if (isMe) palette.accentOrange else assignedColor - Surface( - onClick = onItemClick, - color = if (isSelected) { - colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - Color.Transparent - }, - shape = MaterialTheme.shapes.medium, + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .clickable(onClick = onItemClick) + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Box( + modifier = Modifier.size(SheetRowLeadingSlot), + contentAlignment = Alignment.Center ) { - Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Connection/status indicator - if (hasUnreadDM) { - // Show mail icon for unread DMs (iOS orange) - Icon( - imageVector = Icons.Filled.Email, - contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(16.dp), - tint = Color(0xFFFF9500) // iOS orange - ) - } else if (showNostrGlobe) { - // Purple globe to indicate Nostr availability - Icon( - imageVector = Icons.Filled.Public, - contentDescription = stringResource(R.string.cd_reachable_via_nostr), - modifier = Modifier.size(16.dp), - tint = Color(0xFF9C27B0) // Purple - ) - } else if (!isDirect && isFavorite) { - // Offline favorited user: show outlined circle icon - Icon( - imageVector = Icons.Outlined.Circle, - contentDescription = stringResource(R.string.cd_offline_favorite), - modifier = Modifier.size(16.dp), - tint = Color.Gray - ) - } else { - Icon( - imageVector = when { - isWifiAware -> Icons.Filled.Wifi - isDirect -> Icons.Outlined.Bluetooth - else -> Icons.Filled.Route - }, - contentDescription = when { - isWifiAware -> "Direct Wi-Fi Aware" - isDirect -> "Direct Bluetooth" - else -> "Routed" - }, - modifier = Modifier.size(16.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - - // Display name with iOS-style color and hashtag suffix support - Row(verticalAlignment = Alignment.CenterVertically) { - // Base name with peer-specific color - Text( - text = baseName, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal - ), - color = baseColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - // Hashtag suffix in lighter shade (iOS-style) - if (suffix.isNotEmpty()) { - Text( - text = suffix, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = baseColor.copy(alpha = 0.6f) - ) - } - - if (isWifiAware && hasUnreadDM) { - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.Filled.Wifi, - contentDescription = "Direct Wi-Fi Aware", - modifier = Modifier.size(13.dp), - tint = colorScheme.onSurface.copy(alpha = 0.8f) - ) - } - } - } - - if (isVerified) { - Spacer(modifier = Modifier.width(4.dp)) + if (isSelected) { + Box( + modifier = Modifier + .size(SheetRowSelectedDot) + .background(colorScheme.primary, CircleShape) + ) + } else if (hasUnreadDM) { Icon( - imageVector = Icons.Filled.Verified, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = Color(0xFF32D74B) // iOS Green + painter = painterResource(R.drawable.ic_spec_envelope), + contentDescription = stringResource(R.string.cd_unread_message), + modifier = Modifier.size(PeerRowIconSize), + tint = palette.accentOrange + ) + } else if (showNostrGlobe) { + Icon( + painter = painterResource(R.drawable.ic_spec_globe), + contentDescription = stringResource(R.string.cd_reachable_via_nostr), + modifier = Modifier.size(PeerRowIconSize), + tint = palette.accentPurple + ) + } else if (!isDirect && isFavorite) { + Icon( + imageVector = Icons.Outlined.Circle, + contentDescription = stringResource(R.string.cd_offline_favorite), + modifier = Modifier.size(PeerRowIconSize), + tint = palette.textTertiary + ) + } else { + Icon( + painter = painterResource( + conversationTransportIcon( + isReachedOverInternet = false, + isWifiAware = isWifiAware, + isDirect = isDirect + ) + ), + contentDescription = when { + isWifiAware -> "Direct Wi-Fi Aware" + isDirect -> "Direct Bluetooth" + else -> "Routed" + }, + modifier = Modifier.size(PeerRowIconSize), + tint = colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = baseName, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, + color = baseColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (suffix.isNotEmpty()) { + Text( + text = suffix, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, + color = baseColor.copy(alpha = SUFFIX_ALPHA) ) } - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Favorite star with proper filled/outlined states - IconButton( - onClick = onToggleFavorite, - modifier = Modifier.size(32.dp) - ) { - Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, - contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", - modifier = Modifier.size(16.dp), - tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50) - ) - } + if (isVerified) { + Icon( + painter = painterResource(R.drawable.ic_spec_check), + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(16.dp), + tint = colorScheme.primary + ) } } + + Box( + modifier = Modifier + .size(36.dp) + .clickable(onClick = onToggleFavorite), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource( + if (isFavorite) R.drawable.ic_spec_star_filled else R.drawable.ic_spec_star + ), + contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", + modifier = Modifier.size(PeerRowIconSize), + tint = if (isFavorite) palette.accentOrange else palette.textTertiary + ) + } } } @@ -696,25 +718,41 @@ private fun UnreadBadge( colorScheme: ColorScheme, modifier: Modifier = Modifier ) { - if (count > 0) { + val palette = LocalBitchatPalette.current + // Scale/fade in and out so a badge appearing while the sheet is open is noticed, and one + // clearing does not just blink away. + AnimatedVisibility( + visible = count > 0, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + scaleIn( + initialScale = 0.5f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + scaleOut( + targetScale = 0.5f, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ), + modifier = modifier + ) { Box( - modifier = modifier + modifier = Modifier .background( - color = Color(0xFFFFD700), // Yellow color + color = palette.accentOrange, shape = RoundedCornerShape(10.dp) ) .padding(horizontal = 6.dp, vertical = 2.dp) .defaultMinSize(minWidth = 18.dp, minHeight = 18.dp), contentAlignment = Alignment.Center ) { - Text( + AnimatedCountLabel( + count = count, text = if (count > 99) "99+" else count.toString(), - style = MaterialTheme.typography.labelSmall.copy( - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace - ), - color = Color.Black // Black text on yellow background + style = MaterialTheme.typography.labelSmall, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = BitchatFontFamily, + color = Color.Black ) } } @@ -804,7 +842,7 @@ fun PrivateChatSheet( val name = if (fullPubkey.isNotEmpty()) { viewModel.geohashViewModel.displayNameForGeohashConversation(fullPubkey, gh) } else { - peerNicknames[peerID] ?: "unknown" + peerNicknames[peerID] ?: "Unknown" } "#$gh/@$name" } else { @@ -826,12 +864,7 @@ fun PrivateChatSheet( viewModel.isPeerVerified(peerID, verifiedFingerprints) } - val securityModifier = if (!isNostrPeer && !isNostrReachableFavorite) { - Modifier.clickable { viewModel.showSecurityVerificationSheet() } - } else { - Modifier - } - + val palette = LocalBitchatPalette.current val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true ) @@ -845,9 +878,9 @@ fun PrivateChatSheet( Column( modifier = Modifier.fillMaxSize() ) { - Spacer(modifier = Modifier.height(64.dp)) + Spacer(modifier = Modifier.height(ChatHeaderHeight)) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) // Messages list var forceScrollToBottom by remember { mutableStateOf(false) } @@ -858,6 +891,7 @@ fun PrivateChatSheet( currentUserNickname = nickname, meshService = viewModel.meshServiceFacade, modifier = Modifier.weight(1f), + conversationKey = "dm:$peerID", forceScrollToBottom = forceScrollToBottom, onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, onNicknameClick = { /* handle mention */ }, @@ -866,9 +900,8 @@ fun PrivateChatSheet( onImageClick = { _, _, _ -> /* handle image click */ } ) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) - - // Input section + // Input section. No divider here: ChatInputSection draws its own fade and + // hairline. var messageText by remember { mutableStateOf( androidx.compose.ui.text.input.TextFieldValue( @@ -913,112 +946,97 @@ fun PrivateChatSheet( ) } - // TopBar (fixed at top, iOS-style) - BitchatSheetCenterTopBar( - onClose = onDismiss, + // Header. Built from the same tokens as the main chat header rather than a + // TopAppBar, so moving between the timeline and a conversation does not shift the + // bar's height, insets or type. + Surface( modifier = Modifier.align(Alignment.TopCenter), - navigationIcon = { - IconButton( - onClick = onDismiss, - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = 16.dp) - .size(32.dp) + color = colorScheme.background + ) { + ConversationHeader( + leadingIconRes = conversationTransportIcon( + isReachedOverInternet = isNostrPeer || isNostrReachableFavorite, + isWifiAware = isWifiAware, + isDirect = isDirect + ), + leadingIconTint = colorScheme.primary, + leadingContentDescription = when { + isNostrPeer || isNostrReachableFavorite -> + stringResource(R.string.cd_nostr_reachable) + else -> null + }, + title = titleText + ) { + ConversationHeaderAction( + onClick = { viewModel.toggleFavorite(peerID) }, + contentDescription = if (isFavorite) { + stringResource(R.string.cd_remove_favorite) + } else { + stringResource(R.string.cd_add_favorite) + } ) { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.chat_back), - tint = colorScheme.onSurface + painter = painterResource( + if (isFavorite) { + R.drawable.ic_spec_star_filled + } else { + R.drawable.ic_spec_star + } + ), + contentDescription = null, + modifier = Modifier.size(HeaderIconSize), + tint = if (isFavorite) { + palette.accentOrange + } else { + colorScheme.onSurfaceVariant + } ) } - }, - title = { - // Center content: connection status + name + encryption - Row( - modifier = Modifier.align(Alignment.Center), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - when { - isNostrPeer || isNostrReachableFavorite -> { - Icon( - imageVector = Icons.Filled.Public, - contentDescription = stringResource(R.string.cd_nostr_reachable), - modifier = Modifier.size(14.dp), - tint = Color(0xFF9C27B0) - ) - } - isWifiAware -> { - Icon( - imageVector = Icons.Filled.Wifi, - contentDescription = "Direct Wi-Fi Aware", - modifier = Modifier.size(14.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - isDirect -> { - Icon( - imageVector = Icons.Outlined.Bluetooth, - contentDescription = "Direct Bluetooth", - modifier = Modifier.size(14.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - isConnected -> { - Icon( - imageVector = Icons.Filled.Route, - contentDescription = "Routed", - modifier = Modifier.size(14.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - } - Text( - text = titleText, - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace - ), - color = if (isNostrPeer || isNostrReachableFavorite) Color(0xFFFF9500) else colorScheme.onSurface - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.then(securityModifier) + // Encryption state, and the verification badge that qualifies it. Both are + // read-only for Nostr peers, which have no Noise session at all. + if (!isNostrPeer && !isNostrReachableFavorite) { + ConversationHeaderAction( + onClick = { viewModel.showSecurityVerificationSheet() }, + contentDescription = stringResource(R.string.verify_title) ) { - if (!isNostrPeer && !isNostrReachableFavorite) { + Box(contentAlignment = Alignment.Center) { NoiseSessionIcon( sessionState = sessionState, - modifier = Modifier.size(14.dp) - ) - } - - if (isVerified) { - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.Filled.Verified, - contentDescription = stringResource(R.string.verify_title), - modifier = Modifier.size(14.dp), - tint = Color(0xFF32D74B) // iOS Green + modifier = Modifier.size(HeaderIconSize) ) } } + } - IconButton( - onClick = { viewModel.toggleFavorite(peerID) }, - modifier = Modifier.size(28.dp) + if (isVerified) { + Box( + modifier = Modifier.size(HeaderIconSize), + contentAlignment = Alignment.Center ) { Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, - contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite), - modifier = Modifier.size(16.dp), - tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f) + painter = painterResource(R.drawable.ic_spec_check), + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(HeaderIconSize), + tint = colorScheme.primary ) } } + + val dismiss = LocalSheetDismiss.current + ConversationHeaderAction( + onClick = { dismiss?.invoke() ?: onDismiss() }, + contentDescription = stringResource(R.string.close_plain) + ) { + Icon( + painter = painterResource(R.drawable.ic_spec_close), + contentDescription = null, + modifier = Modifier.size(HeaderIconSize), + tint = colorScheme.onSurfaceVariant + ) + } } - ) + } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 73fac04d..16c7f63d 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -1,6 +1,18 @@ package com.bitchat.android.ui - +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -9,16 +21,16 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.ColorScheme import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -29,20 +41,25 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.component.text.AnnotatedClickableText import com.bitchat.android.mesh.MeshService @@ -50,6 +67,13 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.ui.media.FileMessageItem +import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.ChatVisualTokens +import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.ui.theme.MessageBodyTextStyle +import com.bitchat.android.ui.theme.MessageSenderTextStyle +import kotlinx.coroutines.delay import java.text.SimpleDateFormat import java.util.Locale @@ -61,12 +85,119 @@ import java.util.Locale * Extracted from ChatScreen.kt for better organization */ +/** How far a newly arrived message travels up into place. */ +private val MessageEntrySlide = 14.dp + +/** + * Entry motion for a new message: quick, with just enough damping to settle rather than snap. + * Runs entirely on a graphics layer, so it costs a transform and nothing else. + */ +private val MessageEntrySpec: AnimationSpec = + spring(dampingRatio = 0.85f, stiffness = 1200f) + +/** + * Motion for messages being pushed out of the way by an arrival. Softer than the entry so the + * conversation glides up while the new message itself lands crisply. + */ +private val MessagePlacementSpec: FiniteAnimationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = IntOffset.VisibilityThreshold +) + +/** Removals are not worth dwelling on. */ +private val MessageFadeOutSpec: FiniteAnimationSpec = tween(BitchatMotion.QUICK_MS) + +/** + * How long placement animation stays armed after the list gains or loses a message. + * + * Comfortably longer than [MessagePlacementSpec] takes to settle, so an arrival's push is never cut + * short. + */ +private const val PlacementArmWindowMs = 600L + +/** + * Above this many simultaneous arrivals, entry animations are skipped. + * + * A history sync or a channel switch can append hundreds of messages in one frame. Animating each + * would spend the entire frame budget on motion nobody asked to see, so a burst is adopted + * silently and only conversational-pace arrivals animate. + */ +internal const val MaxAnimatedArrivals = 6 + +/** + * Remembers which message ids have already been seen, so genuine arrivals can be told apart from + * items merely scrolling back into view. + * + * This distinction is the whole reason the entry animation is usable: `LazyColumn` composes items + * on demand, so animating on first composition would replay the animation for every old message + * the user scrolled back to. + */ +internal class MessageArrivalTracker { + val known = HashSet() + var seeded = false +} + +/** + * Ids that should animate in on this composition pass. + * + * Deliberately computed during composition rather than in a `LaunchedEffect`: effects run *after* + * the frame's composition, by which point a new message's item has already composed and would + * have missed its cue. + */ +internal fun MessageArrivalTracker.arrivals(messages: List): Set { + if (!seeded) { + // First load adopts everything silently. A whole screenful animating on open reads as a + // glitch, not a flourish. + messages.forEach { known.add(it.id) } + seeded = true + return emptySet() + } + + // A list with nothing in common with the last one is a different conversation, not a burst of + // arrivals — /clear, or a switch the caller did not give us a distinct key for. Adopt it + // silently rather than sliding in every message at once. + val isWholesaleReplacement = + messages.isNotEmpty() && known.isNotEmpty() && messages.none { it.id in known } + + // `HashSet.add` reports whether the id was new, so this both diffs and updates in one pass. + val added = messages.filter { known.add(it.id) } + + if (known.size > messages.size) { + // Messages disappeared (/clear, channel switch). Drop the stale ids so the set cannot + // grow without bound and so re-added messages animate again. + known.retainAll(messages.mapTo(HashSet(messages.size)) { it.id }) + } + + return when { + isWholesaleReplacement -> emptySet() + added.isEmpty() || added.size > MaxAnimatedArrivals -> emptySet() + else -> added.mapTo(HashSet(added.size)) { it.id } + } +} + @Composable fun MessagesList( messages: List, currentUserNickname: String, meshService: MeshService, modifier: Modifier = Modifier, + mentionPeerIdentities: Map? = null, + /** + * Extra inset on top of the list's own gutters. + * + * The chat screen's bars are translucent and the list scrolls underneath them, so the caller + * has to reserve room for their heights here rather than by shrinking the viewport. + */ + contentPadding: PaddingValues = PaddingValues(0.dp), + /** + * Identity of the conversation being shown — a channel, a geohash, a peer. + * + * Everything below that is per-conversation state is keyed on this. Without it, switching + * channels reused the previous conversation's scroll offset, follow flag and seen-message set, + * so the new channel opened at a stale position and then animated itself into place. + */ + conversationKey: Any? = null, forceScrollToBottom: Boolean = false, onScrolledUpChanged: ((Boolean) -> Unit)? = null, onNicknameClick: ((String) -> Unit)? = null, @@ -74,11 +205,23 @@ fun MessagesList( onCancelTransfer: ((BitchatMessage) -> Unit)? = null, onImageClick: ((String, List, Int) -> Unit)? = null ) { - val listState = rememberLazyListState() - + val resolvedMentionPeerIdentities = remember(messages, mentionPeerIdentities) { + mentionPeerIdentities ?: buildMentionPeerIdentityMap(messages) + } + + // A fresh scroll position per conversation. Sharing one state meant a switch inherited the + // previous channel's offset and then had to correct itself, which is what the jump was. + // + // Passing the key as an *input* rather than as `key =` is deliberate: it discards the saved + // offset on every switch, so a conversation always opens on its newest message instead of + // wherever the reader happened to be some time ago, with unseen messages below them. + val listState = rememberSaveable(conversationKey, saver = LazyListState.Saver) { + LazyListState() + } + // Track if this is the first time messages are being loaded - var hasScrolledToInitialPosition by remember { mutableStateOf(false) } - var followIncomingMessages by remember { mutableStateOf(true) } + var hasScrolledToInitialPosition by remember(conversationKey) { mutableStateOf(false) } + var followIncomingMessages by remember(conversationKey) { mutableStateOf(true) } // Smart scroll: auto-scroll to bottom for initial load, then follow unless user scrolls away LaunchedEffect(messages.size) { @@ -94,7 +237,7 @@ fun MessagesList( } // Track whether user has scrolled away from the latest messages - val isAtLatest by remember { + val isAtLatest by remember(listState) { derivedStateOf { val firstVisibleIndex = listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1 firstVisibleIndex <= 2 @@ -114,27 +257,107 @@ fun MessagesList( } } + // Recomputed only when the list actually gains or loses a message, and synchronously, so the + // arriving item can read its cue during the same composition pass in which it first appears. + // Reset per conversation, so a switch adopts the incoming messages silently instead of + // treating a whole channel's backlog as brand-new arrivals and sliding each one in. + val arrivalTracker = remember(conversationKey) { MessageArrivalTracker() } + val enteringIds = remember(conversationKey, messages.size, messages.lastOrNull()?.id) { + arrivalTracker.arrivals(messages) + } + + // Placement animation exists to soften insertions and removals. But *any* relayout moves every + // item — the keyboard opening behind a bottom sheet, that sheet closing again, the composer + // growing a line — and animating those made the whole conversation lurch. So it is armed only + // briefly around a genuine change to the list, and is otherwise off, letting items track the + // viewport exactly. + var placementArmed by remember(conversationKey) { mutableStateOf(false) } + var previousMessageCount by remember(conversationKey) { mutableStateOf(null) } + LaunchedEffect(conversationKey, messages.size) { + val previous = previousMessageCount + previousMessageCount = messages.size + // Skip the first composition: the list settling into its initial padding is not a change + // worth animating. + if (previous == null || previous == messages.size) return@LaunchedEffect + placementArmed = true + delay(PlacementArmWindowMs) + placementArmed = false + } + + val layoutDirection = LocalLayoutDirection.current LazyColumn( state = listState, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + // Wider side gutters than the old 12.dp: the redesign trades a little line length for + // a much calmer edge, and long monospace lines were running into the screen bezel. + contentPadding = PaddingValues( + start = 16.dp + contentPadding.calculateStartPadding(layoutDirection), + end = 16.dp + contentPadding.calculateEndPadding(layoutDirection), + top = 8.dp + contentPadding.calculateTopPadding(), + bottom = 12.dp + contentPadding.calculateBottomPadding() + ), + // Spacing is owned by each item. The exported transcript uses a consistent 8.dp rhythm; + // a new speaker gets additional separation from the visible sender row's top inset. + verticalArrangement = Arrangement.spacedBy(0.dp), modifier = modifier, reverseLayout = true ) { - items( - items = messages.asReversed(), - key = { it.id } - ) { message -> - MessageItem( - message = message, - messages = messages, - currentUserNickname = currentUserNickname, - meshService = meshService, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress, - onCancelTransfer = onCancelTransfer, - onImageClick = onImageClick - ) + val reversed = messages.asReversed() + itemsIndexed( + items = reversed, + key = { _, message -> message.id } + ) { reversedIndex, message -> + // reverseLayout renders index 0 at the bottom, so the chronological predecessor of + // this row lives at a *higher* original index offset. Resolve against the original + // list rather than the reversed view to keep the grouping logic readable. + val originalIndex = messages.lastIndex - reversedIndex + val previous = messages.getOrNull(originalIndex - 1) + val isGrouped = MessageGrouping.shouldGroup(previous, message) + + // Decided once per item instance, so an item recycling back into view during a scroll + // never re-animates. Items that are not arriving skip the animation machinery + // entirely: no Animatable, no coroutine, and no extra render layer per row. + val isArriving = remember(message.id) { message.id in enteringIds } + val entryModifier = if (isArriving) { + val entry = remember(message.id) { Animatable(0f) } + LaunchedEffect(message.id) { entry.animateTo(1f, MessageEntrySpec) } + // A draw-time transform only: no measure, no layout, and no recomposition of the + // message content on any frame of the animation. + Modifier.graphicsLayer { + val progress = entry.value + alpha = progress + translationY = (1f - progress) * MessageEntrySlide.toPx() + } + } else { + Modifier + } + + MessageItem( + message = message, + messages = messages, + currentUserNickname = currentUserNickname, + meshService = meshService, + mentionPeerIdentities = resolvedMentionPeerIdentities, + showSender = !isGrouped, + topSpacing = MessageGrouping.topSpacingFor( + isGrouped = isGrouped, + isFirstInList = originalIndex == 0 + ), + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + onCancelTransfer = onCancelTransfer, + onImageClick = onImageClick, + modifier = Modifier + // Animates the shift when a neighbour is inserted or removed: this is what + // makes the conversation glide up instead of jumping. + .animateItem( + // Entry fade is handled by entryModifier, together with the slide, so the + // two cannot drift out of step. + fadeInSpec = null, + placementSpec = if (placementArmed) MessagePlacementSpec else null, + fadeOutSpec = MessageFadeOutSpec + ) + .then(entryModifier) + ) } } } @@ -146,16 +369,22 @@ fun MessageItem( currentUserNickname: String, meshService: MeshService, messages: List = emptyList(), + mentionPeerIdentities: Map = emptyMap(), + showSender: Boolean = true, + topSpacing: Dp = 0.dp, onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, onCancelTransfer: ((BitchatMessage) -> Unit)? = null, - onImageClick: ((String, List, Int) -> Unit)? = null + onImageClick: ((String, List, Int) -> Unit)? = null, + modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) } - + val timeFormatter = remember { SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) } + Column( - modifier = Modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .padding(top = topSpacing), verticalArrangement = Arrangement.spacedBy(0.dp) ) { Box(modifier = Modifier.fillMaxWidth()) { @@ -172,8 +401,10 @@ fun MessageItem( messages = messages, currentUserNickname = currentUserNickname, meshService = meshService, + mentionPeerIdentities = mentionPeerIdentities, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -209,14 +440,18 @@ fun MessageItem( messages: List, currentUserNickname: String, meshService: MeshService, + mentionPeerIdentities: Map, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, + showSender: Boolean, onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, onImageClick: ((String, List, Int) -> Unit)?, modifier: Modifier = Modifier ) { + val palette = LocalBitchatPalette.current + // Image special rendering if (message.type == BitchatMessageType.Image) { com.bitchat.android.ui.media.ImageMessageItem( @@ -226,6 +461,7 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -243,6 +479,7 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -268,9 +505,11 @@ fun MessageItem( val headerText = formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter + myPeerID = meshService.myPeerID, + palette = palette, + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + includeSender = showSender ) val haptic = LocalHapticFeedback.current AnnotatedClickableText( @@ -286,7 +525,7 @@ fun MessageItem( } }, onLongPress = { onMessageLongPress?.invoke(message) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface, ) @@ -335,15 +574,24 @@ fun MessageItem( .align(Alignment.TopEnd) .padding(4.dp) .size(22.dp) - .background(Color.Gray.copy(alpha = 0.6f), CircleShape) + .background(colorScheme.surfaceVariant.copy(alpha = 0.85f), CircleShape) .clickable { onCancelTransfer?.invoke(message) }, contentAlignment = Alignment.Center ) { - Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp)) + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.cd_cancel), + tint = colorScheme.onSurface, + modifier = Modifier.size(14.dp) + ) } } } else { - Text(text = stringResource(R.string.file_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray) + Text( + text = stringResource(R.string.file_unavailable), + fontFamily = BitchatFontFamily, + color = palette.textTertiary + ) } } } @@ -351,31 +599,15 @@ fun MessageItem( return } - // Check if this message should be animated during PoW mining - val shouldAnimate = shouldAnimateMessage(message.id) - - // If animation is needed, use the matrix animation component for content only - if (shouldAnimate) { - // Display message with matrix animation for content - MessageWithMatrixAnimation( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress, - modifier = modifier - ) - } else if (message.sender == "system") { - // Keep system messages on the compact legacy line. - val annotatedText = formatMessageAsAnnotatedString( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter - ) + if (message.sender == "system") { + // Background narration: `// Tor started. Routing all chats…` + val annotatedText = remember(message, colorScheme.onSurface) { + formatSystemMessage( + message = message, + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter + ) + } val haptic = LocalHapticFeedback.current Text( @@ -388,11 +620,11 @@ fun MessageItem( } ) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, softWrap = true, overflow = TextOverflow.Visible, - style = androidx.compose.ui.text.TextStyle( - color = colorScheme.onSurface + style = ChatVisualTokens.SystemActionStyle.copy( + color = colorScheme.onSurface.copy(alpha = ChatVisualTokens.MutedTextAlpha), ) ) } else { @@ -400,8 +632,10 @@ fun MessageItem( message = message, currentUserNickname = currentUserNickname, meshService = meshService, + mentionPeerIdentities = mentionPeerIdentities, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, modifier = modifier, @@ -414,37 +648,47 @@ internal fun TextMessageLayout( message: BitchatMessage, currentUserNickname: String, meshService: MeshService, + mentionPeerIdentities: Map = emptyMap(), colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, modifier: Modifier = Modifier, + showSender: Boolean = true, bodyContent: String = message.content, ) { + val palette = LocalBitchatPalette.current val myPeerId = meshService.myPeerID val displayMessage = remember(message, bodyContent) { if (bodyContent == message.content) message else message.copy(content = bodyContent) } - val senderText = remember(message, currentUserNickname, myPeerId, colorScheme) { + val senderText = remember(message, currentUserNickname, myPeerId, palette) { formatTextMessageSender( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, + myPeerID = myPeerId, + palette = palette, ) } - val metadataText = remember(message.timestamp, message.powDifficulty, timeFormatter) { - formatTextMessageMetadata( - message = message, - timeFormatter = timeFormatter, - ) - } - val bodyText = remember(displayMessage, currentUserNickname, myPeerId, colorScheme) { + // The timestamp trails the body rather than occupying its own column, so a short message + // no longer reserves a full-width row for eight grey characters. + val bodyText = remember( + displayMessage, + currentUserNickname, + palette, + colorScheme.onSurface, + colorScheme.secondary, + mentionPeerIdentities, + timeFormatter + ) { formatTextMessageBody( message = displayMessage, currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + mentionPeerIdentities = mentionPeerIdentities, + timeFormatter = timeFormatter, ) } val isSelf = message.isFromSelf(currentUserNickname, myPeerId) @@ -457,12 +701,9 @@ internal fun TextMessageLayout( Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(MessageGrouping.SENDER_TO_BODY_SPACING), ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { + if (showSender) { AnnotatedClickableText( text = senderText, annotationTags = listOf("nickname_click"), @@ -476,18 +717,13 @@ internal fun TextMessageLayout( } }, onLongPress = handleLongPress, - modifier = Modifier.weight(1f), - fontFamily = FontFamily.Monospace, + modifier = Modifier + .fillMaxWidth() + .padding(top = MessageGrouping.SENDER_TOP_PADDING), + fontFamily = BitchatFontFamily, softWrap = false, overflow = TextOverflow.Ellipsis, - ) - AnnotatedClickableText( - text = metadataText, - annotationTags = emptyList(), - onAnnotationClick = { _, _ -> false }, - onLongPress = handleLongPress, - fontFamily = FontFamily.Monospace, - softWrap = false, + style = MessageSenderTextStyle, ) } @@ -512,10 +748,10 @@ internal fun TextMessageLayout( } }, onLongPress = handleLongPress, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, softWrap = true, overflow = TextOverflow.Visible, - style = androidx.compose.ui.text.TextStyle(color = colorScheme.onSurface), + style = MessageBodyTextStyle.copy(color = colorScheme.onSurface), ) } } @@ -523,53 +759,40 @@ internal fun TextMessageLayout( @Composable fun DeliveryStatusIcon(status: DeliveryStatus) { val colorScheme = MaterialTheme.colorScheme - - when (status) { - is DeliveryStatus.Sending -> { - Text( - text = stringResource(R.string.status_sending), - fontSize = 10.sp, - color = colorScheme.primary.copy(alpha = 0.6f) - ) - } - is DeliveryStatus.Sent -> { - // Use a subtle hollow marker for Sent; single check is reserved for Delivered (iOS parity) - Text( - text = stringResource(R.string.status_pending), - fontSize = 10.sp, - color = colorScheme.primary.copy(alpha = 0.6f) - ) - } - is DeliveryStatus.Delivered -> { - // Single check for Delivered (matches iOS expectations) - Text( - text = stringResource(R.string.status_sent), - fontSize = 10.sp, - color = colorScheme.primary.copy(alpha = 0.8f) - ) - } - is DeliveryStatus.Read -> { - Text( - text = stringResource(R.string.status_delivered), - fontSize = 10.sp, - color = Color(0xFF007AFF), // Blue - fontWeight = FontWeight.Bold - ) - } - is DeliveryStatus.Failed -> { - Text( - text = stringResource(R.string.status_failed), - fontSize = 10.sp, - color = Color.Red.copy(alpha = 0.8f) - ) - } - is DeliveryStatus.PartiallyDelivered -> { - // Show a single subdued check without numeric label - Text( - text = stringResource(R.string.status_sent), - fontSize = 10.sp, - color = colorScheme.primary.copy(alpha = 0.6f) - ) + + // Status advances on its own as acks come back, so a hard glyph swap reads as a flicker. + // Keyed on the status *type* rather than the instance, because Delivered/Read carry a + // timestamp that would otherwise retrigger the transition on every identical update. + AnimatedContent( + targetState = status::class, + transitionSpec = { + fadeIn(tween(BitchatMotion.STANDARD_MS)) togetherWith + fadeOut(tween(BitchatMotion.QUICK_MS)) + }, + label = "deliveryStatus" + ) { statusClass -> + val (text, color, weight) = when (statusClass) { + DeliveryStatus.Sending::class -> + Triple(R.string.status_sending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) + // Subtle hollow marker for Sent; a single check is reserved for Delivered (iOS parity). + DeliveryStatus.Sent::class -> + Triple(R.string.status_pending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) + DeliveryStatus.Delivered::class -> + Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.8f), FontWeight.Normal) + DeliveryStatus.Read::class -> + Triple(R.string.status_delivered, colorScheme.secondary, FontWeight.Bold) + DeliveryStatus.Failed::class -> + Triple(R.string.status_failed, colorScheme.error, FontWeight.Normal) + // A single subdued check, without the numeric label. + else -> + Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) } + + Text( + text = stringResource(text), + fontSize = 10.sp, + color = color, + fontWeight = weight + ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt new file mode 100644 index 00000000..9b3278c4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt @@ -0,0 +1,84 @@ +package com.bitchat.android.ui + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.theme.ChatVisualTokens + +/** + * Consecutive-message grouping for the chat surface. + * + * The redesign suppresses the `@sender` label on runs of messages from the same author so the + * eye only has to register a name when the speaker actually changes. The transcript uses one + * consistent item rhythm; new groups get their additional separation from the visible sender + * row's own top padding. + */ +object MessageGrouping { + + /** Space above the first message of a group (i.e. one that renders its sender label). */ + val NEW_GROUP_SPACING: Dp = ChatVisualTokens.MessageItemSpacing + + /** Space above a continuation message inside an existing group. */ + val GROUPED_SPACING: Dp = ChatVisualTokens.MessageItemSpacing + + /** Gap between the sender label and the first line of the body. */ + val SENDER_TO_BODY_SPACING: Dp = ChatVisualTokens.SenderToBodySpacing + + /** Top inset inside a visible sender row. */ + val SENDER_TOP_PADDING: Dp = ChatVisualTokens.SenderTopPadding + + /** + * Messages further apart than this always start a new group, even from the same sender: + * a reply an hour later is a new thought, not a continuation. + */ + const val GROUPING_WINDOW_MS: Long = 5 * 60 * 1000L + + private const val SYSTEM_SENDER = "system" + + /** + * Whether [current] should be rendered as a continuation of [previous], hiding its sender + * label. + * + * [previous] is the message immediately *before* [current] in chronological order. Note the + * message list renders with `reverseLayout = true`, so callers must be careful to resolve + * the chronological predecessor rather than the visually preceding item. + */ + fun shouldGroup(previous: BitchatMessage?, current: BitchatMessage): Boolean { + if (previous == null) return false + + // System/action lines never participate in grouping in either direction: they are + // narration, and folding a real message into them would attribute it to "system". + if (previous.sender == SYSTEM_SENDER || current.sender == SYSTEM_SENDER) return false + + // Never group across the public/private boundary or between different channels. + if (previous.isPrivate != current.isPrivate) return false + if (previous.channel != current.channel) return false + + if (!isSameSender(previous, current)) return false + + val elapsed = current.timestamp.time - previous.timestamp.time + return elapsed in 0..GROUPING_WINDOW_MS + } + + /** + * Identity comparison. Peer IDs are authoritative when both messages carry one, because two + * different peers can share a nickname. Falls back to the display name (which includes the + * `#abcd` suffix) when peer IDs are unavailable, e.g. for locally injected messages. + */ + private fun isSameSender(previous: BitchatMessage, current: BitchatMessage): Boolean { + val previousPeerID = previous.senderPeerID + val currentPeerID = current.senderPeerID + return if (previousPeerID != null && currentPeerID != null) { + previousPeerID.equals(currentPeerID, ignoreCase = true) + } else { + previous.sender == current.sender + } + } + + /** Top padding for a message given whether it continues the previous author's run. */ + fun topSpacingFor(isGrouped: Boolean, isFirstInList: Boolean): Dp = when { + isFirstInList -> 0.dp + isGrouped -> GROUPED_SPACING + else -> NEW_GROUP_SPACING + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt b/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt new file mode 100644 index 00000000..47dd5629 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt @@ -0,0 +1,71 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import java.util.Locale + +/** + * Canonical, presentation-neutral identity used to derive a peer hue. + * + * Callers cannot construct arbitrary color seeds. Every identity is normalized and namespaced + * here so the same mesh or Nostr user resolves to the same color on every surface. + */ +@JvmInline +value class PeerIdentity private constructor(internal val stableKey: String) { + companion object { + fun mesh(peerID: String): PeerIdentity = + PeerIdentity("noise:${normalize(peerID)}") + + /** + * Preserve the established geohash-chat color mapping while accepting a full public key. + * + * Older chat rendering hashed `nostr:nostr:`. Keeping that visual + * key here avoids recoloring existing conversations; the important change is that every + * surface now derives it from the same canonical Nostr identity. + */ + fun nostr(pubkeyHex: String): PeerIdentity { + val normalized = normalizeNostrIdentifier(pubkeyHex) + return PeerIdentity("nostr:nostr:${normalized.take(8)}") + } + + /** + * Last-resort identity for legacy messages and mentions that carry no stable peer ID. + */ + fun nickname(nickname: String): PeerIdentity = + PeerIdentity(normalize(nickname)) + + private fun normalize(value: String): String = + value.trim().lowercase(Locale.ROOT) + + private fun normalizeNostrIdentifier(value: String): String { + var normalized = normalize(value) + while (normalized.startsWith("nostr:") || normalized.startsWith("nostr_")) { + normalized = normalized.removePrefix("nostr:").removePrefix("nostr_") + } + return normalized + } + } +} + +/** + * Resolve the canonical identity attached to a rendered message. + * + * Full Nostr keys take precedence over routing aliases such as `nostr_abcd…`; aliases remain a + * compatibility fallback for messages saved by older app versions. + */ +fun peerIdentityForMessage(message: BitchatMessage): PeerIdentity { + message.senderNostrPubkey?.takeIf { it.isNotBlank() }?.let { + return PeerIdentity.nostr(it) + } + + val senderPeerID = message.senderPeerID + return when { + senderPeerID?.startsWith("nostr:", ignoreCase = true) == true || + senderPeerID?.startsWith("nostr_", ignoreCase = true) == true -> { + PeerIdentity.nostr(senderPeerID) + } + senderPeerID?.length == 16 || senderPeerID?.length == 64 -> { + PeerIdentity.mesh(senderPeerID) + } + else -> PeerIdentity.nickname(message.sender) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt b/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt deleted file mode 100644 index 883b2b45..00000000 --- a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.bitchat.android.ui - -import androidx.compose.animation.core.* -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Security -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.bitchat.android.nostr.NostrProofOfWork -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bitchat.android.R -import com.bitchat.android.nostr.PoWPreferenceManager - -/** - * Shows the current Proof of Work status and settings - */ -@Composable -fun PoWStatusIndicator( - modifier: Modifier = Modifier, - style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT -) { - val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle() - val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle() - val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle() - val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - - if (!powEnabled) return - - when (style) { - PoWIndicatorStyle.COMPACT -> { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // PoW icon with animation if mining - if (isMining) { - val rotation by rememberInfiniteTransition(label = "pow-rotation").animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "pow-icon-rotation" - ) - - Icon( - imageVector = Icons.Filled.Security, - contentDescription = stringResource(R.string.cd_mining_pow), - tint = Color(0xFFFF9500), // Orange for mining - modifier = Modifier - .size(12.dp) - .graphicsLayer { rotationZ = rotation } - ) - } else { - Icon( - imageVector = Icons.Filled.Security, - contentDescription = stringResource(R.string.cd_pow_enabled), - tint = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), // Green when ready - modifier = Modifier.size(12.dp) - ) - } - } - } - - PoWIndicatorStyle.DETAILED -> { - Surface( - modifier = modifier, - color = colorScheme.surfaceVariant.copy(alpha = 0.3f), - shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // PoW icon - Icon( - imageVector = Icons.Filled.Security, - contentDescription = stringResource(R.string.cd_proof_of_work), - tint = if (isMining) Color(0xFFFF9500) else { - if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - }, - modifier = Modifier.size(14.dp) - ) - - // Status text - Text( - text = if (isMining) { - stringResource(R.string.pow_mining_ellipsis) - } else { - stringResource(R.string.pow_label_format, powDifficulty) - }, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = if (isMining) Color(0xFFFF9500) else { - colorScheme.onSurface.copy(alpha = 0.7f) - } - ) - - // Time estimate - if (!isMining && powDifficulty > 0) { - Text( - text = stringResource(R.string.pow_time_estimate, NostrProofOfWork.estimateMiningTime(powDifficulty)), - fontSize = 9.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f) - ) - } - } - } - } - } -} - -/** - * Style options for the PoW status indicator - */ -enum class PoWIndicatorStyle { - COMPACT, // Small icon + difficulty number - DETAILED // Icon + status text + time estimate -} diff --git a/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt b/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt new file mode 100644 index 00000000..01c9c6ca --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale + +/** + * Press feedback for the app's icon buttons. + * + * Terminal-style chrome has no elevation and no fills to lean on, so a Material ripple has almost + * nothing to show. A brief scale dip is legible on any background and reads as physical. Springs + * rather than tweens, so releasing overshoots very slightly instead of stopping dead. + */ +@Composable +fun rememberPressScale( + interactionSource: InteractionSource, + pressedScale: Float = 0.86f +): Float { + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) pressedScale else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "pressScale" + ) + return scale +} + +/** + * Convenience wrapper for the common case: a clickable that scales while held. + * + * Returns the modifier chain to apply, having disabled the default indication — the scale *is* the + * indication, and a ripple underneath it just muddies the edges of these small glyphs. + */ +@Composable +fun Modifier.pressScaleClickable( + onClick: () -> Unit, + enabled: Boolean = true, + onClickLabel: String? = null, + pressedScale: Float = 0.86f +): Modifier { + val interactionSource = remember { MutableInteractionSource() } + val scale = rememberPressScale(interactionSource, pressedScale) + return this + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + onClickLabel = onClickLabel, + onClick = onClick + ) + .scale(scale) +} diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index 2633f888..40da30be 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -1,9 +1,15 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.NoEncryption +import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material.icons.outlined.Warning as OutlinedWarning import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,13 +18,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Verified -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material.icons.outlined.NoEncryption -import androidx.compose.material.icons.outlined.Sync -import androidx.compose.material.icons.outlined.Warning as OutlinedWarning import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenu @@ -39,14 +38,15 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet private data class SecurityStatusInfo( @@ -69,9 +69,9 @@ fun SecurityVerificationSheet( val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() - val isDark = isSystemInDarkTheme() - val accent = if (isDark) Color.Green else Color(0xFF008000) - val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f) + val colorScheme = MaterialTheme.colorScheme + val accent = colorScheme.primary + val boxColor = colorScheme.surfaceVariant val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") } BitchatBottomSheet( @@ -92,7 +92,7 @@ fun SecurityVerificationSheet( if (peerID == null) { Text( text = stringResource(R.string.fingerprint_no_peer), - style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) } else { @@ -155,13 +155,14 @@ private fun SecurityVerificationHeader( Text( text = stringResource(R.string.security_verification_title), style = MaterialTheme.typography.titleSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), color = accent ) Spacer(modifier = Modifier.weight(1f)) - CloseButton(onClick = onClose) + val dismiss = LocalSheetDismiss.current + CloseButton(onClick = { dismiss?.invoke() ?: onClose() }) } } @@ -219,7 +220,7 @@ private fun SecurityStatusCard( Text( text = displayName, style = MaterialTheme.typography.titleMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), color = accent @@ -227,7 +228,7 @@ private fun SecurityStatusCard( Text( text = statusInfo.text, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = accent.copy(alpha = 0.8f) ) @@ -257,7 +258,7 @@ private fun SecurityVerificationActions( ) { Text( text = stringResource(R.string.fingerprint_start_handshake), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -273,7 +274,7 @@ private fun SecurityVerificationActions( Text( text = stringResource(R.string.fingerprint_verified_message), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = accent.copy(alpha = 0.7f), modifier = Modifier.fillMaxWidth(), @@ -289,7 +290,7 @@ private fun SecurityVerificationActions( ) { Text( text = stringResource(R.string.verify_remove), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -303,7 +304,7 @@ private fun SecurityVerificationActions( Text( text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = accent.copy(alpha = 0.7f), modifier = Modifier.fillMaxWidth(), @@ -320,7 +321,7 @@ private fun SecurityVerificationActions( ) { Text( text = stringResource(R.string.fingerprint_mark_verified), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -349,7 +350,7 @@ private fun VerificationStatusRow( Text( text = text, style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), color = textTint @@ -375,7 +376,7 @@ private fun FingerprintBlock( Text( text = title, style = MaterialTheme.typography.labelSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), color = accent.copy(alpha = 0.8f) @@ -385,7 +386,7 @@ private fun FingerprintBlock( Text( text = formatFingerprint(fingerprint), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp ), color = accent, @@ -417,7 +418,7 @@ private fun FingerprintBlock( } else { Text( text = stringResource(R.string.fingerprint_pending), - style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily), color = Color(0xFFFF9500), modifier = Modifier.padding(16.dp) ) diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index 97392eae..a84c3c94 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.QrCodeScanner import android.graphics.Bitmap import android.os.Handler import android.os.Looper @@ -17,7 +19,6 @@ import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,8 +32,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.QrCodeScanner import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -60,7 +59,6 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -70,8 +68,10 @@ import androidx.core.graphics.createBitmap import androidx.core.graphics.set import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.services.VerificationService import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -98,8 +98,7 @@ fun VerificationSheet( ) { if (!isPresented) return - val isDark = isSystemInDarkTheme() - val accent = if (isDark) Color.Green else Color(0xFF008000) + val accent = MaterialTheme.colorScheme.primary var selectedTab by remember { mutableStateOf(0) } // 0 = My Code, 1 = Scan val nickname by viewModel.nickname.collectAsStateWithLifecycle() @@ -144,7 +143,7 @@ fun VerificationSheet( text = { Text( text = "My QR", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp ) } @@ -155,7 +154,7 @@ fun VerificationSheet( text = { Text( text = "Scan", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp ) } @@ -208,7 +207,7 @@ fun VerificationSheet( ) { Text( text = stringResource(R.string.verify_remove), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -232,10 +231,11 @@ private fun VerificationHeader( Text( text = stringResource(R.string.verify_title).uppercase(), fontSize = 14.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = accent ) - CloseButton(onClick = onClose) + val dismiss = LocalSheetDismiss.current + CloseButton(onClick = { dismiss?.invoke() ?: onClose() }) } } @@ -258,7 +258,7 @@ private fun MyQrTabContent( Text( text = stringResource(R.string.verify_my_qr_title), style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = accent ) @@ -283,7 +283,7 @@ private fun MyQrTabContent( ) { Text( text = stringResource(R.string.verify_qr_unavailable), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = Color.Black.copy(alpha = 0.6f) ) @@ -296,7 +296,7 @@ private fun MyQrTabContent( Text( text = nickname, style = MaterialTheme.typography.headlineSmall, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center ) @@ -307,7 +307,7 @@ private fun MyQrTabContent( Text( text = stringResource(R.string.app_name).lowercase(), style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), textAlign = TextAlign.Center ) @@ -355,7 +355,7 @@ private fun ScanTabContent( Text( text = stringResource(R.string.verify_scan_prompt_friend), color = Color.White, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, modifier = Modifier .align(Alignment.BottomCenter) @@ -386,7 +386,7 @@ private fun ScanTabContent( Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.verify_camera_permission), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurface ) @@ -397,7 +397,7 @@ private fun ScanTabContent( ) { Text( text = stringResource(R.string.verify_request_camera), - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index c61f2f9a..c9922bf1 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -1,24 +1,18 @@ package com.bitchat.android.ui -import android.Manifest -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic +import android.Manifest +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.unit.dp import com.bitchat.android.features.voice.VoiceRecorder import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus @@ -27,21 +21,59 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** + * How long the button must be held before a recording starts. + * + * Push-to-talk should require intent. Firing on raw pointer-down meant a stray touch started a + * recording, and it also made the control impossible to defend against pointer events it should + * never have seen (see [ArmDelayMs]). + */ +private const val HoldToRecordMs = 220L + +/** + * How long the button ignores presses after entering composition. + * + * The action cluster swaps send out for camera+microphone the instant the field is cleared, which + * puts the microphone exactly where the send button was a frame earlier. Tapping send quickly + * could hand the microphone a pointer-down whose matching pointer-up had already been delivered + * to the send button that no longer exists — leaving the gesture waiting for a release that will + * never come, stuck in "recording" until the composable is disposed. Refusing presses until the + * swap animation has settled removes that whole class of failure. + */ +private const val ArmDelayMs = 350L + +/** Hard cap on a single recording. */ +private const val MaxRecordingMs = 10_000L + +/** Tail kept after release so the last syllable is not clipped. */ +private const val ReleaseTailMs = 500L @OptIn(ExperimentalPermissionsApi::class) @Composable fun VoiceRecordButton( modifier: Modifier = Modifier, - backgroundColor: Color, + /** + * Recording state as the composer sees it. Drives the active tint so the button and the + * pill's border change together instead of one lagging the other. + */ + isRecording: Boolean = false, onStart: () -> Unit, onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit, - onFinish: (filePath: String) -> Unit + onFinish: (filePath: String) -> Unit, + /** + * Invoked whenever a recording ends without producing a file — permission denied, recorder + * failure, or the button being torn down mid-capture. The caller needs this to clear its own + * recording state; without it a failed capture left the composer stuck in recording mode. + */ + onCancel: () -> Unit = {} ) { val context = LocalContext.current val haptic = LocalHapticFeedback.current val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO) - var isRecording by remember { mutableStateOf(false) } + var isCapturing by remember { mutableStateOf(false) } var recorder by remember { mutableStateOf(null) } var recordedFilePath by remember { mutableStateOf(null) } var recordingStart by remember { mutableStateOf(0L) } @@ -53,71 +85,127 @@ fun VoiceRecordButton( val latestOnStart = rememberUpdatedState(onStart) val latestOnAmplitude = rememberUpdatedState(onAmplitude) val latestOnFinish = rememberUpdatedState(onFinish) + val latestOnCancel = rememberUpdatedState(onCancel) - Box( + // Set when this instance was composed, so presses inherited from whatever occupied this spot + // beforehand can be rejected. + val composedAt = remember { System.currentTimeMillis() } + + fun buzz() { + try { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } catch (_: Exception) { + } + } + + // Last line of defence: if the button is removed while capturing — the cluster swapping to + // send, the sheet closing, the screen going away — release the recorder and tell the caller, + // so nothing is left holding the microphone or showing a recording UI. + DisposableEffect(Unit) { + onDispose { + ampJob?.cancel() + ampJob = null + if (isCapturing) { + isCapturing = false + runCatching { recorder?.stop() } + recorder = null + recordedFilePath = null + latestOnCancel.value() + } + } + } + + // Same disc, same sizing and the same press feedback as the camera and send buttons. + ComposerActionSurface( + isActive = isRecording || isCapturing, + isPressed = isCapturing, modifier = modifier - .size(32.dp) - .background(backgroundColor, CircleShape) .pointerInput(Unit) { detectTapGestures( onPress = { - if (!isRecording) { - if (micPermission.status !is PermissionStatus.Granted) { - micPermission.launchPermissionRequest() - return@detectTapGestures - } - val rec = VoiceRecorder(context) - val f = rec.start() - recorder = rec - isRecording = f != null - recordedFilePath = f?.absolutePath - recordingStart = System.currentTimeMillis() - if (isRecording) { - latestOnStart.value() - // Haptic "knock" when recording starts - try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {} - // Start amplitude polling loop - ampJob?.cancel() - ampJob = scope.launch { - while (isActive && isRecording) { - val amp = recorder?.pollAmplitude() ?: 0 - val elapsedMs = (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L) - latestOnAmplitude.value(amp, elapsedMs) - // Auto-stop after 10 seconds - if (elapsedMs >= 10_000 && isRecording) { - val file = recorder?.stop() - isRecording = false - recorder = null - val path = file?.absolutePath - if (!path.isNullOrBlank()) { - // Haptic "knock" on auto stop - try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {} - latestOnFinish.value(path) - } - break - } - delay(80) + // Guard 1: ignore anything arriving before the swap animation settled. + if (System.currentTimeMillis() - composedAt < ArmDelayMs) { + return@detectTapGestures + } + // Guard 2: never start a second capture on top of a live one. + if (isCapturing) return@detectTapGestures + + if (micPermission.status !is PermissionStatus.Granted) { + micPermission.launchPermissionRequest() + return@detectTapGestures + } + + // Guard 3: require a deliberate hold. `tryAwaitRelease` returns true on + // release and false on cancellation; either way the press was not a hold, + // so nothing should happen. Only a timeout means the finger is still down. + val stillHeld = withTimeoutOrNull(HoldToRecordMs) { + tryAwaitRelease() + } == null + if (!stillHeld) return@detectTapGestures + + val rec = VoiceRecorder(context) + val startedFile = rec.start() + if (startedFile == null) { + // Recorder refused to start; make sure the caller does not sit in a + // recording state that never began. + runCatching { rec.stop() } + latestOnCancel.value() + return@detectTapGestures + } + + recorder = rec + recordedFilePath = startedFile.absolutePath + recordingStart = System.currentTimeMillis() + isCapturing = true + latestOnStart.value() + buzz() + + ampJob?.cancel() + ampJob = scope.launch { + while (isActive && isCapturing) { + val amp = recorder?.pollAmplitude() ?: 0 + val elapsed = + (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L) + latestOnAmplitude.value(amp, elapsed) + + if (elapsed >= MaxRecordingMs && isCapturing) { + val file = recorder?.stop() + isCapturing = false + recorder = null + val path = file?.absolutePath ?: recordedFilePath + recordedFilePath = null + buzz() + // Always report the outcome, even when the file is unusable, + // or the caller stays stuck showing the waveform. + if (!path.isNullOrBlank()) { + latestOnFinish.value(path) + } else { + latestOnCancel.value() } + break } + delay(80) } } + try { - awaitRelease() + tryAwaitRelease() } finally { - if (isRecording) { - // Extend recording for 500ms after release to avoid clipping - delay(500) + if (isCapturing) { + // Keep going briefly past the release so the tail is not clipped. + delay(ReleaseTailMs) } - if (isRecording) { + if (isCapturing) { val file = recorder?.stop() - isRecording = false + isCapturing = false recorder = null - val path = (file?.absolutePath ?: recordedFilePath) + val path = file?.absolutePath ?: recordedFilePath recordedFilePath = null + buzz() if (!path.isNullOrBlank()) { - // Haptic "knock" when recording stops - try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {} latestOnFinish.value(path) + } else { + latestOnCancel.value() } } ampJob?.cancel() @@ -125,14 +213,13 @@ fun VoiceRecordButton( } } ) - }, - contentAlignment = Alignment.Center - ) { + } + ) { tint -> Icon( imageVector = Icons.Filled.Mic, contentDescription = stringResource(com.bitchat.android.R.string.cd_record_voice), - tint = Color.Black, - modifier = Modifier.size(20.dp) + tint = tint, + modifier = Modifier.size(ComposerIconSize) ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 32427c44..9c8c48b5 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -3,6 +3,14 @@ package com.bitchat.android.ui.debug import android.content.ClipData import android.content.ClipboardManager import android.widget.Toast +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.WifiTethering +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.SettingsEthernet import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -12,25 +20,17 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bluetooth -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material.icons.filled.WifiTethering -import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material.icons.filled.Devices -import androidx.compose.material.icons.filled.PowerSettingsNew -import androidx.compose.material.icons.filled.SettingsEthernet import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.draw.rotate +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.services.meshgraph.MeshGraphService import kotlinx.coroutines.launch @@ -67,13 +67,13 @@ fun MeshTopologySection( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93)) - Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Mesh topology", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } val nodes = snapshot.nodes val edges = snapshot.edges val empty = nodes.isEmpty() if (empty) { - Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("No gossip yet", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { ForceDirectedMeshGraph( nodes = nodes, @@ -94,10 +94,10 @@ fun MeshTopologySection( verticalArrangement = Arrangement.spacedBy(4.dp) ) { nodes.forEach { node -> - val label = "${node.peerID.take(8)} • ${node.nickname ?: "unknown"}" + val label = "${node.peerID.take(8)} • ${node.nickname ?: "Unknown"}" Text( text = label, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.85f) ) @@ -128,7 +128,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF5856D6)) Text( "Distribution info", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium ) @@ -137,7 +137,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI if (info == null) { Text( "Inspecting installed package…", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -170,7 +170,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI }, contentPadding = PaddingValues(horizontal = 0.dp) ) { - Text("Copy certificate fingerprint", fontFamily = FontFamily.Monospace) + Text("Copy certificate fingerprint", fontFamily = BitchatFontFamily) } } } @@ -184,13 +184,13 @@ private fun DistributionInfoRow(label: String, value: String) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( label, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.55f) ) Text( value, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.9f) ) @@ -347,7 +347,7 @@ fun DebugSettingsSheet( item { Text( text = stringResource(R.string.debug_tools_desc), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -361,13 +361,13 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF00C851)) - Text(stringResource(R.string.debug_verbose_logging), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_verbose_logging), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) Switch(checked = verboseLogging, onCheckedChange = { manager.setVerboseLoggingEnabled(it) }) } Text( stringResource(R.string.debug_verbose_hint), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -392,10 +392,10 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) - Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_gatt_server), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text(stringResource(R.string.debug_gatt_server), fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) Switch(checked = gattServerEnabled, onCheckedChange = { manager.setGattServerEnabled(it) scope.launch { @@ -404,9 +404,9 @@ fun DebugSettingsSheet( }) } val serverCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_SERVER } - Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_max_server), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp)) + Text(stringResource(R.string.debug_max_server), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp)) Slider( value = maxServer.toFloat(), onValueChange = { manager.setMaxServerConnections(it.toInt().coerceAtLeast(1)) }, @@ -415,7 +415,7 @@ fun DebugSettingsSheet( ) } Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_gatt_client), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text(stringResource(R.string.debug_gatt_client), fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) Switch(checked = gattClientEnabled, onCheckedChange = { manager.setGattClientEnabled(it) scope.launch { @@ -424,9 +424,9 @@ fun DebugSettingsSheet( }) } val clientCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_CLIENT } - Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_max_client), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp)) + Text(stringResource(R.string.debug_max_client), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp)) Slider( value = maxClient.toFloat(), onValueChange = { manager.setMaxClientConnections(it.toInt().coerceAtLeast(1)) }, @@ -435,9 +435,9 @@ fun DebugSettingsSheet( ) } val overallCount = connectedDevices.size - Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_max_overall), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp)) + Text(stringResource(R.string.debug_max_overall), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp)) Slider( value = maxOverall.toFloat(), onValueChange = { manager.setMaxConnectionsOverall(it.toInt().coerceAtLeast(1)) }, @@ -447,7 +447,7 @@ fun DebugSettingsSheet( } Text( stringResource(R.string.debug_roles_hint), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -461,12 +461,12 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50)) - Text("Transports", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Transports", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) Spacer(Modifier.width(8.dp)) - Text("BLE", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text("BLE", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) Switch(checked = bleEnabled, onCheckedChange = { manager.setBleEnabled(it) }) @@ -474,7 +474,7 @@ fun DebugSettingsSheet( Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Filled.Wifi, contentDescription = null, tint = Color(0xFF9C27B0)) Spacer(Modifier.width(8.dp)) - Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text("Wi‑Fi Aware", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) val wifiSwitchEnabled = wifiAwareSupported Text( when { @@ -482,7 +482,7 @@ fun DebugSettingsSheet( wifiAwareAvailable -> "available" else -> "unavailable" }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -497,7 +497,7 @@ fun DebugSettingsSheet( } Row(verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.width(24.dp)) - Text("Wi‑Fi Aware verbose", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text("Wi‑Fi Aware verbose logging", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) Switch(checked = wifiAwareVerbose, onCheckedChange = { manager.setWifiAwareVerbose(it) }) } } @@ -512,7 +512,7 @@ fun DebugSettingsSheet( Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500)) - Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_packet_relay), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) Switch(checked = packetRelayed, onCheckedChange = { manager.setPacketRelayEnabled(it) }) } @@ -648,10 +648,10 @@ fun DebugSettingsSheet( // Helper functions moved to top-level composable below to avoid scope issues // Render two blocks: Incoming and Outgoing - Text("Incoming", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text("Incoming", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Text( "${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}", - fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) DrawGraphBlock( title = "Incoming", @@ -704,10 +704,10 @@ fun DebugSettingsSheet( if (graphMode != GraphMode.OVERALL && stackedKeysIncoming.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ } Spacer(Modifier.height(8.dp)) - Text("Outgoing", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text("Outgoing", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Text( "${relayStats.lastSecondOutgoing}/s • ${relayStats.lastMinuteOutgoing}/m • ${relayStats.last15MinuteOutgoing}/15m • total ${relayStats.totalOutgoingCount}", - fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) DrawGraphBlock( title = "Outgoing", @@ -770,7 +770,7 @@ fun DebugSettingsSheet( val running by com.bitchat.android.wifiaware.WifiAwareController.running.collectAsState() Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.WifiTethering, contentDescription = null, tint = Color(0xFF9C27B0)) - Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Wi‑Fi Aware", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) val wifiStatusText = when { !wifiAwareSupported -> "unsupported" @@ -778,12 +778,12 @@ fun DebugSettingsSheet( !wifiAwareAvailable -> "unavailable" else -> "stopped" } - Text(wifiStatusText, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(wifiStatusText, fontFamily = BitchatFontFamily, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } if (!wifiAwareSupported) { Text( wifiAwareSupportStatus?.reason ?: "Wi-Fi Aware is not supported on this device", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -801,21 +801,21 @@ fun DebugSettingsSheet( label = { Text("Announce") } ) } - Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = BitchatFontFamily, fontSize = 12.sp) if (wifiAwareDiscovered.isEmpty()) { - Text("No discoveries yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("No discoveries yet", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { wifiAwareDiscovered.entries.take(50).forEach { (peer, nick) -> - Text("• ${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("• ${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = BitchatFontFamily, fontSize = 12.sp) } } Divider() - Text("Connected: ${wifiAwareConnected.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("Connected: ${wifiAwareConnected.size}", fontFamily = BitchatFontFamily, fontSize = 12.sp) if (wifiAwareConnected.isEmpty()) { - Text("No active sockets", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("No active sockets", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { wifiAwareConnected.entries.take(50).forEach { (peer, ip) -> - Text("• ${peer.take(8)}… @ $ip", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("• ${peer.take(8)}… @ $ip", fontFamily = BitchatFontFamily, fontSize = 12.sp) } } } @@ -828,17 +828,17 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF9C27B0)) - Text("sync settings", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Sync settings", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } - Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Slider(value = seenCapacity.toFloat(), onValueChange = { manager.setSeenPacketCapacity(it.toInt()) }, valueRange = 10f..1000f, steps = 99) - Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Slider(value = gcsMaxBytes.toFloat(), onValueChange = { manager.setGcsMaxBytes(it.toInt()) }, valueRange = 128f..1024f, steps = 0) - Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Slider(value = gcsFpr.toFloat(), onValueChange = { manager.setGcsFprPercent(it.toDouble()) }, valueRange = 0.1f..5.0f, steps = 49) val p = remember(gcsFpr) { com.bitchat.android.sync.GCSFilter.deriveP(gcsFpr / 100.0) } val nmax = remember(gcsFpr, gcsMaxBytes) { com.bitchat.android.sync.GCSFilter.estimateMaxElementsForSize(gcsMaxBytes, p) } - Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } } } @@ -849,22 +849,22 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50)) - Text(stringResource(R.string.debug_connected_devices), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_connected_devices), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } val localAddr = remember { meshService.connectionManager.getLocalAdapterAddress() } - Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) if (connectedDevices.isEmpty()) { - Text("none", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("None", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { connectedDevices.forEach { dev -> Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) { Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { - Text((dev.peerID ?: stringResource(R.string.unknown)) + " • ${dev.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text((dev.peerID ?: stringResource(R.string.unknown)) + " • ${dev.deviceAddress}", fontFamily = BitchatFontFamily, fontSize = 12.sp) val roleLabel = if (dev.connectionType == ConnectionType.GATT_SERVER) stringResource(R.string.debug_role_server) else stringResource(R.string.debug_role_client) - Text("${dev.nickname ?: ""} • " + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + " • $roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text("${dev.nickname ?: ""} • " + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + " • $roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } - Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable { + Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = BitchatFontFamily, modifier = Modifier.clickable { meshService.connectionManager.disconnectAddress(dev.deviceAddress) }) } @@ -881,19 +881,19 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) - Text(stringResource(R.string.debug_recent_scan_results), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_recent_scan_results), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } if (scanResults.isEmpty()) { - Text(stringResource(R.string.debug_none), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text(stringResource(R.string.debug_none), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { scanResults.forEach { res -> Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) { Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { - Text((res.peerID ?: stringResource(R.string.unknown)) + " • ${res.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) - Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text((res.peerID ?: stringResource(R.string.unknown)) + " • ${res.deviceAddress}", fontFamily = BitchatFontFamily, fontSize = 12.sp) + Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } - Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable { + Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = BitchatFontFamily, modifier = Modifier.clickable { meshService.connectionManager.connectToAddress(res.deviceAddress) }) } @@ -910,15 +910,15 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500)) - Text(stringResource(R.string.debug_debug_console), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_debug_console), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) - Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable { + Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = BitchatFontFamily, modifier = Modifier.clickable { manager.clearDebugMessages() }) } Column(Modifier.heightIn(max = 260.dp).background(colorScheme.surface.copy(alpha = 0.5f)).padding(8.dp)) { debugMessages.takeLast(100).reversed().forEach { msg -> - Text("${msg.content}", fontFamily = FontFamily.Monospace, fontSize = 11.sp) + Text("${msg.content}", fontFamily = BitchatFontFamily, fontSize = 11.sp) } } } @@ -1039,7 +1039,7 @@ private fun DrawGraphBlock( Box(Modifier.width(leftGutter).fillMaxHeight()) { Text( "p/s", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f) @@ -1053,14 +1053,14 @@ private fun DrawGraphBlock( } Text( topLabel, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp) ) Text( "0", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp) @@ -1085,8 +1085,8 @@ private fun DrawGraphBlock( ) { Box(Modifier.size(10.dp).background(swatchColor, RoundedCornerShape(2.dp))) Column { - Text(legendTitleFor(key), fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f)) - Text(legendMetricsFor(key), fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f)) + Text(legendTitleFor(key), fontFamily = BitchatFontFamily, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f)) + Text(legendMetricsFor(key), fontFamily = BitchatFontFamily, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f)) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt index 44c7ef55..3254c565 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt @@ -1,14 +1,14 @@ package com.bitchat.android.ui.debug +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.Bluetooth import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material.icons.outlined.Bluetooth import androidx.compose.material3.Icon import androidx.compose.runtime.* import androidx.compose.ui.Modifier diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 7c0a1ac6..c646d570 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -1,11 +1,11 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -13,14 +13,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.component.text.AnnotatedClickableText import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import androidx.compose.material3.ColorScheme +import com.bitchat.android.ui.theme.LocalBitchatPalette import java.text.SimpleDateFormat @Composable @@ -33,8 +34,10 @@ fun AudioMessageItem( onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showSender: Boolean = true ) { + val palette = LocalBitchatPalette.current val path = message.content.trim() // Derive sending progress if applicable val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) { @@ -50,9 +53,11 @@ fun AudioMessageItem( val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter + myPeerID = meshService.myPeerID, + palette = palette, + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + includeSender = showSender ) val haptic = LocalHapticFeedback.current AnnotatedClickableText( @@ -68,7 +73,7 @@ fun AudioMessageItem( } }, onLongPress = { onMessageLongPress?.invoke(message) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface, ) diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt index ca57931b..7a289686 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -12,8 +14,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Description import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon diff --git a/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt index 16d92d7a..bd5f30e0 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt @@ -1,11 +1,11 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Attachment import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Attachment import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt b/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt index 2f40dec0..51e65108 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -11,8 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Description import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.dp import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import kotlinx.coroutines.delay @@ -96,7 +97,7 @@ fun FileSendingAnimation( androidx.compose.material3.Text( text = revealedText, style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = Color.White ), modifier = Modifier.padding(end = 2.dp) @@ -107,7 +108,7 @@ fun FileSendingAnimation( androidx.compose.material3.Text( text = stringResource(R.string.underscore), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = Color.White ) ) @@ -149,7 +150,7 @@ private fun FileProgressBars( androidx.compose.material3.Text( text = progressString, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = Color(0xFF00FF7F) // Matrix green ), modifier = modifier diff --git a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt index 2964f9c0..3544b637 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt @@ -1,5 +1,8 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Download import android.content.ContentValues import android.os.Build import android.provider.MediaStore @@ -10,9 +13,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Download import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import java.io.File @@ -101,7 +102,7 @@ fun FullScreenImageViewer(imagePaths: List, initialIndex: Int = 0, onClo text = stringResource(R.string.image_counter, (pagerState.currentPage ?: 0) + 1, imagePaths.size), color = Color.White, fontSize = 14.sp, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 35b0bbea..5d9908fc 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -1,12 +1,12 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.* @@ -21,12 +21,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp -import androidx.compose.ui.text.font.FontFamily +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import androidx.compose.material3.ColorScheme import com.bitchat.android.core.ui.component.text.AnnotatedClickableText +import com.bitchat.android.ui.theme.LocalBitchatPalette import java.text.SimpleDateFormat @Composable @@ -41,16 +42,20 @@ fun ImageMessageItem( onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, onImageClick: ((String, List, Int) -> Unit)?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showSender: Boolean = true ) { + val palette = LocalBitchatPalette.current val path = message.content.trim() Column(modifier = modifier.fillMaxWidth()) { val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter + myPeerID = meshService.myPeerID, + palette = palette, + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + includeSender = showSender ) val haptic = LocalHapticFeedback.current AnnotatedClickableText( @@ -66,7 +71,7 @@ fun ImageMessageItem( } }, onLongPress = { onMessageLongPress?.invoke(message) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface, ) @@ -138,7 +143,7 @@ fun ImageMessageItem( } } } else { - Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray) + Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = BitchatFontFamily, color = Color.Gray) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt index eb1f5d86..97ee01a4 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt @@ -1,5 +1,9 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.Photo +import androidx.compose.material.icons.filled.PhotoCamera import android.Manifest import android.content.pm.PackageManager import android.net.Uri @@ -7,23 +11,20 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Camera -import androidx.compose.material.icons.filled.Photo -import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material3.Icon import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import com.bitchat.android.features.media.ImageUtils +import com.bitchat.android.ui.ComposerActionSurface +import com.bitchat.android.ui.ComposerIconSize import java.io.File @OptIn(ExperimentalFoundationApi::class) @@ -86,26 +87,31 @@ fun ImagePickerButton( } } - Box( - modifier = modifier - .size(32.dp) - .combinedClickable( - onClick = { imagePicker.launch("image/*") }, - onLongClick = { - if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { - startCameraCapture() - } else { - permissionLauncher.launch(Manifest.permission.CAMERA) - } + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + // Shares the composer's button treatment so camera, microphone and send read as one set. + ComposerActionSurface( + isActive = false, + isPressed = isPressed, + modifier = modifier.combinedClickable( + interactionSource = interactionSource, + indication = null, + onClick = { imagePicker.launch("image/*") }, + onLongClick = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + startCameraCapture() + } else { + permissionLauncher.launch(Manifest.permission.CAMERA) } - ), - contentAlignment = Alignment.Center - ) { + } + ) + ) { tint -> Icon( imageVector = Icons.Filled.PhotoCamera, contentDescription = stringResource(com.bitchat.android.R.string.pick_image), - tint = Color.Gray, - modifier = Modifier.size(20.dp) + tint = tint, + modifier = Modifier.size(ComposerIconSize) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt b/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt index 47883499..6909a52d 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt @@ -1,5 +1,8 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Description import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -9,9 +12,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Description import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt index 67719d87..10109194 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt @@ -1,13 +1,14 @@ package com.bitchat.android.ui.media +import com.bitchat.android.ui.theme.BitchatFontFamily +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow import android.media.MediaPlayer import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -18,7 +19,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.text.font.FontFamily @Composable fun VoiceNotePlayer( @@ -110,7 +110,7 @@ fun VoiceNotePlayer( onSeek = seekTo ) val durText = if (durationMs > 0) String.format("%02d:%02d", (durationMs / 1000) / 60, (durationMs / 1000) % 60) else "--:--" - Text(text = durText, fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text(text = durText, fontFamily = BitchatFontFamily, fontSize = 12.sp) } } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt new file mode 100644 index 00000000..96ef57d4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -0,0 +1,91 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +/** + * Bitchat-specific color tokens that do not have a faithful Material 3 semantic role. + * + * Standard backgrounds, surfaces, text, outlines, primary/secondary accents, and errors belong + * to [androidx.compose.material3.MaterialTheme.colorScheme]. Keeping only the extra app semantics + * here lets Material components inherit correct defaults without losing Bitchat's identity. + */ +@Immutable +data class BitchatPalette( + // MARK: - Form controls + /** + * Resting border for text inputs. Deliberately a neutral grey rather than the green-tinted + * Material outline: the composer is the one surface the user stares at while typing. + */ + val inputOutline: Color, + /** Border for a focused text input. A step brighter, still neutral. */ + val inputOutlineFocused: Color, + /** + * Fill for text inputs. Near-black / near-white and completely untinted, for the same reason + * as [inputOutline] — and because the composer sits on top of a green-tinted scrim, so any + * tint of its own compounds into something muddy. + */ + val inputSurface: Color, + /** Fill for a focused text input. A barely perceptible lift. */ + val inputSurfaceFocused: Color, + /** Resting disc behind the composer's action glyphs. Neutral grey. */ + val inputButton: Color, + + // MARK: - Extra semantics + /** Timestamps, placeholders, section labels, disabled states. */ + val textTertiary: Color, + /** Self, mentions targeting you, unread DMs. */ + val accentOrange: Color, + /** Nostr reachability. */ + val accentPurple: Color, + + // MARK: - Deterministic peer colors + /** Chroma applied after deriving a peer's stable hue. */ + val peerColorSaturation: Float, + /** Brightness applied after deriving a peer's stable hue. */ + val peerColorValue: Float, +) + +val DarkBitchatPalette = BitchatPalette( + inputOutline = Color(0xFF333635), + inputOutlineFocused = Color(0xFF5A605D), + inputSurface = Color(0xFF0B0B0B), + inputSurfaceFocused = Color(0xFF151515), + inputButton = Color(0xFF1E1E1E), + textTertiary = Color(0xFF6B776B), + accentOrange = Color(0xFFFF9F0A), + accentPurple = Color(0xFFBF5AF2), + peerColorSaturation = 1f, + peerColorValue = 1f, +) + +val LightBitchatPalette = BitchatPalette( + inputOutline = Color(0xFFCFD3D1), + inputOutlineFocused = Color(0xFF8E9490), + inputSurface = Color(0xFFFAFAFA), + inputSurfaceFocused = Color(0xFFF2F2F2), + inputButton = Color(0xFFE8E8E8), + textTertiary = Color(0xFF757F75), + accentOrange = Color(0xFFFF9500), + accentPurple = Color(0xFFAF52DE), + peerColorSaturation = 0.85f, + peerColorValue = 0.45f, +) + +val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette } + +/** + * Motion tokens. The redesign leans on short, snappy transitions: long durations read as + * sluggish on a chat surface where the user is scanning quickly. + */ +object BitchatMotion { + /** Icon tints, text colors, small fills. */ + const val QUICK_MS = 120 + + /** Tab indicators, pill growth, chip reveals. */ + const val STANDARD_MS = 180 + + /** Sheet-level fades and scroll-driven top bars. */ + const val EMPHASIZED_MS = 240 +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt new file mode 100644 index 00000000..cdd4d418 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitchat.android.R + +/** + * The bundled Geist Mono family used throughout the app. + * + * Keeping the fonts in the APK preserves offline behavior and guarantees that the design-spec + * metrics do not depend on which monospace family a device happens to provide. + */ +internal val BitchatFontFamily = FontFamily( + Font(R.font.geist_mono_regular, FontWeight.Normal), + Font(R.font.geist_mono_medium, FontWeight.Medium), + Font(R.font.geist_mono_semibold, FontWeight.SemiBold), + Font(R.font.geist_mono_bold, FontWeight.Bold), +) + +/** Exact typography, spacing, and opacity values exported for the chat transcript. */ +internal object ChatVisualTokens { + val MessageBodyFontSize: TextUnit = 14.sp + val MessageBodyLineHeight: TextUnit = 20.sp + val SenderFontSize: TextUnit = 14.sp + val SenderLineHeight: TextUnit = 16.sp + val SystemActionFontSize: TextUnit = 12.sp + val SystemActionLineHeight: TextUnit = 16.sp + val SystemTimeFontSize: TextUnit = 10.sp + + val MessageItemSpacing: Dp = 8.dp + val SenderTopPadding: Dp = 8.dp + val SenderToBodySpacing: Dp = 4.dp + + const val SenderSuffixAlpha: Float = 0.60f + const val HighlightAlpha: Float = 0.20f + const val MutedTextAlpha: Float = 0.50f + + val MessageBodyStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Normal, + fontSize = MessageBodyFontSize, + lineHeight = MessageBodyLineHeight, + ) + + val SenderStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = SenderFontSize, + lineHeight = SenderLineHeight, + ) + + val SystemActionStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + fontSize = SystemActionFontSize, + lineHeight = SystemActionLineHeight, + ) +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt new file mode 100644 index 00000000..b0f1ed7d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt @@ -0,0 +1,30 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.ui.graphics.Color +import com.bitchat.android.ui.PeerIdentity +import kotlin.math.abs + +/** + * The single identity-to-color boundary used by chat, people sheets, and mentions. + * + * The djb2 hash and hue adjustment are byte-identical to the iOS implementation. Orange is + * avoided because it is reserved for the current user. + */ +fun colorForPeer(identity: PeerIdentity, palette: BitchatPalette): Color { + var hash = 5381UL + for (byte in identity.stableKey.toByteArray()) { + hash = ((hash shl 5) + hash) + byte.toUByte().toULong() + } + + var hue = (hash % 360UL).toDouble() / 360.0 + val orange = 30.0 / 360.0 + if (abs(hue - orange) < 0.05) { + hue = (hue + 0.12) % 1.0 + } + + return Color.hsv( + hue = (hue * 360).toFloat(), + saturation = palette.peerColorSaturation, + value = palette.peerColorValue + ) +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt index 76ef3130..f2c4b0b7 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -16,30 +17,51 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalView -// Colors that match the iOS bitchat theme -private val DarkColorScheme = darkColorScheme( - primary = Color(0xFF39FF14), // Bright green (terminal-like) +// Standard UI semantics live in Material so stock components and custom Bitchat composables +// share one source of truth. LocalBitchatPalette below only supplies app-specific extra colors. +internal val DarkBitchatColorScheme = darkColorScheme( + primary = Color(0xFF32D74B), onPrimary = Color.Black, - secondary = Color(0xFF2ECB10), // Darker green + primaryContainer = Color(0xFF163D1D), + onPrimaryContainer = Color(0xFFB8F5C1), + secondary = Color(0xFF0A84FF), onSecondary = Color.Black, - background = Color.Black, - onBackground = Color(0xFF39FF14), // Green on black - surface = Color(0xFF111111), // Very dark gray - onSurface = Color(0xFF39FF14), // Green text - error = Color(0xFFFF5555), // Red for errors + secondaryContainer = Color(0xFF082E54), + onSecondaryContainer = Color(0xFFC2E0FF), + tertiary = DarkBitchatPalette.accentOrange, + onTertiary = Color.Black, + background = Color(0xFF000000), + onBackground = Color(0xFFF5F5F5), + surface = Color(0xFF0E150E), + onSurface = Color(0xFFF5F5F5), + surfaceVariant = Color(0xFF182118), + onSurfaceVariant = Color(0xFF9AA69A), + outline = Color(0xFF2A3A2A), + outlineVariant = Color(0xFF1C271C), + error = Color(0xFFFF453A), onError = Color.Black ) -private val LightColorScheme = lightColorScheme( - primary = Color(0xFF008000), // Dark green +internal val LightBitchatColorScheme = lightColorScheme( + primary = Color(0xFF248A3D), onPrimary = Color.White, - secondary = Color(0xFF006600), // Even darker green + primaryContainer = Color(0xFFD5F1D8), + onPrimaryContainer = Color(0xFF0A3212), + secondary = Color(0xFF007AFF), onSecondary = Color.White, - background = Color.White, - onBackground = Color(0xFF008000), // Dark green on white - surface = Color(0xFFF8F8F8), // Very light gray - onSurface = Color(0xFF008000), // Dark green text - error = Color(0xFFCC0000), // Dark red for errors + secondaryContainer = Color(0xFFD6E9FF), + onSecondaryContainer = Color(0xFF002C5C), + tertiary = LightBitchatPalette.accentOrange, + onTertiary = Color.Black, + background = Color(0xFFFFFFFF), + onBackground = Color(0xFF131A13), + surface = Color(0xFFF2F6F2), + onSurface = Color(0xFF131A13), + surfaceVariant = Color(0xFFE7EDE7), + onSurfaceVariant = Color(0xFF4C574C), + outline = Color(0xFFCBD6CB), + outlineVariant = Color(0xFFDEE6DE), + error = Color(0xFFD70015), onError = Color.White ) @@ -60,7 +82,8 @@ fun BitchatTheme( } } - val colorScheme = if (shouldUseDark) DarkColorScheme else LightColorScheme + val colorScheme = if (shouldUseDark) DarkBitchatColorScheme else LightBitchatColorScheme + val palette = if (shouldUseDark) DarkBitchatPalette else LightBitchatPalette val view = LocalView.current SideEffect { @@ -83,9 +106,11 @@ fun BitchatTheme( } } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) + CompositionLocalProvider(LocalBitchatPalette provides palette) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) + } } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt index a8c68a4b..2002a44a 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt @@ -2,53 +2,81 @@ package com.bitchat.android.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp // Base font size for consistent scaling across the app -internal const val BASE_FONT_SIZE = com.bitchat.android.util.AppConstants.UI.BASE_FONT_SIZE_SP // sp - increased from 14sp for better readability +internal const val BASE_FONT_SIZE = com.bitchat.android.util.AppConstants.UI.BASE_FONT_SIZE_SP + +/** + * Message body style. The generous leading (1.4x) is what gives the redesigned chat surface + * its readable rhythm; without an explicit lineHeight, Compose falls back to the font's own + * metrics and lines sit too tightly for long paragraphs. + */ +val MessageBodyTextStyle = ChatVisualTokens.MessageBodyStyle + +/** Sender label above a message group. Single line, never wraps. */ +val MessageSenderTextStyle = ChatVisualTokens.SenderStyle // Typography matching the iOS monospace design - using BASE_FONT_SIZE for consistency val Typography = Typography( bodyLarge = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = (BASE_FONT_SIZE + 1).sp, lineHeight = (BASE_FONT_SIZE + 7).sp ), bodyMedium = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = BASE_FONT_SIZE.sp, - lineHeight = (BASE_FONT_SIZE + 3).sp + lineHeight = (BASE_FONT_SIZE + 6).sp ), bodySmall = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = (BASE_FONT_SIZE - 3).sp, lineHeight = (BASE_FONT_SIZE + 1).sp ), headlineSmall = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE + 3).sp, lineHeight = (BASE_FONT_SIZE + 9).sp ), + // Previously unset, which leaked the Roboto default into onboarding + sheet titles. + headlineLarge = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold, + fontSize = (BASE_FONT_SIZE + 13).sp, + lineHeight = (BASE_FONT_SIZE + 21).sp + ), + titleLarge = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + fontSize = (BASE_FONT_SIZE + 5).sp, + lineHeight = (BASE_FONT_SIZE + 13).sp + ), titleMedium = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE + 1).sp, lineHeight = (BASE_FONT_SIZE + 7).sp ), + labelLarge = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + fontSize = (BASE_FONT_SIZE - 1).sp, + lineHeight = (BASE_FONT_SIZE + 5).sp + ), labelMedium = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE - 2).sp, lineHeight = (BASE_FONT_SIZE + 3).sp ), labelSmall = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = (BASE_FONT_SIZE - 4).sp, lineHeight = (BASE_FONT_SIZE + 1).sp diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 11df5b08..4905c927 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -120,7 +120,7 @@ object AppConstants { object UI { const val MAX_NICKNAME_LENGTH: Int = 15 - const val BASE_FONT_SIZE_SP: Int = 15 + const val BASE_FONT_SIZE_SP: Int = 14 const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L diff --git a/app/src/main/res/drawable/ic_spec_bluetooth.xml b/app/src/main/res/drawable/ic_spec_bluetooth.xml new file mode 100644 index 00000000..8c1ed7ae --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_bluetooth.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_bookmark_filled.xml b/app/src/main/res/drawable/ic_spec_bookmark_filled.xml new file mode 100644 index 00000000..2e73b458 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_bookmark_filled.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_bookmark_outline.xml b/app/src/main/res/drawable/ic_spec_bookmark_outline.xml new file mode 100644 index 00000000..988fe44c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_bookmark_outline.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_chat_bubbles.xml b/app/src/main/res/drawable/ic_spec_chat_bubbles.xml new file mode 100644 index 00000000..76109058 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_chat_bubbles.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_check.xml b/app/src/main/res/drawable/ic_spec_check.xml new file mode 100644 index 00000000..16eb9e1c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_check.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_close.xml b/app/src/main/res/drawable/ic_spec_close.xml new file mode 100644 index 00000000..e0fbc4ea --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_close.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/ic_spec_command.xml b/app/src/main/res/drawable/ic_spec_command.xml new file mode 100644 index 00000000..366173cd --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_command.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_envelope.xml b/app/src/main/res/drawable/ic_spec_envelope.xml new file mode 100644 index 00000000..15552d37 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_envelope.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/ic_spec_eye_off.xml b/app/src/main/res/drawable/ic_spec_eye_off.xml new file mode 100644 index 00000000..2e852cf4 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_eye_off.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_globe.xml b/app/src/main/res/drawable/ic_spec_globe.xml new file mode 100644 index 00000000..b56bb3a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_globe.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_lock.xml b/app/src/main/res/drawable/ic_spec_lock.xml new file mode 100644 index 00000000..42713a11 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_lock.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_lock_open.xml b/app/src/main/res/drawable/ic_spec_lock_open.xml new file mode 100644 index 00000000..11d69821 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_lock_open.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_mention.xml b/app/src/main/res/drawable/ic_spec_mention.xml new file mode 100644 index 00000000..a57bf9af --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_mention.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_on_location_person.xml b/app/src/main/res/drawable/ic_spec_on_location_person.xml new file mode 100644 index 00000000..507d47d8 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_on_location_person.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_panic.xml b/app/src/main/res/drawable/ic_spec_panic.xml new file mode 100644 index 00000000..7c876da3 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_panic.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_people.xml b/app/src/main/res/drawable/ic_spec_people.xml new file mode 100644 index 00000000..3b2d46f5 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_people.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_person.xml b/app/src/main/res/drawable/ic_spec_person.xml new file mode 100644 index 00000000..33c42dcb --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_person.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_range.xml b/app/src/main/res/drawable/ic_spec_range.xml new file mode 100644 index 00000000..5ba47d6c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_range.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_routed.xml b/app/src/main/res/drawable/ic_spec_routed.xml new file mode 100644 index 00000000..36e271d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_routed.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_shuffle.xml b/app/src/main/res/drawable/ic_spec_shuffle.xml new file mode 100644 index 00000000..9f431732 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_shuffle.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_star.xml b/app/src/main/res/drawable/ic_spec_star.xml new file mode 100644 index 00000000..c48025ab --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_star.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_star_filled.xml b/app/src/main/res/drawable/ic_spec_star_filled.xml new file mode 100644 index 00000000..d29d62d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_star_filled.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_sync.xml b/app/src/main/res/drawable/ic_spec_sync.xml new file mode 100644 index 00000000..1ab434e5 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_sync.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_teleport.xml b/app/src/main/res/drawable/ic_spec_teleport.xml new file mode 100644 index 00000000..6d215dea --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_teleport.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_warning.xml b/app/src/main/res/drawable/ic_spec_warning.xml new file mode 100644 index 00000000..ffb64e3c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_warning.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_waveform.xml b/app/src/main/res/drawable/ic_spec_waveform.xml new file mode 100644 index 00000000..2e0070b6 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_waveform.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_wifi.xml b/app/src/main/res/drawable/ic_spec_wifi.xml new file mode 100644 index 00000000..4757653f --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_wifi.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_wifi_off.xml b/app/src/main/res/drawable/ic_spec_wifi_off.xml new file mode 100644 index 00000000..4a716e57 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_wifi_off.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/src/main/res/font/geist_mono_bold.ttf b/app/src/main/res/font/geist_mono_bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..90eb8a86551d5425fb1e965a8deff31de1792d49 GIT binary patch literal 150492 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/font/geist_mono_medium.ttf b/app/src/main/res/font/geist_mono_medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ff49ece5a3dbc6012320749a398ea6daca096aeb GIT binary patch literal 149328 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�yj3@rDuD5@3s_u9~XZY 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/font/geist_mono_regular.ttf b/app/src/main/res/font/geist_mono_regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..50c9d5a667bc397a1495b0bb45a52bef4184661c GIT binary patch literal 148516 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

qKjwHQ!ob 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%0DkIT7^Cr|?j=S6Pw9Kpm~8w?-&>9_ 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 Gk@;Vye(^v6 literal 0 HcmV?d00001 diff --git a/app/src/main/res/raw/geist_mono_ofl.txt b/app/src/main/res/raw/geist_mono_ofl.txt new file mode 100644 index 00000000..34ffeb6f --- /dev/null +++ b/app/src/main/res/raw/geist_mono_ofl.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index c81bdb26..13c8d51c 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -71,12 +71,8 @@ إضافة إشارة مرجعية - رجوع - القناة: %1$s - مغادرة يمكن الوصول عبر Nostr رسائل خاصة غير مقروءة - تبديل الإشارة المرجعية تم الانتقال حالة Tor الأقران المتصلون @@ -96,14 +92,12 @@ اختيار صورة - دردشة شبكية لامركزية مع تشفير طرف إلى طرف دردشة شبكية دون اتصال تواصل مباشرة عبر Bluetooth LE بدون إنترنت أو خوادم. تُعاد توجيه الرسائل عبر الأجهزة القريبة لتوسيع النطاق. قنوات geohash عبر الإنترنت تواصل مع أشخاص بالقرب منك عبر قنوات تعتمد على geohash. وسّع الشبكة عبر مرحلات الإنترنت العامة. تشفير طرف إلى طرف الرسائل الخاصة مشفّرة. رسائل القنوات عامة. - المظهر النظام فاتح داكن @@ -127,7 +121,6 @@ حالة Tor: %1$s، التمهيد %2$d%% آخر: %1$s مسح البيانات الطارئ - تلميح: انقر ثلاث مرات على عنوان التطبيق لمسح كل البيانات المحفوظة – بما في ذلك الرسائل والمفاتيح والإعدادات. إعدادات التصحيح مفتوح المصدر • الخصوصية أولاً • لامركزي إغلاق @@ -147,7 +140,6 @@ فتح الخريطة إزالة الإشارة المرجعية انتقال - محدد مغادرة القناة يمكن الوصول عبر Nostr مفضل (غير متصل) @@ -286,14 +278,12 @@ الأذونات منح الأذونات bitchat لا يتتبع موقعك - الأشخاص لا أحد هنا… (أنت) حرّك وقرّب لاختيار geohash اختيار اكتب رسالة… إشارة - (~%1$s) v%1$s image/* صورة @@ -307,11 +297,6 @@ تشغيل إيقاف مؤقت - تعدين PoW - PoW مفعّل - برهان العمل - يتم التعدين… - pow: %1$dbit مطلوب لاكتشاف مستخدمي bitchat عبر البلوتوث diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 95b3662a..7c2617a4 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -71,12 +71,8 @@ বুকমার্ক যোগ করুন - ফিরে যান - চ্যানেল: %1$s - ছেড়ে দিন Nostr এর মাধ্যমে পৌঁছানো যায় অপঠিত ব্যক্তিগত বার্তা - বুকমার্ক টগল করুন টেলিপোর্ট করা হয়েছে Tor অবস্থা সংযুক্ত পিয়ার @@ -96,14 +92,12 @@ ছবি নির্বাচন করুন - এন্ড-টু-এন্ড এনক্রিপশন সহ বিকেন্দ্রীভূত মেশ চ্যাট অফলাইন মেশ চ্যাট ইন্টারনেট বা সার্ভার ছাড়াই Bluetooth LE এর মাধ্যমে সরাসরি যোগাযোগ করুন। পরিসর বাড়ানোর জন্য কাছাকাছি ডিভাইসের মাধ্যমে বার্তা রিলে করা হয়। অনলাইন জিওহ্যাশ চ্যানেল জিওহ্যাশ-ভিত্তিক চ্যানেলের মাধ্যমে আপনার এলাকার মানুষদের সাথে সংযুক্ত হন। পাবলিক ইন্টারনেট রিলেগুলির মাধ্যমে মেশ বিস্তৃত করুন। এন্ড-টু-এন্ড এনক্রিপশন ব্যক্তিগত বার্তাগুলি এনক্রিপ্ট করা। চ্যানেল বার্তাগুলি পাবলিক। - চেহারা সিস্টেম হালকা অন্ধকার @@ -127,7 +121,6 @@ Tor অবস্থা: %1$s, বুটস্ট্র্যাপ %2$d%% শেষ: %1$s জরুরী ডেটা ক্লিয়ার - টিপ: সংরক্ষিত সমস্ত ডেটা – বার্তা, কী এবং সেটিংস সহ – সাফ করতে অ্যাপ শিরোনামে তিনবার ট্যাপ করুন। ডিবাগ সেটিংস ওপেন সোর্স • গোপনীয়তা প্রথম • বিকেন্দ্রীভূত বন্ধ করুন @@ -147,7 +140,6 @@ মানচিত্র খুলুন বুকমার্ক সরান টেলিপোর্ট - নির্বাচিত চ্যানেল ছেড়ে দিন Nostr এর মাধ্যমে পৌঁছানো যায় প্রিয় (অফলাইন) @@ -286,14 +278,12 @@ অনুমতি অনুমতি দিন bitchat আপনার অবস্থান ট্র্যাক করে না - মানুষ কেউ আশেপাশে নেই … (আপনি) জিওহ্যাশ নির্বাচন করতে প্যান এবং জুম করুন নির্বাচন করুন বার্তা টাইপ করুন … উল্লেখ - (~%1$s) v%1$s image/* ছবি @@ -307,11 +297,6 @@ প্লে করুন বিরাম - মাইনিং PoW - PoW সক্রিয় - প্রুফ অফ ওয়ার্ক - মাইনিং … - pow: %1$dbit ব্লুটুথের মাধ্যমে bitchat ব্যবহারকারী আবিষ্কার করতে প্রয়োজন diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a80dc4c4..b13fb001 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -71,12 +71,8 @@ Lesezeichen hinzufügen - zurück - Kanal: %1$s - verlassen Über Nostr erreichbar Ungelesene private Nachrichten - Lesezeichen umschalten Teleportiert Tor‑Status Verbundenen Peers @@ -96,14 +92,12 @@ Bild auswählen - dezentraler Mesh‑Chat mit Ende‑zu‑Ende‑Verschlüsselung Offline‑Mesh‑Chat Kommuniziere direkt über Bluetooth LE ohne Internet oder Server. Nachrichten werden über nahe Geräte weitergeleitet, um die Reichweite zu erhöhen. Online‑Geohash‑Kanäle Vernetze dich mit Menschen in deiner Umgebung über geohash‑basierte Kanäle. Erweitere das Mesh über öffentliche Internet‑Relays. Ende‑zu‑Ende‑Verschlüsselung Private Nachrichten sind verschlüsselt. Kanal‑Nachrichten sind öffentlich. - Darstellung System Hell Dunkel @@ -127,7 +121,6 @@ Tor‑Status: %1$s, Bootstrap %2$d%% Zuletzt: %1$s Notfall‑Datenlöschung - Tipp: Dreifach auf den App‑Titel tippen, um alle gespeicherten Daten – einschließlich Nachrichten, Schlüssel und Einstellungen – zu löschen. Debug‑Einstellungen Open Source • Privacy First • Dezentral Schließen @@ -147,7 +140,6 @@ Karte öffnen Lesezeichen entfernen Teleportieren - Ausgewählt Kanal verlassen Über Nostr erreichbar Favorit (offline) @@ -286,14 +278,12 @@ Berechtigungen Berechtigungen erteilen bitchat verfolgt NICHT deinen Standort - PERSONEN niemand da … (du) schwenken und zoomen, um einen Geohash auszuwählen auswählen Nachricht eingeben … erwähnen - (~%1$s) v%1$s image/* Bild @@ -307,11 +297,6 @@ Abspielen Pause - Mining‑PoW - PoW aktiviert - Proof of Work - Mining … - pow: %1$dbit Erforderlich, um bitchat‑Benutzer über Bluetooth zu entdecken diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 157ea1b4..e1ac2c2b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -71,12 +71,8 @@ Agregar marcador - atrás - Canal: %1$s - salir Accesible vía Nostr Mensajes privados sin leer - Alternar marcador Teletransportado Estado de Tor Pares conectados @@ -96,14 +92,12 @@ Elegir imagen - chat mesh descentralizado con cifrado de extremo a extremo Chat mesh sin conexión Comunícate directamente a través de Bluetooth LE sin internet o servidores. Los mensajes se retransmiten a través de dispositivos cercanos para extender el alcance. Canales geohash en línea Conéctate con personas cerca de ti a través de canales basados en geohash. Extiende la red mesh a través de repetidores de internet público. Cifrado de extremo a extremo Los mensajes privados están cifrados. Los mensajes de canal son públicos. - Apariencia Sistema Claro Oscuro @@ -127,7 +121,6 @@ Estado de Tor: %1$s, bootstrap %2$d%% Último: %1$s Borrado de datos de emergencia - Consejo: Toca tres veces en el título de la app para borrar todos los datos almacenados, incluyendo mensajes, claves y configuraciones. Configuraciones de depuración Código abierto • Privacidad primero • Descentralizado Cerrar @@ -147,7 +140,6 @@ Abrir mapa Quitar marcador Teletransportar - Seleccionado Salir del canal Accesible vía Nostr Favorito (sin conexión) @@ -286,14 +278,12 @@ Permisos Otorgar permisos bitchat NO rastrea tu ubicación - PERSONAS nadie por aquí… (tú) desplaza y hace zoom para seleccionar un geohash seleccionar Escribe un mensaje… mencionar - (~%1$s) v%1$s image/* Imagen @@ -307,11 +297,6 @@ Reproducir Pausar - Minando PoW - PoW habilitado - Prueba de trabajo - Minando… - pow: %1$dbit Requerido para descubrir usuarios bitchat a través de Bluetooth diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 8d7d6ae9..a3e81d29 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -71,12 +71,8 @@ افزودن نشانک - بازگشت - کانال: %1$s - خروج قابل دسترس از طریق Nostr پیام‌های خصوصی خوانده‌نشده - تغییر وضعیت نشانک انتقال مکانی وضعیت Tor همتایان متصل @@ -96,14 +92,12 @@ انتخاب تصویر - گفت‌وگوی مِش غیرمتمرکز با رمزگذاری سرتاسری گفت‌وگوی مِش آفلاین بدون اینترنت یا سرور، مستقیماً از طریق Bluetooth LE ارتباط برقرار کنید. پیام‌ها از طریق دستگاه‌های نزدیک رله می‌شوند تا بُرد افزایش یابد. کانال‌های geohash آنلاین از طریق کانال‌های مبتنی بر geohash با افراد منطقهٔ خود ارتباط برقرار کنید. مِش را از طریق رله‌های عمومی اینترنت گسترش دهید. رمزگذاری سرتاسری پیام‌های خصوصی رمزگذاری می‌شوند. پیام‌های کانال عمومی هستند. - نما سامانه روشن تیره @@ -127,7 +121,6 @@ وضعیت Tor: %1$s، بوت‌استرپ %2$d%% آخرین: %1$s پاکسازی اضطراری داده - نکته: برای پاک کردن همهٔ داده‌های ذخیره‌شده — شامل پیام‌ها، کلیدها و تنظیمات — سه بار روی عنوان برنامه ضربه بزنید. تنظیمات اشکال‌زدایی متن‌باز • حریم خصوصی در اولویت • غیرمتمرکز بستن @@ -147,7 +140,6 @@ باز کردن نقشه حذف نشانک انتقال - انتخاب شد خروج از کانال قابل دسترس از طریق Nostr دلخواه (آفلاین) @@ -286,14 +278,12 @@ مجوزها اعطای مجوزها bitchat موقعیت شما را پیگیری نمی‌کند - افراد کسی در اطراف نیست … (شما) برای انتخاب geohash، جابه‌جا و زوم کنید انتخاب نوشتن پیام … ذکر - (~%1$s) v%1$s image/* تصویر @@ -307,11 +297,6 @@ پخش توقف - استخراج PoW - PoW فعال است - گواه کار - در حال استخراج … - pow: %1$dbit برای یافتن کاربران bitchat از طریق بلوتوث لازم است diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 829079a6..1da03f06 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -71,12 +71,8 @@ Mag‑bookmark - bumalik - channel: %1$s - umalis Maabot sa Nostr Hindi nabasang pribadong mensahe - I‑toggle ang bookmark Naiteleport Katayuan ng Tor Nakakonektang peers @@ -96,14 +92,12 @@ Pumili ng larawan - desentralisadong mesh messaging na may end‑to‑end encryption Offline Mesh Chat Makipag‑usap direkta sa Bluetooth LE nang walang internet o server. Ang mga mensahe ay nire‑relay ng mga kalapit na device. Online na Geohash na mga Channel Makipag‑ugnayan sa mga tao sa paligid gamit ang geohash‑based na mga channel. Palawakin ang mesh sa pampublikong internet relays. End‑to‑End Encryption Ang mga pribadong mensahe ay naka‑encrypt. Ang mga mensahe sa channel ay publiko. - hitsura system light dark @@ -127,7 +121,6 @@ katayuan ng Tor: %1$s, bootstrap %2$d%% Huli: %1$s Emergency Data Deletion - Tip: i‑tap ang pamagat ng app nang 3 beses para burahin ang lahat ng data (mga mensahe, susi, setting). Debug Settings Open Source • Privacy First • Desentralisado Isara @@ -147,7 +140,6 @@ Buksan ang mapa Alisin ang bookmark Teleport - Napili Umalis sa channel Maabot sa Nostr Paborito (offline) @@ -281,7 +273,6 @@ HINDI sinusubaybayan ng bitchat ang lokasyon mo @ · ⧉ - TAO walang tao sa paligid… (ikaw) i‑pan at i‑zoom para pumili ng geohash @@ -290,7 +281,6 @@ @%1$s banggit %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 46e9c023..e632bdb8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -71,12 +71,8 @@ Ajouter un signet - retour - canal : %1$s - quitter Joignable via Nostr Messages privés non lus - Basculer le signet Téléporté Statut Tor Pairs connectés @@ -96,14 +92,12 @@ Choisir une image - messagerie mesh décentralisée avec chiffrement de bout en bout Chat Mesh hors‑ligne Communique directement via Bluetooth LE sans internet ni serveurs. Les messages sont relayés par les appareils proches pour étendre la portée. Canaux Geohash en ligne Discute avec des personnes proches via des canaux basés sur geohash. Étends le mesh grâce à des relais internet publics. Chiffrement de bout en bout Les messages privés sont chiffrés. Les messages de canal sont publics. - apparence système clair sombre @@ -127,7 +121,6 @@ statut Tor : %1$s, bootstrap %2$d%% Dernier : %1$s Suppression d’urgence des données - Astuce : tapote 3 fois le titre de l’app pour supprimer toutes les données (messages, clés, paramètres). Paramètres de debug Open Source • Privacy First • Décentralisé Fermer @@ -147,7 +140,6 @@ Ouvrir la carte Retirer le signet Téléporter - Sélectionné Quitter le canal Joignable via Nostr Favori (hors‑ligne) @@ -282,7 +274,6 @@ bitchat ne suit PAS ta position @ · ⧉ - PERSONNES personne aux alentours… (toi) déplacer et zoomer pour choisir un geohash @@ -291,7 +282,6 @@ @%1$s mention %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index ffb4dac0..1a393f38 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -71,12 +71,8 @@ बुकमार्क जोड़ें - वापस - चैनल: %1$s - छोड़ें Nostr के माध्यम से उपलब्ध अपठित निजी संदेश - बुकमार्क टॉगल करें टेलीपोर्ट हुआ Tor स्थिति कनेक्टेड पीयर्स @@ -96,14 +92,12 @@ छवि चुनें - विकेन्द्रीकृत मेष चैट • एंड‑टू‑एंड एन्क्रिप्शन ऑफ़लाइन मेष चैट इंटरनेट या सर्वर के बिना Bluetooth LE के माध्यम से सीधे संवाद करें। संदेशों को पास के उपकरणों के माध्यम से रिले किया जाता है ताकि पहुँच बढ़े। ऑनलाइन geohash चैनल geohash आधारित चैनलों के माध्यम से अपने आस‑पास के लोगों से जुड़ें। सार्वजनिक इंटरनेट रिले के माध्यम से मेष का विस्तार करें। एंड‑टू‑एंड एन्क्रिप्शन निजी संदेश एन्क्रिप्टेड हैं। चैनल संदेश सार्वजनिक हैं। - रूप सिस्टम लाइट डार्क @@ -127,7 +121,6 @@ Tor स्थिति: %1$s, बूटस्ट्रैप %2$d%% अंतिम: %1$s आपातकालीन डेटा मिटाना - संकेत: सभी सहेजे डेटा – संदेश, कुंजियाँ और सेटिंग्स सहित – मिटाने के लिए ऐप शीर्षक पर तीन बार टैप करें। डिबग सेटिंग्स ओपन सोर्स • गोपनीयता पहले • विकेन्द्रीकृत बंद करें @@ -147,7 +140,6 @@ मानचित्र खोलें बुकमार्क हटाएँ टेलीपोर्ट - चयनित चैनल छोड़ें Nostr के माध्यम से उपलब्ध पसंदीदा (ऑफ़लाइन) @@ -286,14 +278,12 @@ अनुमतियाँ अनुमतियाँ दें bitchat आपकी लोकेशन ट्रैक नहीं करती - लोग आस‑पास कोई नहीं… (आप) geohash चुनने के लिए पैन/ज़ूम करें चयन करें संदेश लिखें… उल्लेख - (~%1$s) v%1$s image/* छवि @@ -307,11 +297,6 @@ चलाएँ रोकें - PoW माइनिंग - PoW सक्षम - प्रूफ़ ऑफ़ वर्क - माइनिंग… - pow: %1$dbit ब्लूटूथ के माध्यम से bitchat उपयोगकर्ताओं की खोज के लिए आवश्यक diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index e67caded..934556e4 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -71,12 +71,8 @@ Tambah bookmark - kembali - Channel: %1$s - keluar Dapat dijangkau melalui Nostr Pesan pribadi yang belum dibaca - Alihkan bookmark Diteleportasi Status Tor Peer yang terhubung @@ -96,14 +92,12 @@ Pilih gambar - chat mesh terdesentralisasi dengan enkripsi end-to-end Chat Mesh Offline Berkomunikasi langsung melalui Bluetooth LE tanpa Internet atau server. Pesan diteruskan melalui perangkat terdekat untuk memperluas jangkauan. Channel Geohash Online Terhubung dengan orang-orang di area Anda melalui channel berbasis geohash. Perluas mesh melalui relay Internet publik. Enkripsi end-to-end Pesan pribadi dienkripsi. Pesan channel bersifat publik. - Tampilan Sistem Terang Gelap @@ -127,7 +121,6 @@ Status Tor: %1$s, Bootstrap %2$d%% Terakhir: %1$s Hapus data darurat - Tips: Ketuk tiga kali pada judul aplikasi untuk menghapus semua data tersimpan – termasuk pesan, kunci, dan pengaturan. Pengaturan debug Sumber Terbuka • Privasi Pertama • Terdesentralisasi Tutup @@ -147,7 +140,6 @@ Buka peta Hapus bookmark Teleportasi - Dipilih Keluar dari channel Dapat dijangkau melalui Nostr Favorit (offline) @@ -286,14 +278,12 @@ Izin Berikan izin bitchat TIDAK melacak lokasi Anda - ORANG tidak ada orang di sekitar … (Anda) geser dan zoom untuk memilih geohash pilih Ketik pesan … sebut - (~%1$s) v%1$s image/* Gambar @@ -307,11 +297,6 @@ Putar Jeda - Mining PoW - PoW diaktifkan - Proof of Work - Mining … - pow: %1$dbit Diperlukan untuk menemukan pengguna bitchat melalui Bluetooth diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 92bf4ad0..7e67de6d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -71,12 +71,8 @@ Aggiungi segnalibro - indietro - canale: %1$s - esci Raggiungibile via Nostr Messaggi privati non letti - Attiva/disattiva segnalibro Teletrasportato Stato Tor Peer connessi @@ -96,14 +92,12 @@ Scegli immagine - messaggistica mesh decentralizzata con crittografia end-to-end Chat mesh offline Comunica direttamente tramite Bluetooth LE senza internet o server. I messaggi vengono inoltrati attraverso i dispositivi vicini per estendere la portata. Canali geohash online Connettiti con persone nella tua area usando canali basati su geohash. Estendi la rete mesh usando relay internet pubblici. Crittografia end-to-end I messaggi privati sono crittografati. I messaggi dei canali sono pubblici. - aspetto sistema chiaro scuro @@ -127,7 +121,6 @@ Stato Tor: %1$s, bootstrap %2$d%% Ultimo: %1$s Eliminazione dati di emergenza - Suggerimento: fai triplo clic sul titolo dell\'app per eliminare in emergenza tutti i dati memorizzati, inclusi messaggi, chiavi e impostazioni. Impostazioni di debug Open Source • Privacy First • Decentralizzato Chiudi @@ -147,7 +140,6 @@ Apri mappa Rimuovi segnalibro Teletrasporta - Selezionato Esci dal canale Raggiungibile via Nostr Preferito offline @@ -286,14 +278,12 @@ permessi Concedi permessi bitchat NON traccia la tua posizione - PERSONE nessuno nei dintorni… (tu) scorri e ingrandisci per selezionare un geohash seleziona scrivi un messaggio… menzione - (~%1$s) v%1$s image/* Immagine @@ -307,11 +297,6 @@ Riproduci Pausa - Mining PoW - PoW abilitato - Proof of Work - mining… - pow: %1$dbit Necessario per scoprire utenti bitchat tramite Bluetooth diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 1b25aa95..1e7da71d 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -71,12 +71,8 @@ ブックマークに追加 - 戻る - チャンネル: %1$s - 退出 Nostr 経由で到達可能 未読のプライベートメッセージ - ブックマークを切り替え テレポート済み Tor ステータス 接続中のピア @@ -96,14 +92,12 @@ 画像を選択 - 分散型メッシュチャット・エンドツーエンド暗号化 オフライン・メッシュチャット インターネットやサーバーなしで、Bluetooth LE 経由で直接通信します。近隣のデバイスを経由してメッセージを中継し、到達範囲を広げます。 オンライン Geohash チャンネル Geohash ベースのチャンネルで近くの人々とつながります。公開インターネットのリレーを介してメッシュを拡張します。 エンドツーエンド暗号化 プライベートメッセージは暗号化されます。チャンネルメッセージは公開です。 - 表示 システム ライト ダーク @@ -127,7 +121,6 @@ Tor ステータス: %1$s、ブートストラップ %2$d%% 最終: %1$s 緊急データ消去 - ヒント: アプリタイトルを 3 回タップすると、メッセージ・鍵・設定を含む保存データをすべて消去します。 デバッグ設定 オープンソース • プライバシーファースト • 分散型 閉じる @@ -147,7 +140,6 @@ 地図を開く ブックマークを削除 テレポート - 選択済み チャンネルから退出 Nostr 経由で到達可能 お気に入り (オフライン) @@ -286,14 +278,12 @@ 権限 権限を付与 bitchat はあなたの位置情報を追跡しません - 人々 誰もいません… (あなた) パン/ズームして geohash を選択 選択 メッセージを入力… メンション - (~%1$s) v%1$s image/* 画像 @@ -307,11 +297,6 @@ 再生 一時停止 - PoW をマイニング中 - PoW 有効 - Proof of Work - マイニング中… - pow: %1$dbit Bluetooth で bitchat ユーザーを検出するために必要です diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index c7620e19..4994f9cc 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -71,12 +71,8 @@ სანიშნეს დამატება - უკან - არხი: %1$s - გასვლა მისაწვდომია Nostr-ით წაუკითხავი პირადი შეტყობინებები - სანიშნეს გადართვა ტელეპორტირებული Tor-ის სტატუსი დაკავშირებული პირები @@ -96,14 +92,12 @@ სურათის არჩევა - დეცენტრალიზებული mesh-ჩათი ბოლო-მდე დაშიფვრით ოფლაინ mesh-ჩათი ესაუბრეთ პირდაპირ Bluetooth LE-ის საშუალებით ინტერნეტის ან სერვერების გარეშე. შეტყობინებები გადაიგზავნება ახლომდებარე მოწყობილობებზე, რათა გაიზარდოს დაფარვა. ონლაინ geohash არხები დაუკავშირდით ახლომდებარე ხალხს geohash-ზე დაფუძნებული არხებით. გააფართოვეთ mesh საჯარო ინტერნეტ რილეებით. ბოლო-მდე დაშიფვრა პირადი შეტყობინებები დაშიფრულია. არხის შეტყობინებები საჯაროა. - გარეგნობა სისტემა ღია მუქი @@ -127,7 +121,6 @@ Tor სტატუსი: %1$s, ჩატვირთვა %2$d%% ბოლო: %1$s სასწრაფო მონაცემების წაშლა - რჩევა: დააჭირეთ აპის სათაურს სამჯერ ყველა შენახული მონაცემის – შეტყობინებების, გასაღებებისა და პარამეტრების – წასაშლელად. დebug პარამეტრები ღია კოდი • კონფიდენციალობა პირველია • დეცენტრალიზებული დახურვა @@ -147,7 +140,6 @@ რუკის გახსნა სანიშნეს წაშლა ტელეპორტი - მონიშნული არხიდან გამოსვლა მისაწვდომია Nostr-ით რჩეული (ოფლაინ) @@ -286,14 +278,12 @@ ნებართვები ნებართვების მინიჭება bitchat არ აკონტროლებს თქვენს მდებარეობას - ხალხი არავინ არის ახლოს… (თქვენ) პანორამირება/ზუმი geohash-ის ასარჩევად არჩევა შეიყვანეთ შეტყობინება… ხსენება - (~%1$s) v%1$s image/* სურათი @@ -307,11 +297,6 @@ დაკვრა პაუზა - PoW მაინინგი - PoW ჩართულია - Proof of Work - მაინინგი… - pow: %1$dbit საჭიროა bitchat მომხმარებლების Bluetooth-ით აღმოსაჩენად diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 8c0cc624..7327257a 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -71,12 +71,8 @@ 북마크 추가 - 뒤로 - 채널: %1$s - 나가기 Nostr로 연결 가능 읽지 않은 개인 메시지 - 북마크 전환 텔레포트됨 Tor 상태 연결된 피어 @@ -96,14 +92,12 @@ 이미지 선택 - 종단간 암호화를 갖춘 탈중앙 메쉬 채팅 오프라인 메쉬 채팅 인터넷이나 서버 없이 Bluetooth LE로 직접 소통합니다. 범위를 확장하기 위해 근처 기기를 통해 메시지가 중계됩니다. 온라인 geohash 채널 geohash 기반 채널을 통해 주변 사람들과 연결하세요. 공용 인터넷 릴레이로 메쉬를 확장합니다. 종단간 암호화 개인 메시지는 암호화됩니다. 채널 메시지는 공개입니다. - 모양 시스템 라이트 다크 @@ -127,7 +121,6 @@ Tor 상태: %1$s, 부트스트랩 %2$d%% 마지막: %1$s 긴급 데이터 삭제 - 팁: 저장된 모든 데이터(메시지, 키, 설정 포함)를 삭제하려면 앱 제목을 세 번 탭하세요. 디버그 설정 오픈 소스 • 프라이버시 우선 • 탈중앙 닫기 @@ -147,7 +140,6 @@ 지도 열기 북마크 제거 텔레포트 - 선택됨 채널 나가기 Nostr로 연결 가능 즐겨찾기 (오프라인) @@ -286,14 +278,12 @@ 권한 권한 허용 bitchat은 사용자의 위치를 추적하지 않습니다 - 사람들 주변에 아무도 없음 … (나) geohash를 선택하려면 이동하고 확대/축소하세요 선택 메시지 입력 … 멘션 - (~%1$s) v%1$s image/* 이미지 @@ -307,11 +297,6 @@ 재생 일시정지 - PoW 채굴 - PoW 사용 - 작업증명 - 채굴 중 … - pow: %1$dbit 블루투스를 통해 bitchat 사용자를 발견하는 데 필요 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index ddff38ad..168eebe7 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -71,12 +71,8 @@ Hanampy bookmark - miverina - fantsona: %1$s - hiala Azo tratrarina amin\'ny alalan\'ny Nostr Hafatra manokana tsy voavaky - Hanova bookmark Nafindra toerana Toe-javatra Tor Mpiara-miasa mifandray @@ -96,14 +92,12 @@ Hifidy sary - hafatra mesh tsy misy foibe misy fandokoana farany amin\'ny farany Mesh Chat tsy an-tserasera Mifandraisa mivantana amin\'ny alalan\'ny Bluetooth LE tsy misy Internet na mpizara. Ny hafatra dia mamindra amin\'ny alalan\'ny fitaovana akaiky mba hanitarana ny elanelana. Fantsona Geohash an-tserasera Mifandray amin\'ny olona eo amin\'ny faritra misy anao amin\'ny fampiasana fantsona mifototra amin\'ny geohash. Manitatra ny mesh amin\'ny fampiasana relay Internet ho an\'ny daholobe. Fandokoana Farany amin\'ny Farany Voafono ny hafatra manokana. Hafatra ho an\'ny daholobe ny hafatra fantsona. - endrika rafitra mazava maizina @@ -127,7 +121,6 @@ Toe-javatra Tor: %1$s, bootstrap %2$d%% Farany: %1$s Famafana Angona Maika - Toro-hevitra: Tsindrio in-telo ny anaran\'ny fampiharana mba hamafana maika ny angona rehetra voatahiry ao anatin\'izany ny hafatra, ny lakile ary ny fametrahana. Fametrahana Debug Loharanom-baovao misokatra • Fiainana manokana voalohany • Tsy misy foibe Hanakatona @@ -147,7 +140,6 @@ Hanokatra sarintany Hanesorana bookmark Hamindra toerana - Voafidy Hiala amin\'ny fantsona Azo tratrarina amin\'ny alalan\'ny Nostr Ankafizina tsy an-tserasera @@ -290,7 +282,6 @@ Ny bitchat dia TSY manaraka ny toerananao @ · ⧉ - OLONA tsy misy olona manodidina... (ianao) ahetsika sy ataovy lehibe mba hifidianana geohash @@ -299,7 +290,6 @@ @%1$s hanonona %1$d / %2$d - (~%1$s) @%1$s v%1$s # @@ -333,11 +323,6 @@ Hilalao Hijanona - Mihaingam-poana PoW - Alefa ny PoW - Porofo Asa - mihaingana... - pow: %1$dbit Ilaina mba hahitana mpampiasa bitchat amin\'ny alalan\'ny Bluetooth diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 3e50146a..48a7afbd 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -71,12 +71,8 @@ बुकमार्क थप्नुहोस् - पछाडि - च्यानल: %1$s - छोड्नुहोस् Nostr मार्फत पुग्न सकिने नपढिएका निजी सन्देश - बुकमार्क टगल टेलिपोर्ट भयो Tor स्थिति जडान भएका साथीहरू @@ -96,14 +92,12 @@ तस्वीर छान्नुहोस् - विकेन्द्रित मेष सन्देश अन्त्यदेखि अन्त्य इन्क्रिप्सनसहित अफलाइन मेष च्याट इन्टरनेट वा सर्भर बिना सिधै Bluetooth LE मार्फत सञ्चार गर्नुहोस्। नजिकका यन्त्रहरूबाट सन्देश रिल레이 हुन्छ। अनलाइन Geohash च्यानलहरू Geohash आधारित च्यानलमार्फत नजिकका मानिसहरूसँग जोडिनुहोस्। सार्वजनिक इन्टरनेट रिलेलाई प्रयोग गरेर मेष विस्तार गर्नुहोस्। एन्ड‑टु‑एन्ड इन्क्रिप्सन निजी सन्देश इन्क्रिप्टेड हुन्छन्। च्यानल सन्देश सार्वजनिक हुन्छन्। - देखावट सिस्टम हल्का गाढा @@ -127,7 +121,6 @@ Tor स्थिति: %1$s, बुटस्ट्र्याप %2$d%% अन्तिम: %1$s आपतकालीन डेटा मेटाउने - सुझाव: एप शीर्षक तीन पटक ट्याप गरी सबै डेटा (सन्देश, कुञ्जी, सेटिङ) मेटाउनुहोस्। डिबग सेटिङ Open Source • Privacy First • विकेन्द्रित बन्द गर्नुहोस् @@ -147,7 +140,6 @@ नक्सा खोल्नुहोस् बुकमार्क हटाउनुहोस् टेलिपोर्ट - छानिएको च्यानल छोड्नुहोस् Nostr बाट पहुँचयोग्य मनपर्ने (अफलाइन) @@ -281,7 +273,6 @@ bitchat ले तपाईंको स्थान पछ्याउँदैन @ · ⧉ - मानिसहरू वरिपरि कोही छैन… (तपाईं) geohash छान्न प्यान र जुम गर्नुहोस् @@ -290,7 +281,6 @@ @%1$s उल्लेख %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 38385885..0eeaec55 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -71,12 +71,8 @@ Bladwijzer toevoegen - terug - kanaal: %1$s - verlaten Bereikbaar via Nostr Ongelezen privéberichten - Bladwijzer omschakelen Geteleporteerd Tor-status Verbonden peers @@ -96,14 +92,12 @@ Afbeelding kiezen - gedecentraliseerde meshcommunicatie met end-to-end-versleuteling Offline meshchat Communiceer rechtstreeks via Bluetooth LE zonder internet of servers. Berichten worden doorgestuurd via apparaten in de buurt om het bereik te vergroten. Online geohash-kanalen Maak contact met mensen in uw omgeving met geohash-gebaseerde kanalen. Breid het mesh uit met openbare internetrelays. End-to-end-versleuteling Privéberichten zijn versleuteld. Kanaalberichten zijn openbaar. - uiterlijk systeem licht donker @@ -127,7 +121,6 @@ Tor-status: %1$s, bootstrap %2$d%% Laatste: %1$s Noodgegevensverwijdering - Tip: Driedubbel klik op de app-titel om alle opgeslagen gegevens inclusief berichten, sleutels en instellingen noodmatig te verwijderen. Debug-instellingen Open Source • Privacy First • Gedecentraliseerd Sluiten @@ -147,7 +140,6 @@ Kaart openen Bladwijzer verwijderen Teleporteren - Geselecteerd Kanaal verlaten Bereikbaar via Nostr Offline favoriet @@ -286,14 +278,12 @@ toestemmingen Toestemmingen verlenen bitchat volgt NIET uw locatie - PERSONEN niemand in de buurt… (jij) pan en zoom om een geohash te selecteren selecteren typ een bericht… vermelden - (~%1$s) v%1$s image/* Afbeelding @@ -307,11 +297,6 @@ Afspelen Pauzeren - Mining-PoW - PoW ingeschakeld - Proof of Work - minen… - pow: %1$dbit Vereist om bitchat-gebruikers via Bluetooth te ontdekken diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index e92d2d51..5f5f5f75 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -71,12 +71,8 @@ بُک مارک پاؤ - واپس - چینل: %1$s - باہر آؤ Nostr راہین پہنچ ممکن اے انہ پڑھیا ذاتی پیغام - بُک مارک ٹوگل کرو ٹیلپورٹ کیتا گیا Tor حالت جُڑے پیئر @@ -96,14 +92,12 @@ تصویر چُنو - اینڈ ٹو اینڈ انکرپشن نال غیر مرکزی میش چیٹ آف لائن میش چیٹ انٹرنیٹ یا سرور توں بغیر Bluetooth LE راہین سیدھا رابطہ کرو۔ رینج ودھاؤن لئی پیغام نیڑے ڈیوائساں راہین ریلے ہوندے نیں۔ آن لائن geohash چینل geohash بیسڈ چینلاں راہین اپنے علاقے دے لوکاں نال جڑو۔ پبلک انٹرنیٹ ریلے راہین میش نوں وسیع کرو۔ اینڈ ٹو اینڈ انکرپشن ذاتی پیغام انکرپٹڈ ہوندے نیں۔ چینل پیغام پبلک ہوندے نیں۔ - دِکھ سسٹم ہلکا گھُپ @@ -127,7 +121,6 @@ Tor حالت: %1$s، بوٹ اسٹرَیپ %2$d%% آخری: %1$s ایمرجنسی ڈیٹا صاف - ٹِپ: سارے محفوظ ڈیٹا (پیغام، کُنجیاں، سیٹنگا) مٹاؤن لئی ایپ دے عنوان تے تین وار ٹیپ کرو۔ ڈی بگ سیٹنگا اوپن سورس • پرائیویسی پہلے • غیر مرکزی بند کرو @@ -147,7 +140,6 @@ نقشہ کھولو بُک مارک ہٹاؤ ٹیلپورٹ - چُنے ہوۓ چینل چھوڑو Nostr راہین پہنچ ممکن اے پسندیدہ (آف لائن) @@ -286,14 +278,12 @@ اجازتاں اجازت دو bitchat توانڈی لوکیشن نئیں ٹریک کردا - لوگ کوئی وی نیڑے نئیں … (تُسی) geohash چُنن لئی پین تے زوم کرو چُنو پیغام لکھو … ذکر - (~%1$s) v%1$s image/* تصویر @@ -307,11 +297,6 @@ چلاؤ روکੋ - PoW مائننگ - PoW چالو - Proof of Work - مائننگ … - pow: %1$dbit بلوٹوتھ راہین bitchat یوزر لبھن لئی ضروری diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 67503cc6..fd4901ef 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -71,12 +71,8 @@ Adicionar favorito - voltar - Canal: %1$s - sair Acessível via Nostr Mensagens privadas não lidas - Alternar favorito Teletransportado Status do Tor Peers conectados @@ -96,14 +92,12 @@ Escolher imagem - chat mesh descentralizado com criptografia de ponta a ponta Chat mesh offline Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas por dispositivos próximos para ampliar o alcance. Canais geohash online Conecte-se com pessoas próximas através de canais baseados em geohash. Expanda a mesh por meio de relays públicos na internet. Criptografia de ponta a ponta Mensagens privadas são criptografadas. Mensagens de canal são públicas. - Aparência Sistema Claro Escuro @@ -127,7 +121,6 @@ Status do Tor: %1$s, bootstrap %2$d%% Último: %1$s Limpeza de dados de emergência - Dica: Toque 3 vezes no título do app para apagar todos os dados salvos – incluindo mensagens, chaves e configurações. Configurações de depuração Código aberto • Privacidade em primeiro lugar • Descentralizado Fechar @@ -147,7 +140,6 @@ Abrir mapa Remover favorito Teletransportar - Selecionado Sair do canal Acessível via Nostr Favorito (offline) @@ -286,14 +278,12 @@ Permissões Conceder permissões O bitchat NÃO rastreia sua localização - PESSOAS ninguém por perto… (você) arraste/amplie para selecionar um geohash selecionar Digite uma mensagem… mencionar - (~%1$s) v%1$s image/* Imagem @@ -307,11 +297,6 @@ Reproduzir Pausar - Minerando PoW - PoW ativado - Prova de Trabalho - Minerando… - pow: %1$dbit Necessário para descobrir usuários do bitchat via Bluetooth diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3bcad4ab..5247916e 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -71,12 +71,8 @@ Adicionar marcador - voltar - Canal: %1$s - sair Alcançável via Nostr Mensagens privadas não lidas - Alternar marcador Teletransportado Estado do Tor Pares ligados @@ -96,14 +92,12 @@ Escolher imagem - chat mesh descentralizado com encriptação ponto a ponto Chat mesh offline Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas através de dispositivos próximos para estender o alcance. Canais geohash online Conecte-se com pessoas perto de si através de canais baseados em geohash. Estenda a mesh através de repetidores de internet pública. Encriptação ponto a ponto Mensagens privadas são encriptadas. Mensagens de canal são públicas. - Aparência Sistema Claro Escuro @@ -127,7 +121,6 @@ Estado do Tor: %1$s, bootstrap %2$d%% Último: %1$s Limpeza de dados de emergência - Dica: Toque três vezes no título da aplicação para apagar todos os dados guardados – incluindo mensagens, chaves e definições. Definições de depuração Código aberto • Privacidade primeiro • Descentralizado Fechar @@ -147,7 +140,6 @@ Abrir mapa Remover marcador Teletransportar - Selecionado Sair do canal Alcançável via Nostr Favorito (offline) @@ -286,14 +278,12 @@ Permissões Conceder permissões O bitchat NÃO rastreia a sua localização - PESSOAS ninguém por perto… (você) arraste e amplie para selecionar um geohash selecionar Digite uma mensagem… mencionar - (~%1$s) v%1$s image/* Imagem @@ -307,11 +297,6 @@ Reproduzir Pausar - A minerar PoW - PoW ativado - Prova de Trabalho - A minerar… - pow: %1$dbit Necessário para descobrir utilizadores bitchat via Bluetooth diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9ce4a560..4dc08928 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -63,12 +63,8 @@ Убрать из избранного Добавить закладку - назад - канал: %1$s - выйти Доступен через Nostr Непрочитанные личные - Переключить закладку Телепортировано Статус Tor Подключённые узлы @@ -86,14 +82,12 @@ ❌ Закрыть Выбрать изображение - децентрализованный mesh‑чат со сквозным шифрованием Оффлайн Mesh‑чат Общение напрямую по Bluetooth LE без интернета и серверов. Сообщения ретранслируются близлежащими устройствами. Онлайн‑каналы Geohash Общайся с людьми поблизости через каналы на основе geohash. Расширяй mesh через публичные ретрансляторы. Сквозное шифрование Личные сообщения — зашифрованы. Канальные — публичны. - оформление система светлая тёмная @@ -117,7 +111,6 @@ статус Tor: %1$s, загрузка %2$d%% Последнее: %1$s Экстренное удаление данных - Совет: тройной тап по заголовку — удалить все данные (сообщения, ключи, настройки). Настройки отладки Open Source • Privacy First • Децентрализация Закрыть @@ -136,7 +129,6 @@ Открыть карту Удалить закладку Телепорт - Выбрано Покинуть канал Доступен через Nostr Избранное (оффлайн) @@ -265,7 +257,6 @@ bitchat НЕ отслеживает вашу геопозицию @ · ⧉ - ЛЮДИ никого рядом… (вы) прокрутите/масштабируйте для выбора geohash @@ -274,7 +265,6 @@ @%1$s упоминание %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 566f940e..dcfda208 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -63,12 +63,8 @@ Ta bort favorit Lägg till bokmärke - tillbaka - kanal: %1$s - lämna Nås via Nostr Olästa privata meddelanden - Växla bokmärke Teleporterad Tor‑status Anslutna noder @@ -86,14 +82,12 @@ ❌ Stäng Välj bild - decentraliserad mesh‑meddelanden med ända‑till‑ända‑kryptering Offline Mesh‑chatt Kommunicera direkt via Bluetooth LE utan internet eller servrar. Meddelanden reläas av enheter i närheten. Online Geohash‑kanaler Anslut med personer i närheten via geohash‑kanaler. Utöka mesh via publika internetreläer. Ända‑till‑ända‑kryptering Privata meddelanden är krypterade. Kanalmeddelanden är publika. - utseende system ljus mörk @@ -117,7 +111,6 @@ Tor‑status: %1$s, bootstrap %2$d%% Senast: %1$s Nödradering av data - Tips: tryck tre gånger på appens titel för att radera alla data (meddelanden, nycklar, inställningar). Felsökningsinställningar Open Source • Privacy First • Decentraliserad Stäng @@ -136,7 +129,6 @@ Öppna karta Ta bort bokmärke Teleportera - Vald Lämna kanal Nås via Nostr Favorit (offline) @@ -265,7 +257,6 @@ bitchat spårar INTE din plats @ · ⧉ - PERSONER ingen i närheten… (du) panorera och zooma för att välja geohash @@ -274,7 +265,6 @@ @%1$s nämn %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 817cd335..7e1fc05a 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -71,12 +71,8 @@ เพิ่มที่คั่นหน้า - กลับ - ช่อง: %1$s - ออก ติดต่อได้ผ่าน Nostr ข้อความส่วนตัวยังไม่ได้อ่าน - สลับที่คั่นหน้า เทเลพอร์ตแล้ว สถานะ Tor เพียร์ที่เชื่อมต่อ @@ -96,14 +92,12 @@ เลือกภาพ - แชทเมชแบบกระจายศูนย์พร้อมการเข้ารหัสแบบปลายทางถึงปลายทาง แชทเมชออฟไลน์ สื่อสารโดยตรงผ่าน Bluetooth LE โดยไม่ใช้อินเทอร์เน็ตหรือเซิร์ฟเวอร์ ข้อความจะถูกส่งต่อผ่านอุปกรณ์ใกล้เคียงเพื่อขยายระยะ ช่อง geohash ออนไลน์ เชื่อมต่อกับผู้คนใกล้คุณผ่านช่องที่อ้างอิง geohash ขยายเมชผ่านรีเลย์อินเทอร์เน็ตสาธารณะ การเข้ารหัสแบบปลายทางถึงปลายทาง ข้อความส่วนตัวถูกเข้ารหัส ข้อความในช่องเป็นสาธารณะ - ลักษณะ ระบบ สว่าง มืด @@ -127,7 +121,6 @@ สถานะ Tor: %1$s, การบูต %2$d%% ล่าสุด: %1$s ลบข้อมูลฉุกเฉิน - เคล็ดลับ: แตะชื่อแอปสามครั้งเพื่อลบข้อมูลที่บันทึกทั้งหมด – รวมทั้งข้อความ กุญแจ และการตั้งค่า การตั้งค่าดีบัก โอเพนซอร์ส • ความเป็นส่วนตัวมาก่อน • กระจายศูนย์ ปิด @@ -147,7 +140,6 @@ เปิดแผนที่ เอาที่คั่นหน้าออก เทเลพอร์ต - ที่เลือก ออกจากช่อง ติดต่อได้ผ่าน Nostr รายการโปรด (ออฟไลน์) @@ -286,14 +278,12 @@ สิทธิ์ ให้สิทธิ์ bitchat ไม่ติดตามตำแหน่งของคุณ - ผู้คน ไม่มีใครอยู่ใกล้… (คุณ) ลาก/ซูมเพื่อเลือก geohash เลือก พิมพ์ข้อความ… กล่าวถึง - (~%1$s) v%1$s image/* รูปภาพ @@ -307,11 +297,6 @@ เล่น หยุดชั่วคราว - กำลังขุด PoW - เปิดใช้ PoW - Proof of Work - กำลังขุด… - pow: %1$dbit จำเป็นสำหรับการค้นหาผู้ใช้ bitchat ผ่านบลูทูธ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 93888483..b81fdd08 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -63,12 +63,8 @@ Sık kullanılandan kaldır Yer imi ekle - geri - kanal: %1$s - ayrıl Nostr üzerinden ulaşılabilir Okunmamış özel mesaj - Yer imini değiştir Teleport edildi Tor durumu Bağlı eşler @@ -86,14 +82,12 @@ ❌ Kapat Görsel seç - uçtan uca şifrelemeli merkezsiz mesh mesajlaşma Çevrimdışı Mesh Sohbeti İnternet/ sunucu olmadan doğrudan Bluetooth LE ile iletişim kur. Mesajlar yakındaki cihazlarca aktarılır. Çevrimiçi Geohash Kanalları Geohash tabanlı kanallarla yakınındaki insanlarla bağlantı kur. Herkese açık internet röleleriyle mesh’i genişlet. Uçtan Uca Şifreleme Özel mesajlar şifrelidir. Kanal mesajları herkese açıktır. - görünüm sistem açık koyu @@ -117,7 +111,6 @@ Tor durumu: %1$s, önyükleme %2$d%% Son: %1$s Acil veri silme - İpucu: tüm verileri silmek için uygulama başlığına üç kez dokun (mesajlar, anahtarlar, ayarlar). Hata ayıklama ayarları Open Source • Privacy First • Merkezsiz Kapat @@ -136,7 +129,6 @@ Haritayı aç Yer imini kaldır Teleport - Seçildi Kanaldan ayrıl Nostr üzerinden erişilebilir Favori (çevrimdışı) @@ -265,7 +257,6 @@ bitchat konumunu takip etmez @ · ⧉ - KİŞİLER yakında kimse yok… (sen) geohash seçmek için kaydır/zoom yap @@ -274,7 +265,6 @@ @%1$s bahset %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 3f1c735a..36c5265d 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -71,12 +71,8 @@ بک مارک شامل کریں - واپس - چینل: %1$s - چھوڑیں Nostr کے ذریعے رسائی غیر پڑھے ہوئے نجی پیغامات - بک مارک تبدیل کریں ٹیلی پورٹ Tor کی کیفیت منسلک پیئرز @@ -96,14 +92,12 @@ تصویر منتخب کریں - غیر مرکزی میش چیٹ • اینڈ‑ٹو‑اینڈ انکرپشن آف لائن میش چیٹ انٹرنیٹ یا سرورز کے بغیر Bluetooth LE کے ذریعے براہ راست بات چیت کریں۔ پیغامات قریبی آلات کے ذریعے ریلے ہوتے ہیں تاکہ رسائی بڑھے۔ آن لائن geohash چینلز geohash پر مبنی چینلز کے ذریعے اپنے قریب کے لوگوں سے جڑیں۔ عوامی انٹرنیٹ ریلے کے ذریعے میش کو پھیلائیں۔ اینڈ‑ٹو‑اینڈ انکرپشن نجی پیغامات انکرپٹڈ ہیں۔ چینل پیغامات عوامی ہیں۔ - ظاہری شکل سسٹم ہلکا گہرا @@ -127,7 +121,6 @@ Tor کی کیفیت: %1$s، بوٹ اسٹریپ %2$d%% آخری: %1$s ہنگامی ڈیٹا صافی - تجویز: تمام محفوظ شدہ ڈیٹا – پیغامات، کلیدیں اور سیٹنگز سمیت – صاف کرنے کے لیے ایپ کے ٹائٹل پر تین بار ٹیپ کریں۔ ڈیبگ سیٹنگز اوپن سورس • پرائیویسی پہلے • غیر مرکزی بند کریں @@ -147,7 +140,6 @@ نقشہ کھولیں بک مارک ہٹائیں ٹیلی پورٹ - منتخب چینل چھوڑیں Nostr کے ذریعے رسائی پسندیدہ (آف لائن) @@ -286,14 +278,12 @@ اجازات اجازات دیں bitchat آپ کا مقام ٹریک نہیں کرتا - لوگ یہاں کوئی نہیں… (آپ) geohash منتخب کرنے کے لیے پین/زوم کریں منتخب کریں پیغام لکھیں… ذکر - (~%1$s) v%1$s image/* تصویر @@ -307,11 +297,6 @@ چلائیں روکیں - PoW مائننگ - PoW فعال - پروف آف ورک - مائننگ… - pow: %1$dbit بلوٹوتھ کے ذریعے bitchat صارفین کی دریافت کے لیے ضروری diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 4dd4ad37..20028f38 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -71,12 +71,8 @@ Thêm bookmark - quay lại - Kênh: %1$s - rời khỏi Có thể tiếp cận qua Nostr Tin nhắn riêng chưa đọc - Bật/tắt bookmark Đã dịch chuyển Trạng thái Tor Peers đã kết nối @@ -96,14 +92,12 @@ Chọn hình ảnh - trò chuyện mesh phi tập trung với mã hóa đầu cuối Trò chuyện Mesh ngoại tuyến Liên lạc trực tiếp qua Bluetooth LE mà không cần Internet hoặc máy chủ. Tin nhắn được chuyển tiếp qua các thiết bị gần để mở rộng phạm vi. Kênh Geohash trực tuyến Kết nối với những người trong khu vực của bạn qua các kênh dựa trên geohash. Mở rộng mesh qua các relay Internet công cộng. Mã hóa đầu cuối Tin nhắn riêng được mã hóa. Tin nhắn kênh là công khai. - Giao diện Hệ thống Sáng Tối @@ -127,7 +121,6 @@ Trạng thái Tor: %1$s, Bootstrap %2$d%% Cuối cùng: %1$s Xóa dữ liệu khẩn cấp - Mẹo: Nhấn ba lần vào tiêu đề ứng dụng để xóa tất cả dữ liệu đã lưu – bao gồm tin nhắn, khóa và cài đặt. Cài đặt debug Mã nguồn mở • Quyền riêng tư trước tiên • Phi tập trung Đóng @@ -147,7 +140,6 @@ Mở bản đồ Xóa bookmark Dịch chuyển - Đã chọn Rời khỏi kênh Có thể tiếp cận qua Nostr Yêu thích (ngoại tuyến) @@ -286,14 +278,12 @@ Quyền Cấp quyền bitchat KHÔNG theo dõi vị trí của bạn - MỌI NGƯỜI không có ai xung quanh … (bạn) di chuyển và zoom để chọn geohash chọn Nhập tin nhắn … nhắc đến - (~%1$s) v%1$s image/* Hình ảnh @@ -307,11 +297,6 @@ Phát Tạm dừng - Đang mining PoW - PoW đã bật - Proof of Work - Đang mining … - pow: %1$dbit Cần thiết để khám phá người dùng bitchat qua Bluetooth diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index f8879cc8..d6eada8c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -71,12 +71,8 @@ 添加书签 - 返回 - 频道:%1$s - 离开 可通过 Nostr 联系 未读私信 - 切换书签 已传送 Tor 状态 已连接节点 @@ -96,14 +92,12 @@ 选择图片 - 去中心化 mesh 消息,端到端加密 离线 Mesh 聊天 无需网络或服务器,通过 Bluetooth LE 直接通信。利用附近设备中继消息以扩展范围。 在线 Geohash 频道 使用基于 geohash 的频道与附近的人交流。通过公共互联网中继扩展 mesh。 端到端加密 私信加密;频道消息为公开。 - 外观 系统 浅色 深色 @@ -127,7 +121,6 @@ Tor 状态:%1$s,引导 %2$d%% 最近:%1$s 紧急数据删除 - 提示:三击应用标题以删除所有数据,包括消息、密钥和设置。 调试设置 Open Source • Privacy First • 去中心化 关闭 @@ -147,7 +140,6 @@ 打开地图 移除书签 传送 - 已选择 离开频道 可通过 Nostr 联系 收藏(离线) @@ -281,7 +273,6 @@ bitchat 不会跟踪你的位置 @ · ⧉ - 成员 附近无人… (你) 拖动/缩放以选择 geohash @@ -290,7 +281,6 @@ @%1$s 提及 %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd316e59..02393f74 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,8 +4,8 @@ Bluetooth permission is required for peer-to-peer messaging without internet. Location permission is required to discover nearby devices via Bluetooth. Notification permission is required to alert you of new messages. - nickname - type a message… + Nickname + Type a message… Password Join Channel Leave @@ -54,7 +54,7 @@ Messages in %1$s %1$s joined the conversation bitchat - %1$d mentions - bitchat - location chats + bitchat - Location chats %1$d messages from %2$d locations Mentioned in Mesh Chat %1$d mentions in Mesh Chat @@ -72,12 +72,8 @@ Add bookmark - back - channel: %1$s - leave Nostr reachable Unread private messages - Toggle bookmark Location notes Teleported Tor status @@ -109,43 +105,75 @@ Pick file - decentralized mesh messaging with end-to-end encryption - Offline Mesh Chat - Communicate directly via Bluetooth LE without internet or servers. Messages relay through nearby devices to extend range. - Online Geohash Channels - Connect with people in your area using geohash-based channels. Extend the mesh using public internet relays. + The sidegroup chat + + + Info + Settings + + + How To Use + Set your nickname by tapping it + Tap the channel name to change channels + Tap the people icon to see who\'s in the channel + Bookmark a channel to keep it in your list + Type @ to mention someone + Type / to see the available commands + Triple-tap the logo to wipe all data + + + About + Theme + Settings + + + No Tracking + No servers, accounts, or data collection. + Ephemeral Identity + A new peer ID is generated regularly. + Triple-tap the logo to instantly clear all data. + + + Tor Network + Difficulty + %1$d bits • %2$s + Connected (%1$d%%) + Disconnected + + Offline Communication + Chat over a Bluetooth mesh with no internet and no servers. Messages relay through nearby devices to extend range. + Local Channels + Geohash channels connect you with people nearby, extending the mesh over public relays. End-to-End Encryption Private messages are encrypted. Channel messages are public. - appearance - system - light - dark - proof of work - pow off - pow on - add proof of work to geohash messages for spam deterrence. - difficulty: %1$d bits (~%2$s) - difficulty %1$d requires ~%2$s hash attempts - no proof of work required - very low - minimal spam protection - low - basic spam protection - medium - good spam protection - high - strong spam protection - very high - may cause delays - extreme - significant computation required - network - tor off - tor on - route internet over tor for enhanced privacy. - tor Status: %1$s, bootstrap %2$d%% + System + Light + Dark + Proof of Work + PoW Off + PoW On + Add proof of work to geohash messages for spam deterrence. + Difficulty: %1$d bits (~%2$s) + Difficulty %1$d requires ~%2$s hash attempts + No proof of work required + Very low – minimal spam protection + Low – basic spam protection + Medium – good spam protection + High – strong spam protection + Very high – may cause delays + Extreme – significant computation required + Network + Tor Off + Tor On + Route internet over Tor for enhanced privacy. + Tor Status: %1$s, bootstrap %2$d%% Last: %1$s - Emergency Data Deletion - Tip: Triple-click the app title to emergency delete all stored data including messages, keys, and settings. + Panic Mode Debug Settings Open Source • Privacy First • Decentralized Close Privacy Protected - cancel + Cancel Share BitChat @@ -226,10 +254,9 @@ Open map Remove bookmark Teleport - Selected Quit bitchat - run in background - keep mesh active when app is closed (foreground service) + Run in Background + Keep mesh active when app is closed (foreground service) Leave channel Reachable via Nostr Offline favorite @@ -243,15 +270,11 @@ Link Record voice note Pick media - Mining PoW - PoW Enabled - Proof of Work - mining... - pow: %1$dbit Offline Mesh Chat Online Geohash Channels End-to-End Encryption - #bluetooth • %1$s + Bluetooth • %1$s + Bluetooth • Wifi • %1$s Image %1$d of %2$d Image unavailable @@ -259,44 +282,61 @@ Failed to save image Pick file [file unavailable] - unknown + Unknown - choose an action for this message or user - choose an action for this user - copy message - copy this message to clipboard - slap %1$s - send a playful slap message - hug %1$s - send a friendly hug message - block %1$s - block all messages from this user - message %1$s - send a private message + Choose an action for this message or user + Choose an action for this user + Copy message + Copy this message to clipboard + Slap %1$s + Send a playful slap message + Hug %1$s + Send a friendly hug message + Block %1$s + Block all messages from this user + Message %1$s + Send a private message - #location channels - chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. do not screenshot or share this screen to protect your privacy. - grant location permission - location permission denied. enable in settings to use location channels. + Channels + Location Channels + Chat by coarse location. Never shares exact GPS. + Nearby + Offline mesh. No internet required. - \u2713 location permission granted - checking permissions... - finding nearby channels… - bookmarked + + Nearby + Tor Routing + Hides your IP for location channels. + Recommended + + + People (%1$d) + People + On location + Teleported in + + Grant location permission + Location permission denied. Enable in settings to use location channels. + + \u2713 Location permission granted + Checking permissions… + Finding nearby channels… + Bookmarked geohash - invalid geohash - teleport - disable location services - enable location services + Invalid geohash + Teleport + Remove location access + Enable location services mesh - block - neighborhood - city - province - region + Mesh + Block + Neighborhood + City + Province + Region check for notes left here @@ -307,89 +347,89 @@ #%1$s ± 1 • %2$d note #%1$s ± 1 • %2$d notes - add short permanent notes to this location for other visitors to find. - geo relays unavailable; notes paused - no geo relays nearby - notes rely on geo relays. check connection and try again. - loading notes… - no notes yet - be the first to add one for this spot. - dismiss - add a note for this place + Add short permanent notes to this location for other visitors to find. + Geo relays unavailable; notes paused + No geo relays nearby + Notes rely on geo relays. Check connection and try again. + Loading notes… + No notes yet + Be the first to add one for this spot. + Dismiss + Add a note for this place - debug tools - developer utilities for diagnostics and control - verbose logging - logs peer joins/leaves, connection direction, packet routing and relays - bluetooth roles - gatt server - connections: %1$d / %2$d - max server - gatt client - max client - connections: %1$d / %2$d - max overall - packet relay - since start: %1$d - turn roles on/off and close all connections when disabled - sync settings - max packets per sync: %1$d - max GCS filter size: %1$d bytes (128–1024) - target FPR: %1$.2f%% - connected devices - our device id: %1$s - none - disconnect - recent scan results + Debug Tools + Developer utilities for diagnostics and control + Verbose logging + Logs peer joins/leaves, connection direction, packet routing and relays + Bluetooth roles + GATT server + Connections: %1$d / %2$d + Max server + GATT client + Max client + Connections: %1$d / %2$d + Max overall + Packet relay + Since start: %1$d + Turn roles on/off and close all connections when disabled + Sync settings + Max packets per sync: %1$d + Max GCS filter size: %1$d bytes (128–1024) + Target FPR: %1$.2f%% + Connected devices + Our device ID: %1$s + None + Disconnect + Recent scan results - verify - scan to verify me - scan someone elses qr - scan someone elses qr - show my qr - remove verification - qr unavailable - camera permission is needed to scan qr codes - enable camera - paste verification url - validate - verification requested - security verification - their fingerprint - your fingerprint - handshake pending - open a private chat to view fingerprints - encrypted & verified - encrypted - handshaking - handshake failed - not encrypted - verified - you have verified this persons identity. - not verified - compare these fingerprints with %1$s using a secure channel. - mark as verified - start handshake - copy + Verify + Scan to verify me + Scan someone else\'s QR + Scan someone else\'s QR + Show my QR + Remove verification + QR unavailable + Camera permission is needed to scan QR codes + Enable camera + Paste verification URL + Validate + Verification requested + Security verification + Their fingerprint + Your fingerprint + Handshake pending + Open a private chat to view fingerprints + Encrypted & verified + Encrypted + Handshaking + Handshake failed + Not encrypted + Verified + You have verified this person\'s identity. + Not verified + Compare these fingerprints with %1$s using a secure channel. + Mark as verified + Start handshake + Copy Mutual verification You and %1$s verified each other - mutual verification with %1$s + Mutual verification with %1$s Verified You verified %1$s - verified %1$s + Verified %1$s - connect - debug console - clear - last 10s: %1$d • 1m: %2$d • 15m: %3$d - derived P: %1$s • est. max elements: %2$s + Connect + Debug console + Clear + Last 10s: %1$d • 1m: %2$d • 15m: %3$d + Derived P: %1$s • est. max elements: %2$s • direct RSSI: %1$s ? - as server (we host) - as client (we connect) + As server (we host) + As client (we connect) Location Services Required @@ -398,11 +438,11 @@ bitchat needs location services for: • Bluetooth device scanning\n• Discovering nearby users on mesh network\n• Geohash chat feature\n• No tracking or location collection Background Location Recommended - optional, improves mesh reliability + Optional, improves mesh reliability Android recommends background location so bitchat can scan for nearby devices while the app is not open. This keeps the mesh alive after reboot. When settings opens, choose "Allow all the time". bitchat uses background location for: - - scan for nearby devices while the app is closed\n- reconnect after reboot\n- keep the mesh running in the background + - Scan for nearby devices while the app is closed\n- Reconnect after reboot\n- Keep the mesh running in the background We NEVER collect or store your location. Your privacy is safe. Allow Background Location Continue without background location @@ -418,13 +458,13 @@ Bluetooth Not Supported This device doesn\'t support Bluetooth Low Energy (BLE), which is required for bitchat to function.\n\nbitchat needs BLE to create mesh networks and communicate with nearby devices without internet. Checking Bluetooth status... - battery optimization detected + Battery optimization detected Battery Optimization Enabled - bitchat needs to run in the background to maintain mesh connections. battery optimization can interrupt these connections. + bitchat needs to run in the background to maintain mesh connections. Battery optimization can interrupt these connections. Benefits of Disabling - • reliable message delivery\n• maintains mesh connectivity\n• prevents connection drops + • Reliable message delivery\n• Maintains mesh connectivity\n• Prevents connection drops Disable Battery Optimization - battery optimization disabled + Battery optimization disabled Continue Initializing mesh network . @@ -435,23 +475,21 @@ Try Again Open Settings Your Privacy is Protected - • no tracking or data collection\n• Bluetooth mesh chats are fully offline\n• Geohash chats use the internet - permissions + • No tracking or data collection\n• Bluetooth mesh chats are fully offline\n• Geohash chats use the internet + Permissions Grant Permissions bitchat does NOT track your location @ Open About · ⧉ - PEOPLE - nobody around... + Nobody around… (you) - pan and zoom to select a geohash - select - type a message... + Pan and zoom to select a geohash + Select + Type a message… @%1$s - mention + Mention %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index 304166c0..e64448c3 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -1,44 +1,558 @@ package com.bitchat.android.ui +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.ChatVisualTokens +import com.bitchat.android.ui.theme.DarkBitchatColorScheme +import com.bitchat.android.ui.theme.DarkBitchatPalette +import com.bitchat.android.ui.theme.LightBitchatColorScheme +import com.bitchat.android.ui.theme.LightBitchatPalette +import com.bitchat.android.ui.theme.MessageBodyTextStyle +import com.bitchat.android.ui.theme.MessageSenderTextStyle +import com.bitchat.android.ui.theme.colorForPeer import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +/** + * Runs under Robolectric because URL detection in message bodies goes through + * `android.util.Patterns.WEB_URL`, which is null on a bare JVM. + */ +@RunWith(RobolectricTestRunner::class) class ChatUIUtilsTest { - private val timeFormatter = SimpleDateFormat("HH:mm:ss", Locale.ROOT).apply { + private val timeFormatter = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.ROOT).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } + private val palette = DarkBitchatPalette + private val colorScheme = DarkBitchatColorScheme + + private fun message( + content: String, + sender: String = "alice", + powDifficulty: Int? = null, + ) = BitchatMessage( + sender = sender, + content = content, + timestamp = Date(0), + powDifficulty = powDifficulty, + ) + + // MARK: - Timestamp metadata + @Test fun `text message metadata separates PoW badge with one space`() { - val message = BitchatMessage( - sender = "alice", - content = "hello", - timestamp = Date(0), - powDifficulty = 12, - ) - assertEquals( - "00:00:00 ⛨12b", - formatTextMessageMetadata(message, timeFormatter).text, + "00:00 ⛨12b", + formatTextMessageMetadata(message("hello", powDifficulty = 12), timeFormatter).text, ) } @Test fun `text message metadata omits non-positive PoW difficulty`() { - val message = BitchatMessage( - sender = "alice", - content = "hello", - timestamp = Date(0), - powDifficulty = 0, - ) - assertEquals( - "00:00:00", - formatTextMessageMetadata(message, timeFormatter).text, + "00:00", + formatTextMessageMetadata(message("hello", powDifficulty = 0), timeFormatter).text, ) } + + // MARK: - Body with inline trailing timestamp + + @Test + fun `body appends timestamp inline after the message text`() { + val body = formatTextMessageBody( + message = message("hello there"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + ) + + assertEquals("hello there 00:00", body.text) + val timestamp = body.spanStyles.first { + body.text.substring(it.start, it.end) == " 00:00" + }.item + assertEquals(10.sp, timestamp.fontSize) + assertEquals(FontWeight.Normal, timestamp.fontWeight) + assertEquals(palette.textTertiary, timestamp.color) + } + + @Test + fun `body appends PoW badge after the inline timestamp`() { + val body = formatTextMessageBody( + message = message("mined", powDifficulty = 8), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + ) + + assertEquals("mined 00:00 ⛨8b", body.text) + val timestampAndPow = body.spanStyles.first { + body.text.substring(it.start, it.end) == " 00:00 ⛨8b" + }.item + assertEquals(10.sp, timestampAndPow.fontSize) + assertEquals(palette.textTertiary, timestampAndPow.color) + } + + @Test + fun `body can omit the timestamp for callers that render it separately`() { + val body = formatTextMessageBody( + message = message("hello there"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + assertEquals("hello there", body.text) + } + + @Test + fun `body renders plain text in the neutral palette color, not terminal green`() { + val body = formatTextMessageBody( + message = message("plain words"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val textStyle = body.spanStyles.first { it.start == 0 } + assertEquals(colorScheme.onSurface, textStyle.item.color) + } + + @Test + fun `chat text styles match the exported type scale`() { + assertEquals(14.sp, MessageBodyTextStyle.fontSize) + assertEquals(20.sp, MessageBodyTextStyle.lineHeight) + assertEquals(FontWeight.Normal, MessageBodyTextStyle.fontWeight) + assertEquals(BitchatFontFamily, MessageBodyTextStyle.fontFamily) + assertEquals(14.sp, MessageSenderTextStyle.fontSize) + assertEquals(16.sp, MessageSenderTextStyle.lineHeight) + assertEquals(FontWeight.SemiBold, MessageSenderTextStyle.fontWeight) + } + + @Test + fun `body does not bold plain text for the sender's own messages`() { + // Regression guard: the old renderer bolded the entire body when the message was yours, + // and again when you were mentioned, which is what made busy channels unreadable. + val body = formatTextMessageBody( + message = message("my own words", sender = "bob"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + assertTrue( + "no span should force a bold weight", + body.spanStyles.none { it.item.fontWeight?.weight?.let { w -> w >= 700 } == true } + ) + } + + // MARK: - Mention chips + + private fun mentionChipSpans(body: androidx.compose.ui.text.AnnotatedString) = + body.spanStyles.filter { it.item.background.isSpecified() } + + private fun androidx.compose.ui.graphics.Color.isSpecified() = + this != androidx.compose.ui.graphics.Color.Unspecified && alpha > 0f + + @Test + fun `mention renders as a single contiguous background chip`() { + val body = formatTextMessageBody( + message = message("hey @carol#04af what's up"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val chips = mentionChipSpans(body) + assertEquals("expected exactly one chip", 1, chips.size) + + // The chip must cover "@carol#04af" as one run so it paints without seams. + val chip = chips.single() + assertEquals("@carol#04af", body.text.substring(chip.start, chip.end)) + } + + @Test + fun `mention targeting the current user uses the orange accent`() { + val body = formatTextMessageBody( + message = message("ping @bob now"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val chip = mentionChipSpans(body).single() + assertEquals(palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA_SELF), chip.item.background) + assertEquals(0.2f, chip.item.background.alpha) + + val nameStyle: SpanStyle = body.spanStyles + .first { it.start == chip.start && it.item.color == palette.accentOrange } + .item + assertEquals(palette.accentOrange, nameStyle.color) + } + + @Test + fun `mention of another user is tinted by that user's own peer color`() { + val pubkey = "0123456789abcdef".repeat(4) + val identity = PeerIdentity.nostr(pubkey) + val mentionPeerIdentities = buildMentionPeerIdentityMap( + messages = listOf( + BitchatMessage( + sender = "carol#04af", + content = "hello", + timestamp = Date(0), + senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, + ) + ) + ) + val body = formatTextMessageBody( + message = message("cc @carol#04af"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + mentionPeerIdentities = mentionPeerIdentities, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val expected = colorForPeer(identity, palette) + val chip = mentionChipSpans(body).single() + assertEquals(expected.copy(alpha = MENTION_CHIP_ALPHA), chip.item.background) + assertTrue(body.spanStyles.any { it.item.color == expected }) + } + + @Test + fun `composer colors nickname and hash suffix from the mentioned peer identity`() { + val pubkey = "0123456789abcdef".repeat(4) + val identity = PeerIdentity.nostr(pubkey) + val token = "@carol#04af" + val input = "ping $token now" + val transformed = MentionVisualTransformation( + mentionPeerIdentities = mapOf("carol#04af" to identity), + palette = palette, + ).filter(AnnotatedString(input)).text + + val expectedColor = colorForPeer(identity, palette) + val tokenStart = input.indexOf(token) + val suffixStart = input.indexOf("#04af") + val tokenEnd = tokenStart + token.length + + assertEquals(input, transformed.text) + assertTrue(transformed.spanStyles.any { + it.start == tokenStart && + it.end == tokenEnd && + it.item.background == expectedColor.copy(alpha = MENTION_CHIP_ALPHA) + }) + assertTrue(transformed.spanStyles.any { + it.start == tokenStart && + it.end == suffixStart && + it.item.color == expectedColor + }) + assertTrue(transformed.spanStyles.any { + it.start == suffixStart && + it.end == tokenEnd && + it.item.color == expectedColor.copy(alpha = SUFFIX_ALPHA) + }) + } + + @Test + fun `ambiguous base nickname is not assigned to the wrong peer`() { + val firstIdentity = PeerIdentity.nostr("11111111".repeat(8)) + val secondIdentity = PeerIdentity.nostr("22222222".repeat(8)) + val identities = buildMentionPeerIdentityMap( + messages = emptyList(), + knownPeers = listOf( + "alice#1111" to firstIdentity, + "alice#2222" to secondIdentity, + ) + ) + + assertEquals(firstIdentity, identities["alice#1111"]) + assertEquals(secondIdentity, identities["alice#2222"]) + assertFalse(identities.containsKey("alice")) + assertEquals( + firstIdentity, + resolveMentionPeerIdentity("@alice#1111", identities) + ) + assertEquals(null, resolveMentionPeerIdentity("@alice", identities)) + } + + @Test + fun `message without mentions has no background chips`() { + val body = formatTextMessageBody( + message = message("no mentions in here"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + assertTrue(mentionChipSpans(body).isEmpty()) + } + + // MARK: - System / action messages + + @Test + fun `system message uses a double-slash prefix and no brackets`() { + val text = formatSystemMessage( + message = message("Tor started. Routing all chats via Tor", sender = "system"), + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + ).text + + assertEquals("// Tor started. Routing all chats via Tor 00:00", text) + } + + @Test + fun `system message is not italic`() { + // The old treatment was `* italic asterisks *`, which competed visually with real + // messages despite being lower-priority narration. + val annotated = formatSystemMessage( + message = message("tor restarting", sender = "system"), + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + ) + + assertTrue(annotated.spanStyles.all { it.item.fontStyle == null }) + } + + @Test + fun `system action and timestamp use their exported weights sizes and opacity`() { + val annotated = formatSystemMessage( + message = message("tor restarting", sender = "system"), + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + ) + + val action = annotated.spanStyles.first { + annotated.text.substring(it.start, it.end) == "// tor restarting" + }.item + val time = annotated.spanStyles.first { + annotated.text.substring(it.start, it.end) == " 00:00" + }.item + + assertEquals(12.sp, action.fontSize) + assertEquals(FontWeight.Medium, action.fontWeight) + assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), action.color) + assertEquals(10.sp, time.fontSize) + assertEquals(FontWeight.Normal, time.fontWeight) + assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), time.color) + } + + // MARK: - Sender label + + @Test + fun `sender label drops angle brackets and dims the hash suffix`() { + val sender = formatTextMessageSender( + message = message("hi", sender = "carol#04af"), + currentUserNickname = "bob", + myPeerID = "peer-me", + palette = palette, + ) + + assertEquals("@carol#04af", sender.text) + + val suffixSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "#04af" } + val nameSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "@carol" } + assertNotNull(suffixSpan.item.color) + assertEquals(14.sp, nameSpan.item.fontSize) + assertEquals(FontWeight.SemiBold, nameSpan.item.fontWeight) + assertEquals(14.sp, suffixSpan.item.fontSize) + assertEquals(FontWeight.Normal, suffixSpan.item.fontWeight) + assertEquals(ChatVisualTokens.SenderSuffixAlpha, suffixSpan.item.color.alpha) + assertTrue( + "suffix must be dimmer than the name", + suffixSpan.item.color.alpha < nameSpan.item.color.alpha + ) + } + + @Test + fun `sender label annotates the nickname for others but not for yourself`() { + val other = formatTextMessageSender( + message = message("hi", sender = "carol#04af"), + currentUserNickname = "bob", + myPeerID = "peer-me", + palette = palette, + ) + assertEquals(1, other.getStringAnnotations("nickname_click", 0, other.length).size) + + val mine = formatTextMessageSender( + message = message("hi", sender = "bob"), + currentUserNickname = "bob", + myPeerID = "peer-me", + palette = palette, + ) + assertTrue(mine.getStringAnnotations("nickname_click", 0, mine.length).isEmpty()) + } + + // MARK: - Peer colors + + @Test + fun `peer identity factories normalize stable IDs without resolving UI colors`() { + assertEquals( + PeerIdentity.mesh("abcdef"), + PeerIdentity.mesh("ABCDEF") + ) + assertEquals( + PeerIdentity.nostr("abcdef"), + PeerIdentity.nostr("ABCDEF") + ) + assertEquals( + PeerIdentity.nostr("abcdef"), + PeerIdentity.nostr("nostr:nostr_ABCDEF") + ) + assertEquals( + "nostr:nostr:abcdef01", + PeerIdentity.nostr("ABCDEF0123456789").stableKey + ) + assertEquals("alice#1234", PeerIdentity.nickname("ALICE#1234").stableKey) + } + + @Test + fun `peer color hue is stable across light and dark, only chroma differs`() { + // Hue derivation must stay byte-identical to iOS; only saturation/value are tuned for + // the redesigned neutral message body. + val identity = PeerIdentity.mesh("abc") + val dark = colorForPeer(identity, DarkBitchatPalette) + val light = colorForPeer(identity, LightBitchatPalette) + + val darkHsv = FloatArray(3) + val lightHsv = FloatArray(3) + rgbToHsv(dark.red, dark.green, dark.blue, darkHsv) + rgbToHsv(light.red, light.green, light.blue, lightHsv) + + assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0) + assertEquals(1.0, darkHsv[1].toDouble(), 0.01) + assertEquals(1.0, darkHsv[2].toDouble(), 0.01) + assertEquals(0.85, lightHsv[1].toDouble(), 0.01) + assertEquals(0.45, lightHsv[2].toDouble(), 0.01) + } + + @Test + fun `peer color avoids the orange hue reserved for self`() { + // Sweep a range of seeds; none may land within the reserved orange band. + repeat(500) { i -> + val color = colorForPeer( + PeerIdentity.mesh("seed$i"), + DarkBitchatPalette + ) + val hsv = FloatArray(3) + rgbToHsv(color.red, color.green, color.blue, hsv) + val distanceFromOrange = kotlin.math.abs(hsv[0] - 30f) + assertTrue( + "seed$i resolved to ${hsv[0]}°, inside the reserved orange band", + distanceFromOrange >= 17f || hsv[1] < 0.01f + ) + } + } + + @Test + fun `material owns standard text while Bitchat palette owns peer chroma`() { + assertEquals(Color(0xFFF5F5F5), DarkBitchatColorScheme.onSurface) + assertTrue(LightBitchatColorScheme.onSurface != DarkBitchatColorScheme.onSurface) + assertTrue( + LightBitchatPalette.peerColorValue != DarkBitchatPalette.peerColorValue + ) + } + + @Test + fun `geohash chat and people sheet resolve the same full Nostr identity`() { + val pubkey = "ABCDEF0123456789".repeat(4) + val peopleIdentity = PeerIdentity.nostr(pubkey) + val chatIdentity = peerIdentityForMessage( + BitchatMessage( + sender = "alice#1234", + content = "hello", + timestamp = Date(0), + senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, + ) + ) + + assertEquals(peopleIdentity, chatIdentity) + assertEquals( + colorForPeer(peopleIdentity, palette), + colorForPeer(chatIdentity, palette) + ) + } + + @Test + fun `mesh chat and people sheet resolve the same peer identity`() { + val peerID = "ABCDEF0123456789" + val peopleIdentity = PeerIdentity.mesh(peerID) + val chatIdentity = peerIdentityForMessage( + BitchatMessage( + sender = "alice#1234", + content = "hello", + timestamp = Date(0), + senderPeerID = peerID, + ) + ) + + assertEquals(peopleIdentity, chatIdentity) + } + + @Test + fun `full Nostr identity wins over a truncated routing alias`() { + val pubkey = "0123456789ABCDEF".repeat(4) + val identity = peerIdentityForMessage( + BitchatMessage( + sender = "alice", + content = "hello", + timestamp = Date(0), + senderPeerID = "nostr_${pubkey.take(16)}", + senderNostrPubkey = pubkey, + ) + ) + + assertEquals(PeerIdentity.nostr(pubkey), identity) + } + + private fun rgbToHsv(r: Float, g: Float, b: Float, out: FloatArray) { + val max = maxOf(r, g, b) + val min = minOf(r, g, b) + val delta = max - min + out[0] = when { + delta == 0f -> 0f + max == r -> (60f * (((g - b) / delta) % 6f) + 360f) % 360f + max == g -> 60f * (((b - r) / delta) + 2f) + else -> 60f * (((r - g) / delta) + 4f) + } + out[1] = if (max == 0f) 0f else delta / max + out[2] = max + } } diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt new file mode 100644 index 00000000..869ae924 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt @@ -0,0 +1,130 @@ +package com.bitchat.android.ui + +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GeohashPresenceGroupingTest { + + private fun person(id: String, name: String = id, lastSeen: Long = 0) = GeoPerson( + id = id, + displayName = name, + lastSeen = Date(lastSeen) + ) + + @Test + fun `heartbeat anon is hidden while announced anon name remains`() { + val people = listOf( + person("local-heartbeat", "anon"), + person("named", "alice"), + person("teleported-named", "anon7674"), + person("teleported-heartbeat", "anon#04af") + ) + + val sections = sectionGeohashPeople( + people = people, + myId = null, + selfIsTeleported = false, + teleportedIds = setOf("teleported-named", "teleported-heartbeat") + ) + + assertEquals(listOf("alice"), sections.onLocation.map { it.displayName }) + assertEquals(listOf("anon7674"), sections.teleportedIn.map { it.displayName }) + assertEquals(2, sections.onLocation.size + sections.teleportedIn.size) + } + + @Test + fun `self is first in on-location section when not teleported`() { + val sections = sectionGeohashPeople( + people = listOf( + person("recent", lastSeen = 3_000), + person("me", name = "anon", lastSeen = 0), + person("older", lastSeen = 1_000) + ), + myId = "me", + selfIsTeleported = false, + teleportedIds = emptySet() + ) + + assertEquals(listOf("me", "recent", "older"), sections.onLocation.map { it.id }) + assertTrue(sections.teleportedIn.isEmpty()) + } + + @Test + fun `self is first in teleported section when teleported`() { + val sections = sectionGeohashPeople( + people = listOf( + person("remote-teleport", lastSeen = 3_000), + person("me", lastSeen = 0), + person("local", lastSeen = 2_000) + ), + myId = "ME", + selfIsTeleported = true, + teleportedIds = setOf("remote-teleport") + ) + + assertEquals(listOf("local"), sections.onLocation.map { it.id }) + assertEquals(listOf("me", "remote-teleport"), sections.teleportedIn.map { it.id }) + } + + @Test + fun `remote teleport matching is case insensitive`() { + val sections = sectionGeohashPeople( + people = listOf(person("ABCDEF")), + myId = null, + selfIsTeleported = false, + teleportedIds = setOf("abcdef") + ) + + assertTrue(sections.onLocation.isEmpty()) + assertEquals(listOf("ABCDEF"), sections.teleportedIn.map { it.id }) + } + + @Test + fun `duplicate nicknames are detected across case and sections`() { + val duplicates = duplicateGeohashBaseNames( + listOf( + person("first", "Alice"), + person("second", "alice"), + person("third", "bob") + ) + ) + + assertEquals(setOf("alice"), duplicates) + } + + @Test + fun `duplicate nickname gets the same last-four ID suffix as chat`() { + assertEquals( + "#cdef", + geohashIdentitySuffix(person("0123456789abcdef", "alice"), showHashSuffix = true) + ) + assertEquals( + "", + geohashIdentitySuffix(person("0123456789abcdef", "alice"), showHashSuffix = false) + ) + } + + @Test + fun `existing chat-style suffix is preserved`() { + assertEquals( + "#04af", + geohashIdentitySuffix(person("0123456789abcdef", "alice#04af"), showHashSuffix = true) + ) + } + + @Test + fun `disambiguated display name matches the mention token used by chat`() { + val alice = person("0123456789abcdef", "alice") + + assertEquals( + "alice#cdef", + disambiguatedGeohashDisplayName(alice, duplicateBaseNames = setOf("alice")) + ) + assertEquals( + "alice", + disambiguatedGeohashDisplayName(alice, duplicateBaseNames = emptySet()) + ) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt b/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt new file mode 100644 index 00000000..e53665ca --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt @@ -0,0 +1,49 @@ +package com.bitchat.android.ui + +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class LocationChannelsSheetTest { + + @Test + fun `teleported channel gets a standalone row`() { + val nearby = listOf(channel("u33dc")) + val teleported = channel("dr5ru") + + assertEquals( + teleported, + selectedLocationChannelOutsideNearby(ChannelID.Location(teleported), nearby) + ) + } + + @Test + fun `nearby selected channel is not duplicated`() { + val nearby = channel("u33dc") + + assertNull( + selectedLocationChannelOutsideNearby( + ChannelID.Location(channel("U33DC")), + listOf(nearby) + ) + ) + } + + @Test + fun `mesh selection has no standalone location row`() { + assertNull( + selectedLocationChannelOutsideNearby( + ChannelID.Mesh, + listOf(channel("u33dc")) + ) + ) + } + + private fun channel(geohash: String) = GeohashChannel( + level = GeohashChannelLevel.CITY, + geohash = geohash + ) +} diff --git a/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt new file mode 100644 index 00000000..607aaf1e --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt @@ -0,0 +1,54 @@ +package com.bitchat.android.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MentionSuggestionsTest { + + @Test + fun `only users without an announced nickname are excluded from mentions`() { + val suggestions = filterMentionCandidates( + candidates = listOf( + "anon", + "anon#04af", + "anon7674#df5b", + "alice#1234", + "anonymous", + "anonracer#04af" + ), + query = "" + ) + + assertEquals( + listOf("alice#1234", "anon7674#df5b", "anonracer#04af", "anonymous"), + suggestions + ) + assertTrue(suggestions.none(::isUnannouncedNickname)) + } + + @Test + fun `mention filtering is case insensitive and removes duplicates`() { + val suggestions = filterMentionCandidates( + candidates = listOf("Bob#1234", "bob#1234", "bobby#5678", "alice#9999"), + query = "BO" + ) + + assertEquals(listOf("Bob#1234", "bobby#5678"), suggestions) + } + + @Test + fun `announced names beginning with anon stay mentionable`() { + assertTrue(isUnannouncedNickname("anon")) + assertTrue(isUnannouncedNickname("anon#04af")) + assertFalse(isUnannouncedNickname("anon1234#04af")) + assertFalse(isUnannouncedNickname("anonymous#04af")) + assertFalse(isUnannouncedNickname("anonracer")) + } + + @Test + fun `mention popup viewport is capped at five rows`() { + assertEquals(5, MaxVisibleMentionSuggestions) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt new file mode 100644 index 00000000..fb534e69 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt @@ -0,0 +1,186 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The arrival tracker decides which messages animate in. + * + * Getting it wrong is not subtle: animate too eagerly and every old message replays its entrance + * whenever `LazyColumn` recycles it back into view, which looks broken during a scroll. + */ +class MessageArrivalTrackerTest { + + private var seq = 0 + + private fun msg(id: String = "m${seq++}") = BitchatMessage( + id = id, + sender = "alice", + content = "hello", + timestamp = Date(0), + ) + + @Test + fun `first load adopts everything silently`() { + val tracker = MessageArrivalTracker() + val initial = listOf(msg("a"), msg("b"), msg("c")) + + assertTrue( + "a whole screenful animating on open reads as a glitch", + tracker.arrivals(initial).isEmpty() + ) + } + + @Test + fun `a message appended after the first load animates`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a"), msg("b")) + tracker.arrivals(messages) + + messages += msg("c") + assertEquals(setOf("c"), tracker.arrivals(messages)) + } + + @Test + fun `an empty first load still lets the first real message animate`() { + // A brand new conversation seeds with nothing, so its opening message is a genuine arrival. + val tracker = MessageArrivalTracker() + tracker.arrivals(emptyList()) + + assertEquals(setOf("a"), tracker.arrivals(listOf(msg("a")))) + } + + @Test + fun `re-presenting the same list animates nothing`() { + val tracker = MessageArrivalTracker() + val messages = listOf(msg("a"), msg("b")) + tracker.arrivals(messages) + + assertTrue(tracker.arrivals(messages).isEmpty()) + assertTrue(tracker.arrivals(messages).isEmpty()) + } + + @Test + fun `an already-seen message never animates again`() { + // Stands in for LazyColumn recycling an old row back into view mid-scroll. + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a")) + tracker.arrivals(messages) + messages += msg("b") + tracker.arrivals(messages) + + assertTrue(tracker.arrivals(messages).isEmpty()) + } + + @Test + fun `only the new messages in a batch animate`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a")) + tracker.arrivals(messages) + + messages += listOf(msg("b"), msg("c")) + assertEquals(setOf("b", "c"), tracker.arrivals(messages)) + } + + @Test + fun `a burst larger than the cap is adopted silently`() { + // History sync: animating hundreds of rows would spend the whole frame budget on motion + // nobody asked to see. + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("seed")) + tracker.arrivals(messages) + + repeat(MaxAnimatedArrivals + 1) { messages += msg("burst$it") } + assertTrue(tracker.arrivals(messages).isEmpty()) + } + + @Test + fun `a burst exactly at the cap still animates`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("seed")) + tracker.arrivals(messages) + + repeat(MaxAnimatedArrivals) { messages += msg("b$it") } + assertEquals(MaxAnimatedArrivals, tracker.arrivals(messages).size) + } + + @Test + fun `clearing the conversation prunes the known set`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a"), msg("b"), msg("c")) + tracker.arrivals(messages) + + tracker.arrivals(emptyList()) + assertTrue("stale ids would leak and grow without bound", tracker.known.isEmpty()) + } + + @Test + fun `a message re-added after a clear animates again`() { + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"))) + tracker.arrivals(emptyList()) + + assertEquals(setOf("a"), tracker.arrivals(listOf(msg("a")))) + } + + @Test + fun `a wholesale replacement animates nothing`() { + // Switching channels, or /clear followed by fresh content: the incoming list shares no ids + // with the outgoing one, so it is a different conversation rather than a burst of arrivals. + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"), msg("b"))) + + val other = listOf(msg("x"), msg("y")) + assertTrue( + "a different conversation must not slide every message in", + tracker.arrivals(other).isEmpty() + ) + } + + @Test + fun `a small replacement below the burst cap still animates nothing`() { + // The burst cap alone would not catch this: two messages is well under it. + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"), msg("b"), msg("c"))) + + assertTrue(tracker.arrivals(listOf(msg("x"), msg("y"))).isEmpty()) + } + + @Test + fun `a replacement that overlaps is treated as normal arrivals`() { + // Still the same conversation if anything carries over, so genuine new messages animate. + val tracker = MessageArrivalTracker() + val kept = msg("a") + tracker.arrivals(listOf(kept, msg("b"))) + + assertEquals(setOf("c"), tracker.arrivals(listOf(kept, msg("c")))) + } + + @Test + fun `after a wholesale replacement, later arrivals animate normally`() { + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"))) + + val switched = mutableListOf(msg("x")) + tracker.arrivals(switched) // adopted silently + + switched += msg("y") + assertEquals(setOf("y"), tracker.arrivals(switched)) + } + + @Test + fun `the known set never outgrows the conversation`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a"), msg("b")) + tracker.arrivals(messages) + + // Simulate a channel switch: entirely different messages, same list size. + val switched = listOf(msg("x"), msg("y")) + tracker.arrivals(switched) + + assertEquals(setOf("x", "y"), tracker.known) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt new file mode 100644 index 00000000..eea760f7 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt @@ -0,0 +1,154 @@ +package com.bitchat.android.ui + +import androidx.compose.ui.unit.dp +import com.bitchat.android.model.BitchatMessage +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Grouping decides whether a message repeats its author's name. Getting it wrong either spams + * the transcript with redundant labels or, worse, silently attributes one person's message to + * another — hence the emphasis on the negative cases here. + */ +class MessageGroupingTest { + + private val base = 1_700_000_000_000L + + private fun message( + sender: String = "alice", + senderPeerId: String? = "peer-alice", + offsetMs: Long = 0L, + isPrivate: Boolean = false, + channel: String? = null, + content: String = "hello", + ) = BitchatMessage( + sender = sender, + content = content, + timestamp = Date(base + offsetMs), + isPrivate = isPrivate, + senderPeerID = senderPeerId, + channel = channel, + ) + + @Test + fun `first message in a list never groups`() { + assertFalse(MessageGrouping.shouldGroup(previous = null, current = message())) + } + + @Test + fun `consecutive messages from the same peer group`() { + val first = message(offsetMs = 0) + val second = message(offsetMs = 30_000) + + assertTrue(MessageGrouping.shouldGroup(first, second)) + } + + @Test + fun `messages from different peers do not group`() { + val first = message(sender = "alice", senderPeerId = "peer-alice") + val second = message(sender = "bob", senderPeerId = "peer-bob", offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(first, second)) + } + + @Test + fun `same nickname but different peer id does not group`() { + // Two people can pick the same nickname; peer ID is the authority. + val first = message(sender = "alice", senderPeerId = "peer-one") + val second = message(sender = "alice", senderPeerId = "peer-two", offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(first, second)) + } + + @Test + fun `falls back to display name when peer ids are unavailable`() { + val first = message(sender = "alice#04af", senderPeerId = null) + val second = message(sender = "alice#04af", senderPeerId = null, offsetMs = 1_000) + val other = message(sender = "bob#1122", senderPeerId = null, offsetMs = 2_000) + + assertTrue(MessageGrouping.shouldGroup(first, second)) + assertFalse(MessageGrouping.shouldGroup(second, other)) + } + + @Test + fun `messages outside the grouping window start a new group`() { + val first = message(offsetMs = 0) + val justInside = message(offsetMs = MessageGrouping.GROUPING_WINDOW_MS) + val justOutside = message(offsetMs = MessageGrouping.GROUPING_WINDOW_MS + 1) + + assertTrue(MessageGrouping.shouldGroup(first, justInside)) + assertFalse(MessageGrouping.shouldGroup(first, justOutside)) + } + + @Test + fun `out of order timestamps do not group`() { + // Mesh delivery can surface messages out of order; a negative delta means we cannot + // reason about adjacency, so fall back to showing the sender. + val later = message(offsetMs = 60_000) + val earlier = message(offsetMs = 0) + + assertFalse(MessageGrouping.shouldGroup(later, earlier)) + } + + @Test + fun `system messages never group in either direction`() { + val system = message(sender = "system", senderPeerId = null, content = "tor started") + val user = message(offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(system, user)) + assertFalse(MessageGrouping.shouldGroup(user, system.copyWithOffset(2_000))) + assertFalse(MessageGrouping.shouldGroup(system, system.copyWithOffset(1_000))) + } + + @Test + fun `grouping does not cross the public private boundary`() { + val public = message(isPrivate = false) + val private = message(isPrivate = true, offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(public, private)) + } + + @Test + fun `grouping does not cross channels`() { + val inGeneral = message(channel = "#general") + val inRandom = message(channel = "#random", offsetMs = 1_000) + val inNoChannel = message(channel = null, offsetMs = 2_000) + + assertFalse(MessageGrouping.shouldGroup(inGeneral, inRandom)) + assertFalse(MessageGrouping.shouldGroup(inGeneral, inNoChannel)) + } + + @Test + fun `peer id comparison ignores case`() { + val first = message(senderPeerId = "PEER-Alice") + val second = message(senderPeerId = "peer-alice", offsetMs = 1_000) + + assertTrue(MessageGrouping.shouldGroup(first, second)) + } + + // MARK: - Spacing + + @Test + fun `spacing is zero for the very first row`() { + assertEquals(0.dp, MessageGrouping.topSpacingFor(isGrouped = false, isFirstInList = true)) + assertEquals(0.dp, MessageGrouping.topSpacingFor(isGrouped = true, isFirstInList = true)) + } + + @Test + fun `all transcript rows use the exported eight dp rhythm`() { + val grouped = MessageGrouping.topSpacingFor(isGrouped = true, isFirstInList = false) + val newGroup = MessageGrouping.topSpacingFor(isGrouped = false, isFirstInList = false) + + assertEquals(8.dp, grouped) + assertEquals(8.dp, newGroup) + assertEquals(grouped, newGroup) + assertEquals(8.dp, MessageGrouping.SENDER_TOP_PADDING) + assertEquals(4.dp, MessageGrouping.SENDER_TO_BODY_SPACING) + } + + private fun BitchatMessage.copyWithOffset(offsetMs: Long) = + copy(timestamp = Date(base + offsetMs)) +} From 85cbe20b3902e249fc3e1d3c78e5c7f1cffba0b3 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:56:48 +0200 Subject: [PATCH 37/70] fix: use authenticated timestamps for Nostr DMs --- .../nostr/NostrDirectMessageHandler.kt | 9 +- .../nostr/NostrDirectMessageHandlerTest.kt | 179 ++++++++++++++++++ 2 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 7a49057d..469972c8 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -31,11 +31,14 @@ class NostrDirectMessageHandler( private val meshDelegateHandler: MeshDelegateHandler, private val scope: CoroutineScope, private val repo: GeohashRepository, - private val dataManager: com.bitchat.android.ui.DataManager + private val dataManager: com.bitchat.android.ui.DataManager, + private val seenStoreProvider: () -> SeenMessageStore = { + SeenMessageStore.getInstance(application) + } ) { companion object { private const val TAG = "NostrDirectMessageHandler" } - private val seenStore by lazy { SeenMessageStore.getInstance(application) } + private val seenStore by lazy(seenStoreProvider) // Simple event deduplication private val processedIds = ArrayDeque() @@ -82,7 +85,7 @@ class NostrDirectMessageHandler( if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch val noisePayload = NoisePayload.decode(packet.payload) ?: return@launch - val messageTimestamp = Date(giftWrap.createdAt * 1000L) + val messageTimestamp = Date(rumorTimestamp * 1000L) val convKey = "nostr_${senderPubkey.take(16)}" repo.putNostrKeyMapping(convKey, senderPubkey) com.bitchat.android.nostr.GeohashAliasRegistry.put(convKey, senderPubkey) diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt new file mode 100644 index 00000000..738b3231 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt @@ -0,0 +1,179 @@ +package com.bitchat.android.nostr + +import android.os.Build +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.SeenMessageStore +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.MeshDelegateHandler +import com.bitchat.android.ui.MessageManager +import com.bitchat.android.ui.NoiseSessionDelegate +import com.bitchat.android.ui.PrivateChatManager +import com.google.gson.Gson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +@OptIn(ExperimentalCoroutinesApi::class) +class NostrDirectMessageHandlerTest { + private val gson = Gson() + private lateinit var scope: CoroutineScope + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + AppStateStore.clear() + } + + @After + fun tearDown() { + AppStateStore.clear() + scope.cancel() + Dispatchers.resetMain() + } + + @Test + fun `private messages use authenticated rumor time instead of randomized gift wrap time`() { + val application = RuntimeEnvironment.getApplication() + val state = ChatState(scope).apply { setNickname("recipient") } + val dataManager = DataManager(application) + val messageManager = MessageManager(state) + val privateChatManager = PrivateChatManager( + state = state, + messageManager = messageManager, + dataManager = dataManager, + noiseSessionDelegate = mock() + ) + val seenStore = mock() + whenever(seenStore.hasDelivered(any())).thenReturn(true) + whenever(seenStore.hasRead(any())).thenReturn(false) + val handler = NostrDirectMessageHandler( + application = application, + state = state, + privateChatManager = privateChatManager, + meshDelegateHandler = mock(), + scope = scope, + repo = GeohashRepository(application, state, dataManager), + dataManager = dataManager, + seenStoreProvider = { seenStore } + ) + val sender = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + val now = (System.currentTimeMillis() / 1000).toInt() + val firstRumorTime = now - 120 + val secondRumorTime = now - 60 + val firstId = "first-real-time" + val secondId = "second-real-time" + + val first = privateMessageGiftWrap( + content = requireNotNull( + NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content = "first", + messageID = firstId, + senderPeerID = "0011223344556677" + ) + ), + sender = sender, + recipient = recipient, + rumorCreatedAt = firstRumorTime, + giftWrapCreatedAt = now - 5 + ) + val second = privateMessageGiftWrap( + content = requireNotNull( + NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content = "second", + messageID = secondId, + senderPeerID = "0011223344556677" + ) + ), + sender = sender, + recipient = recipient, + rumorCreatedAt = secondRumorTime, + giftWrapCreatedAt = now - 86_400 + ) + + handler.onGiftWrap(first, "", recipient) + waitForMessage(state, firstId) + handler.onGiftWrap(second, "", recipient) + waitForMessage(state, secondId) + + val messages = state.getPrivateChatsValue().values.single() + assertEquals(listOf(firstId, secondId), messages.map { it.id }) + assertEquals(firstRumorTime * 1000L, messages[0].timestamp.time) + assertEquals(secondRumorTime * 1000L, messages[1].timestamp.time) + } + + private fun waitForMessage(state: ChatState, messageId: String) { + kotlinx.coroutines.runBlocking { + withTimeout(5_000) { + while (state.getPrivateChatsValue().values.flatten().none { it.id == messageId }) { + delay(10) + } + } + } + } + + private fun privateMessageGiftWrap( + content: String, + sender: NostrIdentity, + recipient: NostrIdentity, + rumorCreatedAt: Int, + giftWrapCreatedAt: Int + ): NostrEvent { + val rumorBase = NostrEvent( + pubkey = sender.publicKeyHex, + createdAt = rumorCreatedAt, + kind = NostrKind.DIRECT_MESSAGE, + tags = listOf(listOf("p", recipient.publicKeyHex)), + content = content + ) + val rumor = rumorBase.copy(id = rumorBase.computeEventIdHex()) + val sealContent = NostrCrypto.encryptNIP44( + plaintext = gson.toJson(rumor), + recipientPublicKeyHex = recipient.publicKeyHex, + senderPrivateKeyHex = sender.privateKeyHex + ) + val seal = NostrEvent( + pubkey = sender.publicKeyHex, + createdAt = giftWrapCreatedAt, + kind = NostrKind.SEAL, + tags = emptyList(), + content = sealContent + ).sign(sender.privateKeyHex) + + val (wrapPrivateKey, wrapPublicKey) = NostrCrypto.generateKeyPair() + val giftWrapContent = NostrCrypto.encryptNIP44( + plaintext = gson.toJson(seal), + recipientPublicKeyHex = recipient.publicKeyHex, + senderPrivateKeyHex = wrapPrivateKey + ) + return NostrEvent( + pubkey = wrapPublicKey, + createdAt = giftWrapCreatedAt, + kind = NostrKind.GIFT_WRAP, + tags = listOf(listOf("p", recipient.publicKeyHex)), + content = giftWrapContent + ).sign(wrapPrivateKey) + } +} From 81a35d9dba0641ae50a5526f538a6a19c5d51f49 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:58:36 +0200 Subject: [PATCH 38/70] ui: use shared green CloseButton in private and channel headers Align conversation exit controls with bottom-sheet close chrome so the X reads as primary green rather than muted grey. Co-authored-by: Cursor --- .../main/java/com/bitchat/android/ui/ChatHeader.kt | 13 ++----------- .../com/bitchat/android/ui/MeshPeerListSheet.kt | 12 +----------- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index af38ea1d..fe3ad343 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -47,6 +47,7 @@ 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.core.ui.component.button.CloseButton import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.TorMode import com.bitchat.android.ui.theme.BitchatMotion @@ -620,17 +621,7 @@ private fun ChannelHeader( title = "#$channel", onTitleClick = onSidebarClick ) { - ConversationHeaderAction( - onClick = onBackClick, - contentDescription = stringResource(R.string.close_plain) - ) { - Icon( - painter = painterResource(R.drawable.ic_spec_close), - contentDescription = stringResource(R.string.close_plain), - modifier = Modifier.size(HeaderIconSize), - tint = colorScheme.onSurfaceVariant - ) - } + CloseButton(onClick = onBackClick) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index d9a8d035..c496938e 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -1024,17 +1024,7 @@ fun PrivateChatSheet( } 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 - ) - } + CloseButton(onClick = { dismiss?.invoke() ?: onDismiss() }) } } } From b22184940b7811a31a7ef7b19cf32eb15d0154f7 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:00:10 +0200 Subject: [PATCH 39/70] Harden background relay lifecycle --- .../android/mesh/UnifiedMeshService.kt | 27 +-- .../android/nostr/NostrBackgroundRuntime.kt | 56 +++--- .../android/nostr/NostrPendingEventQueue.kt | 90 +++++++++ .../android/nostr/NostrRelayManager.kt | 180 +++++++++--------- .../android/nostr/NostrSubscriptionManager.kt | 110 +++++------ .../android/service/MeshForegroundService.kt | 3 +- .../bitchat/android/ui/GeohashViewModel.kt | 8 +- .../nostr/NostrPendingEventQueueTest.kt | 81 ++++++++ .../NostrRelayManagerLifecycleSmokeTest.kt | 28 +++ 9 files changed, 401 insertions(+), 182 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index b9f84547..5a0cc697 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -6,7 +6,6 @@ import com.bitchat.android.favorites.FavoriteControlMessage import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.BitchatMessage import com.bitchat.android.noise.NoiseSession -import com.bitchat.android.services.AppStateStore import com.bitchat.android.wifiaware.WifiAwareController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -14,7 +13,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -77,18 +77,21 @@ class UnifiedMeshService( private fun startAnnouncementScheduler() { if (announcementJob?.isActive == true) return announcementJob = serviceScope.launch { - combine(powerManager.profile, AppStateStore.directPeers) { profile, peers -> - profile.meshAnnouncementIntervalMs to peers.isNotEmpty() - }.collectLatest { (intervalMs, hasRecipients) -> - if (!hasRecipients) return@collectLatest - // Connection-specific paths already send an immediate announce. Begin the - // periodic cadence after the configured interval to avoid a transition burst. - while (isActive) { - delay(intervalMs) - if (AppStateStore.directPeers.value.isNotEmpty()) sendBroadcastAnnounce() + powerManager.profile + .map { profile -> + profile.meshAnnouncementIntervalMs to profile.hasDirectPeers + } + .distinctUntilChanged() + .collectLatest { (intervalMs, hasRecipients) -> + if (!hasRecipients) return@collectLatest + // Connection-specific paths already send an immediate announce. Begin the + // periodic cadence after the configured interval to avoid a transition burst. + while (isActive) { + delay(intervalMs) + if (powerManager.profile.value.hasDirectPeers) sendBroadcastAnnounce() + } } } - } } override fun sendMessage(content: String, mentions: List, channel: String?) { diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt index 9e3bf785..3931ea72 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt @@ -61,7 +61,6 @@ object NostrBackgroundRuntime { locationChannels = LocationChannelManager.getInstance(app) subscriptions = NostrSubscriptionManager( app, - scope, owner = NostrRelayManager.OWNER_BACKGROUND ) } @@ -94,7 +93,13 @@ object NostrBackgroundRuntime { } fun ensureConversationDm(geohash: String) { - if (!initialized || geohash == activeGeohash || geohash == conversationGeohash) return + if (!initialized || geohash == conversationGeohash) return + val selectedLiveToken = activeGeohashLiveToken + val selectedChannelSubscriptionIsUsable = + geohash == activeGeohash && + (selectedLiveToken == null || + LiveLocationPrivacyGate.accepts(selectedLiveToken)) + if (selectedChannelSubscriptionIsUsable) return conversationGeohash?.let { subscriptions.unsubscribe("geo-dm-conversation-$it") } conversationGeohash = geohash subscribeGeohashDm( @@ -119,8 +124,9 @@ object NostrBackgroundRuntime { private fun observeSelectedChannel() { scope.launch { locationChannels.selectedChannel.collectLatest { channel -> - val next = (channel as? ChannelID.Location)?.channel?.geohash - val nextToken = (channel as? ChannelID.Location)?.let { + val locationChannel = channel as? ChannelID.Location + val next = locationChannel?.channel?.geohash + val nextToken = locationChannel?.let { locationChannels.liveLocationTokenForSelectedChannel(it.channel) } val previous = activeGeohash @@ -139,9 +145,8 @@ object NostrBackgroundRuntime { conversationGeohash = null } next?.let { geohash -> - val isLiveDerived = (channel as? ChannelID.Location)?.let { - locationChannels.isSelectedChannelLiveDerived(it.channel) - } == true + val isLiveDerived = + locationChannels.isSelectedChannelLiveDerived(locationChannel.channel) if (!isLiveDerived || nextToken != null) { subscribeSelectedGeohash(geohash, nextToken) } @@ -171,21 +176,28 @@ object NostrBackgroundRuntime { liveLocationToken: Long? ) { scope.launch { - if (liveLocationToken != null && - !LiveLocationPrivacyGate.accepts(liveLocationToken) - ) return@launch - val identity = NostrIdentityBridge.deriveIdentity(geohash, application) - subscriptions.subscribeGiftWraps( - pubkey = identity.publicKeyHex, - sinceMs = System.currentTimeMillis() - 172_800_000L, - id = subscriptionId, - handler = { event -> dispatch { it.geohashDm(event, geohash, identity) } }, - liveLocationToken = liveLocationToken - ) - GeohashAliasRegistry.put( - "nostr_${identity.publicKeyHex.take(16)}", - identity.publicKeyHex - ) + val subscribe = { + val identity = NostrIdentityBridge.deriveIdentity(geohash, application) + subscriptions.subscribeGiftWraps( + pubkey = identity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172_800_000L, + id = subscriptionId, + handler = { event -> + dispatch { it.geohashDm(event, geohash, identity) } + }, + liveLocationToken = liveLocationToken + ) + GeohashAliasRegistry.put( + "nostr_${identity.publicKeyHex.take(16)}", + identity.publicKeyHex + ) + } + + if (liveLocationToken == null) { + subscribe() + } else { + LiveLocationPrivacyGate.runIfAllowed(liveLocationToken, subscribe) + } } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt new file mode 100644 index 00000000..dd9e16d1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt @@ -0,0 +1,90 @@ +package com.bitchat.android.nostr + +/** + * Thread-safe bounded queue of relay deliveries awaiting a usable WebSocket. + * + * Queue entries have a local ID rather than using the Nostr event ID: the same signed event may be + * intentionally published more than once with different relay sets or privacy provenance. + */ +internal class NostrPendingEventQueue( + private val capacity: Int +) { + init { + require(capacity > 0) + } + + data class Delivery( + val queueId: Long, + val event: NostrEvent, + val liveLocationToken: Long? + ) + + private data class Entry( + val queueId: Long, + val event: NostrEvent, + val pendingRelayUrls: MutableSet, + val liveLocationToken: Long? + ) + + private val lock = Any() + private val entries = ArrayDeque() + private var nextQueueId = 1L + + fun enqueue( + event: NostrEvent, + relayUrls: Collection, + liveLocationToken: Long? + ): Long? { + val pendingRelays = relayUrls.filterTo(linkedSetOf()) { it.isNotBlank() } + if (pendingRelays.isEmpty()) return null + + return synchronized(lock) { + if (entries.size >= capacity) entries.removeFirst() + val queueId = nextQueueId++ + entries.addLast( + Entry( + queueId = queueId, + event = event, + pendingRelayUrls = pendingRelays, + liveLocationToken = liveLocationToken + ) + ) + queueId + } + } + + fun pendingForRelay(relayUrl: String): List = synchronized(lock) { + entries + .asSequence() + .filter { relayUrl in it.pendingRelayUrls } + .map { Delivery(it.queueId, it.event, it.liveLocationToken) } + .toList() + } + + fun markDelivered(queueId: Long, relayUrl: String) { + synchronized(lock) { + val iterator = entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (entry.queueId != queueId) continue + entry.pendingRelayUrls.remove(relayUrl) + if (entry.pendingRelayUrls.isEmpty()) iterator.remove() + return + } + } + } + + fun removeLiveLocationEvents() { + synchronized(lock) { + entries.removeAll { it.liveLocationToken != null } + } + } + + fun clear() { + synchronized(lock) { + entries.clear() + } + } + + internal fun size(): Int = synchronized(lock) { entries.size } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index d361ec49..3386a014 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -2,17 +2,17 @@ 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 import kotlinx.coroutines.flow.asStateFlow -import com.google.gson.JsonArray +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map 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 java.util.concurrent.atomic.AtomicBoolean import kotlin.math.min import kotlin.math.pow @@ -114,15 +114,8 @@ class NostrRelayManager private constructor() { // Event deduplication system private val eventDeduplicator = NostrEventDeduplicator.getInstance() - // Message queue for reliability - private data class QueuedEvent( - val event: NostrEvent, - val pendingRelayUrls: MutableSet, - val liveLocationToken: Long? = null - ) - - private val messageQueue = mutableListOf() - private val messageQueueLock = Any() + // Bounded per-relay delivery queue for reconnect reliability. + private val messageQueue = NostrPendingEventQueue(MAX_QUEUED_EVENTS) // Coroutine scope for background operations private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -287,14 +280,24 @@ class NostrRelayManager private constructor() { ) closeTargets.forEach { (relayUrl, relaySubscriptionIds) -> val webSocket = connections[relayUrl] ?: return@forEach - relaySubscriptionIds.forEach { subscriptionId -> + for (subscriptionId in relaySubscriptionIds) { 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) + subscriptions.remove(relayUrl) webSocket.cancel() + updateRelayStatus( + relayUrl, + isConnected = false, + error = IllegalStateException("Failed to close revoked subscription") + ) + if (desiredConnected.get() && relayUrl in nonLiveRelayUrls) { + scope.launch { connectToRelay(relayUrl, liveLocationToken = null) } + } + break } } } @@ -314,9 +317,7 @@ class NostrRelayManager private constructor() { } subscriptions.replaceAll { _, ids -> ids - liveSubscriptionIds } - synchronized(messageQueueLock) { - messageQueue.removeAll { it.liveLocationToken != null } - } + messageQueue.removeLiveLocationEvents() liveGeohashTokens.keys.forEach(geohashToRelays::remove) liveGeohashTokens.clear() @@ -325,6 +326,8 @@ class NostrRelayManager private constructor() { .filterNotTo(mutableSetOf()) { it in nonLiveRelayUrls } liveOnlyRelayUrls.forEach { relayUrl -> connections.remove(relayUrl)?.cancel() + subscriptions.remove(relayUrl) + reconnectJobs.remove(relayUrl)?.cancel() } synchronized(relaysList) { relaysList.removeAll { it.url in liveOnlyRelayUrls } @@ -451,28 +454,24 @@ class NostrRelayManager private constructor() { relayUrls: List? = null, liveLocationToken: Long? = null ) { - val targetRelays = relayUrls ?: relaysList.map { it.url } + val targetRelays = (relayUrls ?: relaysList.map { it.url }) + .filter { it.isNotBlank() } + .distinct() + if (targetRelays.isEmpty()) return val queued = runNetworkAction(liveLocationToken) { - synchronized(messageQueueLock) { - if (messageQueue.size >= MAX_QUEUED_EVENTS) { - messageQueue.removeAt(0) - } - messageQueue.add( - QueuedEvent( - event = event, - pendingRelayUrls = targetRelays.toMutableSet(), - liveLocationToken = liveLocationToken - ) - ) - } + val queueId = messageQueue.enqueue( + event = event, + relayUrls = targetRelays, + liveLocationToken = liveLocationToken + ) ?: return@runNetworkAction scope.launch { if (!isNetworkActionAllowed(liveLocationToken)) return@launch targetRelays.forEach { relayUrl -> val webSocket = connections[relayUrl] if (webSocket != null) { if (sendToRelay(event, webSocket, relayUrl, liveLocationToken)) { - markQueuedRelayDelivered(event.id, relayUrl) + messageQueue.markDelivered(queueId, relayUrl) } } } @@ -673,9 +672,7 @@ class NostrRelayManager private constructor() { geohashToRelays.clear() // Clear any queued messages waiting to be sent - synchronized(messageQueueLock) { - messageQueue.clear() - } + messageQueue.clear() Log.i(TAG, "Cleared all Nostr subscriptions and routing caches") } catch (e: Exception) { @@ -767,39 +764,52 @@ class NostrRelayManager private constructor() { stopSubscriptionValidation() // Stop any existing validation subscriptionValidationJob = scope.launch { - while (isActive && desiredConnected.get()) { - val validationInterval = powerManager?.profile?.value - ?.nostr?.subscriptionValidationMs - ?: com.bitchat.android.util.AppConstants.Nostr.SUBSCRIPTION_VALIDATION_INTERVAL_MS - delay(validationInterval) - if (!desiredConnected.get()) break - - try { - val report = validateSubscriptionConsistency() - if (!report.isConsistent && report.connectedRelayCount > 0) { - Log.w(TAG, "Nostr subscription inconsistencies detected") - - // Auto-repair: re-establish subscriptions for relays with missing ones - connections.forEach { (relayUrl, webSocket) -> - val currentSubs = subscriptions[relayUrl] ?: emptySet() - val expectedSubs = activeSubscriptions.keys.filter { subId -> - val subInfo = activeSubscriptions[subId] - subInfo?.targetRelayUrls == null || subInfo.targetRelayUrls.contains(relayUrl) - }.toSet() - - val missingSubs = expectedSubs - currentSubs - if (missingSubs.isNotEmpty()) { - Log.i(TAG, "Auto-repairing ${missingSubs.size} missing subscriptions") - restoreSubscriptionsForRelay(relayUrl, webSocket) - } - } - } - } catch (e: Exception) { - Log.e(TAG, "Error during subscription validation: ${e.message}") + val manager = powerManager + if (manager == null) { + runSubscriptionValidationLoop( + com.bitchat.android.util.AppConstants.Nostr + .SUBSCRIPTION_VALIDATION_INTERVAL_MS + ) + return@launch + } + + manager.profile + .map { it.nostr.subscriptionValidationMs } + .distinctUntilChanged() + .collectLatest(::runSubscriptionValidationLoop) + } + } + + private suspend fun runSubscriptionValidationLoop(intervalMs: Long) { + while (currentCoroutineContext().isActive && desiredConnected.get()) { + delay(intervalMs) + if (!desiredConnected.get()) break + validateAndRepairSubscriptions() + } + } + + private fun validateAndRepairSubscriptions() { + try { + val report = validateSubscriptionConsistency() + if (report.isConsistent || report.connectedRelayCount == 0) return + + Log.w(TAG, "Nostr subscription inconsistencies detected") + connections.forEach { (relayUrl, webSocket) -> + val currentSubs = subscriptions[relayUrl] ?: emptySet() + val expectedSubs = activeSubscriptions.keys.filter { subId -> + val subInfo = activeSubscriptions[subId] + subInfo?.targetRelayUrls == null || + subInfo.targetRelayUrls.contains(relayUrl) + }.toSet() + + if ((expectedSubs - currentSubs).isNotEmpty()) { + Log.i(TAG, "Auto-repairing missing subscriptions") + restoreSubscriptionsForRelay(relayUrl, webSocket) } } + } catch (e: Exception) { + Log.e(TAG, "Error during subscription validation: ${e.message}") } - } /** @@ -869,8 +879,9 @@ class NostrRelayManager private constructor() { } if (success) { // Update relay stats - val relay = relaysList.find { it.url == relayUrl } - relay?.messagesSent = (relay?.messagesSent ?: 0) + 1 + relaysList.find { it.url == relayUrl }?.let { relay -> + relay.messagesSent += 1 + } updateRelaysList() true } else { @@ -883,18 +894,6 @@ class NostrRelayManager private constructor() { } } - private fun markQueuedRelayDelivered(eventId: String, relayUrl: String) { - synchronized(messageQueueLock) { - val iterator = messageQueue.iterator() - while (iterator.hasNext()) { - val queued = iterator.next() - if (queued.event.id != eventId) continue - queued.pendingRelayUrls.remove(relayUrl) - if (queued.pendingRelayUrls.isEmpty()) iterator.remove() - } - } - } - private fun handleMessage(message: String, relayUrl: String) { try { val jsonElement = JsonParser.parseString(message) @@ -908,8 +907,9 @@ class NostrRelayManager private constructor() { when (response) { is NostrResponse.Event -> { // Update relay stats - val relay = relaysList.find { it.url == relayUrl } - relay?.messagesReceived = (relay?.messagesReceived ?: 0) + 1 + relaysList.find { it.url == relayUrl }?.let { relay -> + relay.messagesReceived += 1 + } updateRelaysList() // CLIENT-SIDE FILTER ENFORCEMENT: Ensure this event matches the subscription's filter @@ -1142,17 +1142,17 @@ class NostrRelayManager private constructor() { restoreSubscriptionsForRelay(relayUrl, webSocket) // Process only events still pending for this relay, outside the queue lock. - val queuedForRelay = synchronized(messageQueueLock) { - messageQueue - .filter { - relayUrl in it.pendingRelayUrls && - isNetworkActionAllowed(it.liveLocationToken) - } - .map { it.event to it.liveLocationToken } - } - queuedForRelay.forEach { (event, token) -> - if (sendToRelay(event, webSocket, relayUrl, token)) { - markQueuedRelayDelivered(event.id, relayUrl) + val queuedForRelay = messageQueue.pendingForRelay(relayUrl) + .filter { isNetworkActionAllowed(it.liveLocationToken) } + queuedForRelay.forEach { delivery -> + if (sendToRelay( + delivery.event, + webSocket, + relayUrl, + delivery.liveLocationToken + ) + ) { + messageQueue.markDelivered(delivery.queueId, relayUrl) } } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt index e378e31e..19ebeb9c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt @@ -3,24 +3,32 @@ 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 /** * NostrSubscriptionManager - * - Encapsulates subscription lifecycle with NostrRelayManager + * - Encapsulates ordered subscription lifecycle with NostrRelayManager. + * + * Relay-manager operations are already non-blocking and schedule network I/O on their own scope. + * Keeping this facade synchronous prevents lifecycle cancellation from dropping unsubscribe work + * and guarantees that a channel switch closes the old subscription before opening the new one. */ class NostrSubscriptionManager( private val application: Application, - private val scope: CoroutineScope, private val owner: String = NostrRelayManager.OWNER_LEGACY ) { companion object { private const val TAG = "NostrSubscriptionManager" } private val relayManager get() = NostrRelayManager.getInstance(application) - fun connect() = scope.launch { runCatching { relayManager.connect() }.onFailure { Log.e(TAG, "connect failed: ${it.message}") } } - fun disconnect() = scope.launch { runCatching { relayManager.disconnect() }.onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } } + fun connect() { + runCatching { relayManager.connect() } + .onFailure { Log.e(TAG, "connect failed: ${it.message}") } + } + + fun disconnect() { + runCatching { relayManager.disconnect() } + .onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } + } fun subscribeGiftWraps( pubkey: String, @@ -29,19 +37,15 @@ class NostrSubscriptionManager( handler: (NostrEvent) -> Unit, liveLocationToken: Long? = null ) { - scope.launch { - if (liveLocationToken != null && - !LiveLocationPrivacyGate.accepts(liveLocationToken) - ) return@launch - val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs) - relayManager.subscribe( - filter = filter, - id = id, - handler = handler, - owner = owner, - liveLocationToken = liveLocationToken - ) - } + if (!isAllowed(liveLocationToken)) return + val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs) + relayManager.subscribe( + filter = filter, + id = id, + handler = handler, + owner = owner, + liveLocationToken = liveLocationToken + ) } /** Subscribe to geohash chat messages only (kind 20000) — low-volume, kept alive in background. */ @@ -53,22 +57,18 @@ class NostrSubscriptionManager( 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, - owner = owner, - liveLocationToken = liveLocationToken - ) - } + if (!isAllowed(liveLocationToken)) return + val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit) + relayManager.subscribeForGeohash( + geohash, + filter, + id, + handler, + includeDefaults = false, + nRelays = 5, + owner = owner, + liveLocationToken = liveLocationToken + ) } /** Subscribe to geohash presence heartbeats only (kind 20001) — high-volume, paused in background. */ @@ -80,26 +80,28 @@ class NostrSubscriptionManager( 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, - owner = owner, - liveLocationToken = liveLocationToken - ) - } + if (!isAllowed(liveLocationToken)) return + val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit) + relayManager.subscribeForGeohash( + geohash, + filter, + id, + handler, + includeDefaults = false, + nRelays = 5, + owner = owner, + liveLocationToken = liveLocationToken + ) } - fun unsubscribe(id: String) { scope.launch { runCatching { relayManager.unsubscribe(id) } } } - fun unsubscribeAllOwned() { - scope.launch { runCatching { relayManager.unsubscribeOwner(owner) } } + fun unsubscribe(id: String) { + runCatching { relayManager.unsubscribe(id) } } + + fun unsubscribeAllOwned() { + runCatching { relayManager.unsubscribeOwner(owner) } + } + + private fun isAllowed(liveLocationToken: Long?): Boolean = + liveLocationToken == null || LiveLocationPrivacyGate.accepts(liveLocationToken) } diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index 1286988f..5443808a 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -112,7 +112,8 @@ class MeshForegroundService : Service() { private val unifiedMeshService: com.bitchat.android.mesh.MeshService? get() = MeshServiceHolder.unifiedMeshService private val serviceJob = Job() - private val scope = CoroutineScope(Dispatchers.Default + serviceJob) + // Service lifecycle callbacks and notification state are main-thread confined. + private val scope = CoroutineScope(Dispatchers.Main.immediate + serviceJob) private var isInForeground: Boolean = false private var isShuttingDown: Boolean = false private var lastNotifiedPeerCount: Int? = null diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index dbc312b5..46504649 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -42,10 +42,9 @@ class GeohashViewModel( companion object { private const val TAG = "GeohashViewModel" } private val repo = GeohashRepository(application, state, dataManager) - private val uiSubscriptionOwner = "geohash-ui-${System.identityHashCode(this)}" + private val uiSubscriptionOwner = "geohash-ui-${UUID.randomUUID()}" private val subscriptionManager = NostrSubscriptionManager( application, - viewModelScope, owner = uiSubscriptionOwner ) private val geohashMessageHandler = GeohashMessageHandler( @@ -75,6 +74,7 @@ class GeohashViewModel( private val liveSamplingSubscriptionGeohashes = mutableSetOf() private var requestedLiveSamplingGeohashes: Set = emptySet() private var requestedUserSamplingGeohashes: Set = emptySet() + private var uiSubscriptionsShutdown = false private val liveLocationRevocationListener: () -> Unit = { val revokedLiveGeohashes = liveSamplingSubscriptionGeohashes.toSet() revokedLiveGeohashes.forEach { geohash -> @@ -424,11 +424,13 @@ class GeohashViewModel( } override fun onCleared() { - super.onCleared() shutdownUiSubscriptions() + super.onCleared() } fun shutdownUiSubscriptions() { + if (uiSubscriptionsShutdown) return + uiSubscriptionsShutdown = true subscriptionManager.unsubscribeAllOwned() geoTimer?.cancel() geoTimer = null diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt new file mode 100644 index 00000000..8954cbe3 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt @@ -0,0 +1,81 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NostrPendingEventQueueTest { + @Test + fun `empty relay set is not queued`() { + val queue = NostrPendingEventQueue(capacity = 2) + + assertNull(queue.enqueue(event("empty"), emptyList(), liveLocationToken = null)) + assertEquals(0, queue.size()) + } + + @Test + fun `capacity evicts the oldest publish`() { + val queue = NostrPendingEventQueue(capacity = 2) + queue.enqueue(event("one"), listOf("relay"), liveLocationToken = null) + queue.enqueue(event("two"), listOf("relay"), liveLocationToken = null) + queue.enqueue(event("three"), listOf("relay"), liveLocationToken = null) + + assertEquals( + listOf("two", "three"), + queue.pendingForRelay("relay").map { it.event.content } + ) + } + + @Test + fun `duplicate event publishes retain independent delivery state`() { + val queue = NostrPendingEventQueue(capacity = 4) + val signedEvent = event("same") + val firstId = requireNotNull( + queue.enqueue(signedEvent, listOf("relay-a", "relay-b"), liveLocationToken = null) + ) + val secondId = requireNotNull( + queue.enqueue(signedEvent, listOf("relay-a"), liveLocationToken = null) + ) + assertNotEquals(firstId, secondId) + + queue.markDelivered(firstId, "relay-a") + + assertEquals( + listOf(secondId), + queue.pendingForRelay("relay-a").map { it.queueId } + ) + assertEquals( + listOf(firstId), + queue.pendingForRelay("relay-b").map { it.queueId } + ) + + queue.markDelivered(firstId, "relay-b") + assertEquals(1, queue.size()) + } + + @Test + fun `privacy purge retains non-live publishes`() { + val queue = NostrPendingEventQueue(capacity = 4) + queue.enqueue(event("manual"), listOf("relay"), liveLocationToken = null) + queue.enqueue(event("live"), listOf("relay"), liveLocationToken = 42L) + + queue.removeLiveLocationEvents() + + assertEquals( + listOf("manual"), + queue.pendingForRelay("relay").map { it.event.content } + ) + } + + private fun event(content: String): NostrEvent { + val privateKey = "0".repeat(63) + "1" + return NostrEvent( + pubkey = NostrCrypto.derivePublicKey(privateKey), + createdAt = 1, + kind = NostrKind.TEXT_NOTE, + tags = emptyList(), + content = content + ).sign(privateKey) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt index 7bb4aa30..de53a579 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt @@ -9,6 +9,34 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class NostrRelayManagerLifecycleSmokeTest { + @Test + fun `owner teardown is synchronous and preserves other subscription owners`() { + val manager = NostrRelayManager.shared + manager.disconnect() + manager.clearAllSubscriptions() + val filter = NostrFilter(kinds = listOf(NostrKind.TEXT_NOTE)) + + manager.subscribe( + filter = filter, + id = "background-contract", + handler = {}, + targetRelayUrls = emptyList(), + owner = NostrRelayManager.OWNER_BACKGROUND + ) + manager.subscribe( + filter = filter, + id = "ui-contract", + handler = {}, + targetRelayUrls = emptyList(), + owner = "test-ui" + ) + + manager.unsubscribeOwner("test-ui") + + assertEquals(setOf("background-contract"), manager.getActiveSubscriptions().keys) + manager.clearAllSubscriptions() + } + @Test fun `disconnected manager maintains subscription and empty publish invariants locally`() { val manager = NostrRelayManager.shared From 8c62e90711b941e4bf84caa96995dac186580844 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:00:59 +0200 Subject: [PATCH 40/70] ui: keep unread DM senders visible --- .../com/bitchat/android/ui/ChatViewModel.kt | 21 ++ .../bitchat/android/ui/GeohashPeopleList.kt | 14 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 206 ++++++++++++++++-- .../android/ui/UnreadConversationSummary.kt | 86 ++++++++ .../ui/UnreadConversationSummaryTest.kt | 117 ++++++++++ 5 files changed, 418 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 3017b20c..6ef0ff81 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -8,7 +8,10 @@ import androidx.lifecycle.viewModelScope import com.bitchat.android.favorites.FavoritesPersistenceService import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.mesh.MeshService @@ -171,6 +174,24 @@ class ChatViewModel( val privateChats: StateFlow>> = state.privateChats val selectedPrivateChatPeer: StateFlow = state.selectedPrivateChatPeer val unreadPrivateMessages: StateFlow> = state.unreadPrivateMessages + internal val unreadConversations: StateFlow> = combine( + state.unreadPrivateMessages, + state.privateChats, + state.nickname + ) { unreadConversationIDs, chats, currentNickname -> + val seenStore = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) + buildUnreadConversationSummaries( + unreadConversationIDs = unreadConversationIDs, + privateChats = chats, + currentUserIdentifiers = setOf(currentNickname, mesh.myPeerID), + canonicalize = ContactDirectory::canonicalConversationId, + isMessageRead = { message -> seenStore.hasRead(message.id) } + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = emptyList() + ) val joinedChannels: StateFlow> = state.joinedChannels val currentChannel: StateFlow = state.currentChannel val channelMessages: StateFlow>> = state.channelMessages diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 0cf88b74..e8f87668 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -19,6 +19,7 @@ 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.services.ContactDirectory import com.bitchat.android.ui.theme.LocalBitchatPalette import java.util.* @@ -36,7 +37,8 @@ data class GeoPerson( fun GeohashPeopleList( viewModel: ChatViewModel, onTapPerson: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + excludedConversationIDs: Set = emptySet() ) { val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() @@ -77,9 +79,15 @@ fun GeohashPeopleList( geohashPeople } } - val sections = remember(peopleIncludingSelf, myHex, isTeleported, teleportedGeo) { + val visiblePeople = remember(peopleIncludingSelf, excludedConversationIDs) { + peopleIncludingSelf.filterNot { person -> + val alias = "nostr_${person.id.take(16)}" + ContactDirectory.canonicalConversationId(alias) in excludedConversationIDs + } + } + val sections = remember(visiblePeople, myHex, isTeleported, teleportedGeo) { sectionGeohashPeople( - people = peopleIncludingSelf, + people = visiblePeople, myId = myHex, selfIsTeleported = isTeleported, teleportedIds = teleportedGeo diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index c496938e..7ec2fc0a 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -82,9 +82,16 @@ fun MeshPeerListSheet( val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() + val unreadConversations by viewModel.unreadConversations.collectAsStateWithLifecycle() val geohashPeopleCount = geohashPeople.size val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } + val unreadConversationIDs = remember(unreadConversations) { + unreadConversations.mapTo(mutableSetOf()) { it.conversationID } + } + val visibleConnectedPeers = connectedPeers.filterNot { peerID -> + ContactDirectory.canonicalConversationId(peerID) in unreadConversationIDs + } // Bottom sheet state val sheetState = rememberModalBottomSheetState( @@ -117,7 +124,22 @@ fun MeshPeerListSheet( ) { val peopleCount = when (selectedLocationChannel) { is ChannelID.Location -> geohashPeopleCount - else -> connectedPeers.count { it != viewModel.myPeerID } + else -> visibleConnectedPeers.count { it != viewModel.myPeerID } + } + + if (unreadConversations.isNotEmpty()) { + item(key = "unread_private_messages_section") { + UnreadDirectMessagesSection( + conversations = unreadConversations, + connectedPeers = connectedPeers, + viewModel = viewModel, + onPrivateChatStart = { conversationID -> + viewModel.showPrivateChatSheet(conversationID) + onDismiss() + }, + modifier = Modifier.padding(top = 8.dp) + ) + } } // Channels section @@ -127,7 +149,9 @@ fun MeshPeerListSheet( SheetIconSectionHeader( iconRes = R.drawable.ic_spec_chat_bubbles, title = stringResource(R.string.channels), - modifier = Modifier.padding(top = 8.dp) + modifier = Modifier.padding( + top = if (unreadConversations.isNotEmpty()) 20.dp else 8.dp + ) ) Surface( modifier = Modifier @@ -179,8 +203,12 @@ fun MeshPeerListSheet( GeohashPeopleList( viewModel = viewModel, onTapPerson = onDismiss, + excludedConversationIDs = unreadConversationIDs, modifier = Modifier.padding( - top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp + top = if ( + joinedChannels.isNotEmpty() || + unreadConversations.isNotEmpty() + ) 20.dp else 8.dp ) ) } @@ -188,9 +216,12 @@ fun MeshPeerListSheet( else -> { PeopleSection( modifier = Modifier.padding( - top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp + top = if ( + joinedChannels.isNotEmpty() || + unreadConversations.isNotEmpty() + ) 20.dp else 8.dp ), - connectedPeers = connectedPeers, + connectedPeers = visibleConnectedPeers, peerNicknames = peerNicknames, peerRSSI = peerRSSI, nickname = nickname, @@ -198,6 +229,7 @@ fun MeshPeerListSheet( selectedPrivatePeer = selectedPrivatePeer, wifiAwarePeerIDs = wifiAwarePeerIDs, peopleCount = peopleCount, + excludedConversationIDs = unreadConversationIDs, viewModel = viewModel, onPrivateChatStart = { peerID -> viewModel.showPrivateChatSheet(peerID) @@ -312,6 +344,7 @@ fun PeopleSection( selectedPrivatePeer: String?, wifiAwarePeerIDs: Set = emptySet(), peopleCount: Int = 0, + excludedConversationIDs: Set = emptySet(), viewModel: ChatViewModel, onPrivateChatStart: (String) -> Unit ) { @@ -410,8 +443,6 @@ fun PeopleSection( ) // Build a map of base name counts across all people shown in the list (connected + offline + nostr) - val hex64Regex = Regex("^[0-9a-fA-F]{64}$") - // Helper to compute display name used for a given key fun computeDisplayNameForPeerId(key: String): String { return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12))) @@ -430,33 +461,29 @@ fun PeopleSection( val offlineFavorites = FavoritesPersistenceService.shared.getOurFavorites() offlineFavorites.forEach { fav -> val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey) - if (!isFavoriteMappedToConnected(fav)) { + val conversationID = ContactDirectory.canonicalConversationId(favPeerID) + if ( + conversationID !in excludedConversationIDs && + !isFavoriteMappedToConnected(fav) + ) { val dn = peerNicknames[favPeerID] ?: fav.peerNickname val (b, _) = splitSuffix(dn) if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 } } - // Nostr-only conversations - val connectedIds = sortedPeers.toSet() - privateChats.keys - .filter { key -> - (key.startsWith("nostr_") || hex64Regex.matches(key)) && - !connectedIds.contains(key) && - !connectedNoiseHexes.contains(key.lowercase()) - } - .forEach { convKey -> - val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12)) - val (b, _) = splitSuffix(dn) - if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 - } - // 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 offlineFavoriteRows = offlineFavorites.filterNot { favorite -> + val favoriteConversationID = ContactDirectory.canonicalConversationId( + ContactIdentityResolver.noiseKeyHex(favorite.peerNoisePublicKey) + ) + favoriteConversationID in excludedConversationIDs || + isFavoriteMappedToConnected(favorite) + } val rowKeys: List = sortedPeers + offlineFavoriteRows.map { ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) } @@ -563,6 +590,139 @@ fun PeopleSection( } } +@Composable +private fun UnreadDirectMessagesSection( + conversations: List, + connectedPeers: List, + viewModel: ChatViewModel, + onPrivateChatStart: (String) -> Unit, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + + Column(modifier = modifier) { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_envelope, + title = stringResource(R.string.cd_unread_private_messages) + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + AnimatedRowColumn( + items = conversations, + key = { it.conversationID } + ) { index, conversation -> + Column { + if (index > 0) SheetCardDivider() + + val resolution = ContactDirectory.resolve(conversation.conversationID) + val connected = resolution.meshPeerID?.let(connectedPeers::contains) == true || + conversation.conversationID in connectedPeers + val aliases = ContactDirectory.aliasesForConversation( + conversation.conversationID + ) + val sourceGeohash = aliases + .asSequence() + .mapNotNull(GeohashConversationRegistry::get) + .firstOrNull() + val displayName = resolution.displayName + ?.takeUnless { it.isBlank() || it.equals("Unknown", ignoreCase = true) } + ?: conversation.displayName + val subtitle = when { + sourceGeohash != null -> "#$sourceGeohash" + conversation.transport == DirectMessageTransport.NOSTR -> + stringResource(R.string.cd_reachable_via_nostr) + !connected -> stringResource(R.string.cd_offline_mesh_chat) + else -> null + } + val peerIdentity = conversation.nostrPubkey + ?.let(viewModel::peerIdentityForNostrPubkey) + ?: viewModel.peerIdentityForMeshPeer(conversation.conversationID) + val assignedColor = colorForPeer(peerIdentity, palette) + val (baseNameRaw, suffix) = splitSuffix(displayName) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onPrivateChatStart(conversation.conversationID) + } + .padding( + horizontal = SheetRowHorizontal, + vertical = SheetRowVertical + ), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(SheetRowLeadingSlot), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_spec_envelope), + contentDescription = stringResource(R.string.cd_unread_message), + modifier = Modifier.size(PeerRowIconSize), + tint = palette.accentOrange + ) + } + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = truncateNickname(baseNameRaw), + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = assignedColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (suffix.isNotEmpty()) { + Text( + text = suffix, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = assignedColor.copy(alpha = SUFFIX_ALPHA) + ) + } + } + + if (subtitle != null) { + Text( + text = subtitle, + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + color = palette.textTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + UnreadBadge( + count = conversation.unreadCount, + colorScheme = colorScheme + ) + } + } + } + } + } +} + @Composable private fun PeerItem( peerID: String, diff --git a/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt b/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt new file mode 100644 index 00000000..bad2c43c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt @@ -0,0 +1,86 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage + +internal enum class DirectMessageTransport { + MESH, + NOSTR +} + +/** + * Presence-independent presentation state for an unread private conversation. + * + * A conversation remains in this model until it is read, even when none of its identities are in + * the current mesh or geohash participant lists. + */ +internal data class UnreadConversationSummary( + val conversationID: String, + val displayName: String, + val unreadCount: Int, + val latestMessageAt: Long, + val transport: DirectMessageTransport, + val nostrPubkey: String? +) + +internal fun buildUnreadConversationSummaries( + unreadConversationIDs: Set, + privateChats: Map>, + currentUserIdentifiers: Set, + canonicalize: (String) -> String, + isMessageRead: (BitchatMessage) -> Boolean +): List { + if (unreadConversationIDs.isEmpty()) return emptyList() + + val normalizedCurrentUserIdentifiers = currentUserIdentifiers.filterTo(mutableSetOf()) { + it.isNotBlank() + } + val canonicalUnreadIDs = unreadConversationIDs + .mapTo(linkedSetOf()) { canonicalize(it) } + val unreadAliasesByCanonicalID = unreadConversationIDs.groupBy(canonicalize) + val messagesByCanonicalID = linkedMapOf>() + + privateChats.forEach { (conversationID, messages) -> + val canonicalID = canonicalize(conversationID) + messagesByCanonicalID.getOrPut(canonicalID) { mutableListOf() }.addAll(messages) + } + + return canonicalUnreadIDs.map { conversationID -> + val messages = messagesByCanonicalID[conversationID] + .orEmpty() + .distinctBy { it.id } + val incomingMessages = messages.filterNot { + it.sender in normalizedCurrentUserIdentifiers + } + val unreadIncomingMessages = incomingMessages.filterNot(isMessageRead) + val latestMessage = (unreadIncomingMessages.ifEmpty { incomingMessages }) + .maxWithOrNull(compareBy { it.timestamp.time }.thenBy { it.id }) + val aliases = unreadAliasesByCanonicalID[conversationID].orEmpty() + val nostrPubkey = latestMessage?.senderNostrPubkey + val isNostrConversation = nostrPubkey != null || + aliases.any(::isNostrConversationID) || + isNostrConversationID(conversationID) + + UnreadConversationSummary( + conversationID = conversationID, + displayName = latestMessage + ?.sender + ?.takeIf { it.isNotBlank() } + ?: conversationID.take(12), + unreadCount = unreadIncomingMessages.size.coerceAtLeast(1), + latestMessageAt = latestMessage?.timestamp?.time ?: Long.MIN_VALUE, + transport = if (isNostrConversation) { + DirectMessageTransport.NOSTR + } else { + DirectMessageTransport.MESH + }, + nostrPubkey = nostrPubkey + ) + }.sortedWith( + compareByDescending { it.latestMessageAt } + .thenBy { it.displayName.lowercase() } + .thenBy { it.conversationID } + ) +} + +private fun isNostrConversationID(value: String): Boolean = + value.startsWith("nostr_") || value.startsWith("nostr:") diff --git a/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt new file mode 100644 index 00000000..e054d819 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt @@ -0,0 +1,117 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Date + +class UnreadConversationSummaryTest { + @Test + fun `unread conversations survive missing presence and sort by latest unread`() { + val older = incoming( + id = "older", + sender = "alice", + timestamp = 100 + ) + val newer = incoming( + id = "newer", + sender = "bob", + timestamp = 200 + ) + + val rows = buildUnreadConversationSummaries( + unreadConversationIDs = setOf("alice-peer", "bob-peer"), + privateChats = mapOf( + "alice-peer" to listOf(older), + "bob-peer" to listOf(newer) + ), + currentUserIdentifiers = setOf("me"), + canonicalize = { it }, + isMessageRead = { false } + ) + + assertEquals(listOf("bob-peer", "alice-peer"), rows.map { it.conversationID }) + assertEquals(listOf("bob", "alice"), rows.map { it.displayName }) + } + + @Test + fun `canonical aliases produce one unread conversation row`() { + val message = incoming( + id = "message", + sender = "alice", + timestamp = 100 + ) + + val rows = buildUnreadConversationSummaries( + unreadConversationIDs = setOf("mesh-alias", "nostr_alias"), + privateChats = mapOf( + "mesh-alias" to listOf(message), + "nostr_alias" to listOf(message) + ), + currentUserIdentifiers = setOf("me"), + canonicalize = { "contact_alice" }, + isMessageRead = { false } + ) + + assertEquals(1, rows.size) + assertEquals("contact_alice", rows.single().conversationID) + assertEquals(DirectMessageTransport.NOSTR, rows.single().transport) + } + + @Test + fun `only unseen incoming messages contribute to unread count`() { + val read = incoming( + id = "read", + sender = "alice", + timestamp = 100 + ) + val unread = incoming( + id = "unread", + sender = "alice", + timestamp = 200 + ) + val outgoing = incoming( + id = "outgoing", + sender = "me", + timestamp = 300 + ) + + val row = buildUnreadConversationSummaries( + unreadConversationIDs = setOf("alice-peer"), + privateChats = mapOf("alice-peer" to listOf(read, unread, outgoing)), + currentUserIdentifiers = setOf("me"), + canonicalize = { it }, + isMessageRead = { it.id == "read" } + ).single() + + assertEquals(1, row.unreadCount) + assertEquals(200, row.latestMessageAt) + } + + @Test + fun `unread key without hydrated messages still produces a row`() { + val row = buildUnreadConversationSummaries( + unreadConversationIDs = setOf("orphan-peer"), + privateChats = emptyMap(), + currentUserIdentifiers = setOf("me"), + canonicalize = { it }, + isMessageRead = { false } + ).single() + + assertEquals("orphan-peer", row.conversationID) + assertEquals(1, row.unreadCount) + assertTrue(row.displayName.isNotBlank()) + } + + private fun incoming( + id: String, + sender: String, + timestamp: Long + ) = BitchatMessage( + id = id, + sender = sender, + content = "hello", + timestamp = Date(timestamp) + ) +} From 1a390b5a1f59495986f2db1e0ab1833cffe13c04 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:13:46 +0200 Subject: [PATCH 41/70] ui: glow lock for Noise handshake instead of sync icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match private-chat Noise status to the Tor globe treatment: one lock glyph with an orange pulse while handshaking, then green or red with smooth tint cross-fades — no sync/recycle swap. Co-authored-by: Cursor --- .../java/com/bitchat/android/ui/ChatHeader.kt | 97 ++++++++++++------- 1 file changed, 63 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index fe3ad343..8d4fdfa1 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -8,6 +8,7 @@ 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.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween @@ -157,7 +158,8 @@ internal fun rememberTorConnectionVisual(normal: Color): TorConnectionVisual { /** * Soft, slow brightness pulse used while Tor is connecting. Keeps scale fixed so layout - * does not shift; only opacity / a faint halo breathe. + * does not shift; only opacity / a faint halo breathe. Glow strength itself cross-fades so + * starting/stopping progress never pops. */ @Composable internal fun TorAwareHeaderIcon( @@ -167,7 +169,12 @@ internal fun TorAwareHeaderIcon( contentDescription: String?, modifier: Modifier = Modifier, ) { - val pulse = if (isProgress) { + val progressFade by animateFloatAsState( + targetValue = if (isProgress) 1f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), + label = "torGlowFade" + ) + val pulse = if (progressFade > 0.01f) { val transition = rememberInfiniteTransition(label = "torGlow") transition.animateFloat( initialValue = 0.42f, @@ -188,7 +195,7 @@ internal fun TorAwareHeaderIcon( contentAlignment = Alignment.Center, modifier = modifier.size(HeaderIconSize) ) { - if (isProgress) { + if (progressFade > 0.01f) { val glowBrush = remember(tint) { Brush.radialGradient( colorStops = arrayOf( @@ -201,7 +208,7 @@ internal fun TorAwareHeaderIcon( Box( modifier = Modifier .requiredSize(HeaderIconSize + 14.dp) - .graphicsLayer { alpha = pulse * 0.85f } + .graphicsLayer { alpha = pulse * 0.85f * progressFade } .background(glowBrush) ) } @@ -211,7 +218,9 @@ internal fun TorAwareHeaderIcon( modifier = Modifier .size(HeaderIconSize) .graphicsLayer { - alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f + // Idle = solid; in-progress = breathing opacity, lerped by [progressFade]. + val breathing = 0.55f + pulse * 0.45f + alpha = 1f - progressFade * (1f - breathing) }, tint = tint ) @@ -227,7 +236,12 @@ internal fun TorAwareHeaderIcon( contentDescription: String?, modifier: Modifier = Modifier, ) { - val pulse = if (isProgress) { + val progressFade by animateFloatAsState( + targetValue = if (isProgress) 1f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), + label = "torPainterGlowFade" + ) + val pulse = if (progressFade > 0.01f) { val transition = rememberInfiniteTransition(label = "torPainterGlow") transition.animateFloat( initialValue = 0.42f, @@ -246,7 +260,7 @@ internal fun TorAwareHeaderIcon( contentAlignment = Alignment.Center, modifier = modifier.size(HeaderIconSize) ) { - if (isProgress) { + if (progressFade > 0.01f) { val glowBrush = remember(tint) { Brush.radialGradient( colorStops = arrayOf( @@ -259,7 +273,7 @@ internal fun TorAwareHeaderIcon( Box( modifier = Modifier .requiredSize(HeaderIconSize + 14.dp) - .graphicsLayer { alpha = pulse * 0.85f } + .graphicsLayer { alpha = pulse * 0.85f * progressFade } .background(glowBrush) ) } @@ -269,13 +283,22 @@ internal fun TorAwareHeaderIcon( modifier = Modifier .size(HeaderIconSize) .graphicsLayer { - alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f + val breathing = 0.55f + pulse * 0.45f + alpha = 1f - progressFade * (1f - breathing) }, tint = tint ) } } +/** + * Noise session status for private-chat headers. + * + * Same visual language as the main header's Tor-aware globe: one lock glyph throughout, tint + * cross-fades between states, and a soft radial glow pulse while the handshake is in flight. + * The old sync/recycle glyph is gone — progress is carried by colour and motion, not by swapping + * icons. + */ @Composable fun NoiseSessionIcon( sessionState: String?, @@ -283,39 +306,45 @@ fun NoiseSessionIcon( ) { 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( - R.drawable.ic_spec_lock_open, - colorScheme.onSurfaceVariant, - stringResource(R.string.cd_ready_for_handshake) - ) - "handshaking" -> Triple( - R.drawable.ic_spec_sync, - colorScheme.onSurfaceVariant, + + val (targetTint, isProgress, contentDescription) = when { + sessionState == "handshaking" -> Triple( + palette.accentOrange, + true, stringResource(R.string.cd_handshake_in_progress) ) - "established" -> Triple( - R.drawable.ic_spec_lock, + sessionState == "established" -> Triple( colorScheme.primary, + false, stringResource(R.string.cd_encrypted) ) - else -> { // "failed" or any other state - Triple( - R.drawable.ic_spec_warning, - colorScheme.error, - stringResource(R.string.cd_handshake_failed) - ) - } + sessionState?.startsWith("failed") == true -> Triple( + colorScheme.error, + false, + stringResource(R.string.cd_handshake_failed) + ) + else -> Triple( + // Not yet started — quiet grey lock, same glyph as every other state. + colorScheme.onSurfaceVariant, + false, + stringResource(R.string.cd_ready_for_handshake) + ) } - Icon( - painter = painterResource(iconRes), + // Longer than the usual chrome tint so grey → orange → green reads as a continuous wash, + // not a snap between discrete states. + val animatedTint by animateColorAsState( + targetValue = targetTint, + animationSpec = tween(durationMillis = 480, easing = FastOutSlowInEasing), + label = "noiseSessionTint" + ) + + TorAwareHeaderIcon( + painter = painterResource(R.drawable.ic_spec_lock), + tint = animatedTint, + isProgress = isProgress, contentDescription = contentDescription, - modifier = modifier, - tint = color + modifier = modifier ) } From 21cfa62cc2102764957198ea1c1ecc25382ec242 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:23:08 +0200 Subject: [PATCH 42/70] fix: address unread DM review feedback --- .../com/bitchat/android/ui/ChatViewModel.kt | 120 ++++++++++++++---- .../bitchat/android/ui/GeohashPeopleList.kt | 9 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 44 +++---- .../com/bitchat/android/ui/MessageManager.kt | 12 +- .../bitchat/android/ui/PrivateChatManager.kt | 8 +- .../android/ui/UnreadConversationSummary.kt | 23 +++- .../android/ui/PrivateChatManagerTest.kt | 23 ++++ .../ui/UnreadConversationSummaryTest.kt | 20 +++ 8 files changed, 190 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 6ef0ff81..c00504fb 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -6,11 +6,13 @@ import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.bitchat.android.favorites.FavoritesPersistenceService +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService @@ -19,10 +21,12 @@ import com.bitchat.android.service.MeshServiceHolder import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.nostr.NostrIdentityBridge +import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.protocol.BitchatPacket import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import com.bitchat.android.util.NotificationIntervalManager import kotlinx.coroutines.delay import java.util.Date @@ -177,21 +181,54 @@ class ChatViewModel( internal val unreadConversations: StateFlow> = combine( state.unreadPrivateMessages, state.privateChats, - state.nickname - ) { unreadConversationIDs, chats, currentNickname -> + state.nickname, + state.connectedPeers + ) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs -> val seenStore = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) + val connectedPeerIDSet = connectedPeerIDs.mapTo(mutableSetOf()) { it.lowercase() } buildUnreadConversationSummaries( unreadConversationIDs = unreadConversationIDs, privateChats = chats, currentUserIdentifiers = setOf(currentNickname, mesh.myPeerID), canonicalize = ContactDirectory::canonicalConversationId, isMessageRead = { message -> seenStore.hasRead(message.id) } + ).map { summary -> + val resolution = ContactDirectory.resolve(summary.conversationID) + val resolvedNostrPubkey = summary.nostrPubkey + ?: resolution.nostrPubkey?.let(ContactIdentityResolver::nostrPubkeyHex) + val aliases = buildSet { + addAll(summary.identityAliases) + add(summary.conversationID) + add(resolution.conversationID) + resolution.meshPeerID?.let(::add) + resolution.noiseKeyHex?.let(::add) + resolvedNostrPubkey + ?.let(ContactIdentityResolver::nostrAliasForPubkey) + ?.let(::add) + }.mapTo(mutableSetOf()) { it.lowercase() } + + summary.copy( + displayName = resolution.displayName + ?.takeUnless { + it.isBlank() || it.equals("Unknown", ignoreCase = true) + } + ?: summary.displayName, + nostrPubkey = resolvedNostrPubkey, + identityAliases = aliases, + isConnected = aliases.any(connectedPeerIDSet::contains), + sourceGeohash = aliases + .asSequence() + .mapNotNull(GeohashConversationRegistry::get) + .firstOrNull() + ) + } + } + .flowOn(Dispatchers.IO) + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = emptyList() ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = emptyList() - ) val joinedChannels: StateFlow> = state.joinedChannels val currentChannel: StateFlow = state.currentChannel val channelMessages: StateFlow>> = state.channelMessages @@ -256,18 +293,27 @@ class ChatViewModel( } viewModelScope.launch { try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer -> - val canonicalChats = ContactDirectory.canonicalizePrivateChats(byPeer) + val (canonicalChats, unreadConversationIDs) = withContext(Dispatchers.IO) { + val canonical = ContactDirectory.canonicalizePrivateChats(byPeer) + val unread = try { + val seen = com.bitchat.android.services.SeenMessageStore + .getInstance(getApplication()) + val myNick = state.getNicknameValue().ifBlank { mesh.myPeerID } + canonical + .filterValues { messages -> + messages.any { message -> + message.sender != myNick && !seen.hasRead(message.id) + } + } + .keys + } catch (_: Exception) { + state.getUnreadPrivateMessagesValue() + } + canonical to unread + } state.setPrivateChats(canonicalChats) // Recompute unread set using SeenMessageStore for robustness across Activity recreation - try { - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) - val myNick = state.getNicknameValue() ?: mesh.myPeerID - val unread = mutableSetOf() - canonicalChats.forEach { (peer, list) -> - if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer) - } - state.setUnreadPrivateMessages(unread) - } catch (_: Exception) { } + state.setUnreadPrivateMessages(unreadConversationIDs) } } catch (_: Exception) { } } viewModelScope.launch { @@ -397,15 +443,26 @@ class ChatViewModel( // MARK: - Private Chat Management (delegated) - fun startPrivateChat(peerID: String) { + suspend fun startPrivateChat(peerID: String) { // For geohash conversation keys, ensure DM subscription is active if (peerID.startsWith("nostr_")) { ensureGeohashDMSubscriptionIfNeeded(peerID) } - - val success = privateChatManager.startPrivateChat(peerID, mesh) + + val (conversationID, unreadAliases) = withContext(Dispatchers.IO) { + val canonicalID = ContactDirectory.canonicalConversationId(peerID) + canonicalID to matchingUnreadAliases( + unreadConversationIDs = state.getUnreadPrivateMessagesValue(), + canonicalConversationID = canonicalID, + canonicalize = ContactDirectory::canonicalConversationId + ) + } + val success = privateChatManager.startPrivateChat( + peerID = conversationID, + meshService = mesh, + unreadAliases = unreadAliases + ) if (success) { - val conversationID = ContactDirectory.canonicalConversationId(peerID) // Notify notification manager about current private chat setCurrentPrivateChatPeer(conversationID) // Clear notifications for this sender since user is now viewing the chat @@ -413,14 +470,21 @@ class ChatViewModel( // Persistently mark all messages in this conversation as read so Nostr fetches // after app restarts won't re-mark them as unread. - try { - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) - val chats = state.getPrivateChatsValue() - val messages = chats[conversationID] ?: emptyList() - messages.forEach { msg -> - try { seen.markRead(msg.id) } catch (_: Exception) { } + withContext(Dispatchers.IO) { + try { + val seen = + com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) + val chats = state.getPrivateChatsValue() + val messages = chats[conversationID] ?: emptyList() + messages.forEach { msg -> + try { + seen.markRead(msg.id) + } catch (_: Exception) { + } + } + } catch (_: Exception) { } - } catch (_: Exception) { } + } } } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index e8f87668..d784610a 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -19,7 +19,6 @@ 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.services.ContactDirectory import com.bitchat.android.ui.theme.LocalBitchatPalette import java.util.* @@ -38,7 +37,7 @@ fun GeohashPeopleList( viewModel: ChatViewModel, onTapPerson: () -> Unit, modifier: Modifier = Modifier, - excludedConversationIDs: Set = emptySet() + excludedIdentityAliases: Set = emptySet() ) { val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() @@ -79,10 +78,10 @@ fun GeohashPeopleList( geohashPeople } } - val visiblePeople = remember(peopleIncludingSelf, excludedConversationIDs) { + val visiblePeople = remember(peopleIncludingSelf, excludedIdentityAliases) { peopleIncludingSelf.filterNot { person -> - val alias = "nostr_${person.id.take(16)}" - ContactDirectory.canonicalConversationId(alias) in excludedConversationIDs + val alias = "nostr_${person.id.take(16)}".lowercase() + alias in excludedIdentityAliases } } val sections = remember(visiblePeople, myHex, isTeleported, teleportedGeo) { diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 7ec2fc0a..a5777ccf 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -86,11 +86,12 @@ fun MeshPeerListSheet( val geohashPeopleCount = geohashPeople.size val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } - val unreadConversationIDs = remember(unreadConversations) { - unreadConversations.mapTo(mutableSetOf()) { it.conversationID } + val unreadIdentityAliases = remember(unreadConversations) { + unreadConversations + .flatMapTo(mutableSetOf()) { it.identityAliases } } val visibleConnectedPeers = connectedPeers.filterNot { peerID -> - ContactDirectory.canonicalConversationId(peerID) in unreadConversationIDs + peerID.lowercase() in unreadIdentityAliases } // Bottom sheet state @@ -131,7 +132,6 @@ fun MeshPeerListSheet( item(key = "unread_private_messages_section") { UnreadDirectMessagesSection( conversations = unreadConversations, - connectedPeers = connectedPeers, viewModel = viewModel, onPrivateChatStart = { conversationID -> viewModel.showPrivateChatSheet(conversationID) @@ -203,7 +203,7 @@ fun MeshPeerListSheet( GeohashPeopleList( viewModel = viewModel, onTapPerson = onDismiss, - excludedConversationIDs = unreadConversationIDs, + excludedIdentityAliases = unreadIdentityAliases, modifier = Modifier.padding( top = if ( joinedChannels.isNotEmpty() || @@ -229,7 +229,7 @@ fun MeshPeerListSheet( selectedPrivatePeer = selectedPrivatePeer, wifiAwarePeerIDs = wifiAwarePeerIDs, peopleCount = peopleCount, - excludedConversationIDs = unreadConversationIDs, + excludedIdentityAliases = unreadIdentityAliases, viewModel = viewModel, onPrivateChatStart = { peerID -> viewModel.showPrivateChatSheet(peerID) @@ -344,7 +344,7 @@ fun PeopleSection( selectedPrivatePeer: String?, wifiAwarePeerIDs: Set = emptySet(), peopleCount: Int = 0, - excludedConversationIDs: Set = emptySet(), + excludedIdentityAliases: Set = emptySet(), viewModel: ChatViewModel, onPrivateChatStart: (String) -> Unit ) { @@ -461,9 +461,8 @@ fun PeopleSection( val offlineFavorites = FavoritesPersistenceService.shared.getOurFavorites() offlineFavorites.forEach { fav -> val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey) - val conversationID = ContactDirectory.canonicalConversationId(favPeerID) if ( - conversationID !in excludedConversationIDs && + favPeerID.lowercase() !in excludedIdentityAliases && !isFavoriteMappedToConnected(fav) ) { val dn = peerNicknames[favPeerID] ?: fav.peerNickname @@ -478,10 +477,10 @@ fun PeopleSection( val directMap by viewModel.peerDirect.collectAsStateWithLifecycle() val offlineFavoriteRows = offlineFavorites.filterNot { favorite -> - val favoriteConversationID = ContactDirectory.canonicalConversationId( - ContactIdentityResolver.noiseKeyHex(favorite.peerNoisePublicKey) + val favoriteNoiseKey = ContactIdentityResolver.noiseKeyHex( + favorite.peerNoisePublicKey ) - favoriteConversationID in excludedConversationIDs || + favoriteNoiseKey.lowercase() in excludedIdentityAliases || isFavoriteMappedToConnected(favorite) } val rowKeys: List = sortedPeers + @@ -593,7 +592,6 @@ fun PeopleSection( @Composable private fun UnreadDirectMessagesSection( conversations: List, - connectedPeers: List, viewModel: ChatViewModel, onPrivateChatStart: (String) -> Unit, modifier: Modifier = Modifier @@ -622,31 +620,19 @@ private fun UnreadDirectMessagesSection( Column { if (index > 0) SheetCardDivider() - val resolution = ContactDirectory.resolve(conversation.conversationID) - val connected = resolution.meshPeerID?.let(connectedPeers::contains) == true || - conversation.conversationID in connectedPeers - val aliases = ContactDirectory.aliasesForConversation( - conversation.conversationID - ) - val sourceGeohash = aliases - .asSequence() - .mapNotNull(GeohashConversationRegistry::get) - .firstOrNull() - val displayName = resolution.displayName - ?.takeUnless { it.isBlank() || it.equals("Unknown", ignoreCase = true) } - ?: conversation.displayName val subtitle = when { - sourceGeohash != null -> "#$sourceGeohash" + conversation.sourceGeohash != null -> "#${conversation.sourceGeohash}" conversation.transport == DirectMessageTransport.NOSTR -> stringResource(R.string.cd_reachable_via_nostr) - !connected -> stringResource(R.string.cd_offline_mesh_chat) + !conversation.isConnected -> + stringResource(R.string.cd_offline_mesh_chat) else -> null } val peerIdentity = conversation.nostrPubkey ?.let(viewModel::peerIdentityForNostrPubkey) ?: viewModel.peerIdentityForMeshPeer(conversation.conversationID) val assignedColor = colorForPeer(peerIdentity, palette) - val (baseNameRaw, suffix) = splitSuffix(displayName) + val (baseNameRaw, suffix) = splitSuffix(conversation.displayName) Row( modifier = Modifier diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index e93ef39a..0c7b64b2 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -153,11 +153,17 @@ class MessageManager(private val state: ChatState) { state.setPrivateChats(updatedChats) } - fun clearPrivateUnreadMessages(peerID: String) { + fun clearPrivateUnreadMessages( + peerID: String, + aliases: Set = emptySet() + ) { val conversationID = ContactDirectory.canonicalConversationId(peerID) val updatedUnread = state.getUnreadPrivateMessagesValue().toMutableSet() - updatedUnread.remove(peerID) - updatedUnread.remove(conversationID) + val normalizedAliases = (aliases + peerID + conversationID) + .mapTo(mutableSetOf()) { it.lowercase() } + updatedUnread.removeAll { unreadID -> + unreadID.lowercase() in normalizedAliases + } state.setUnreadPrivateMessages(updatedUnread) } diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index 1af48a04..8d7c5e1d 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -47,7 +47,11 @@ class PrivateChatManager( // MARK: - Private Chat Lifecycle - fun startPrivateChat(peerID: String, meshService: MeshService): Boolean { + fun startPrivateChat( + peerID: String, + meshService: MeshService, + unreadAliases: Set = emptySet() + ): Boolean { val conversationID = ContactDirectory.canonicalConversationId(peerID) val route = ContactDirectory.resolve(conversationID) val meshPeerID = route.meshPeerID ?: peerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) } @@ -75,7 +79,7 @@ class PrivateChatManager( state.setSelectedPrivateChatPeer(conversationID) // Clear unread - messageManager.clearPrivateUnreadMessages(conversationID) + messageManager.clearPrivateUnreadMessages(conversationID, unreadAliases) // Initialize chat if needed messageManager.initializePrivateChat(conversationID) diff --git a/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt b/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt index bad2c43c..5d0ad81a 100644 --- a/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt +++ b/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt @@ -19,7 +19,10 @@ internal data class UnreadConversationSummary( val unreadCount: Int, val latestMessageAt: Long, val transport: DirectMessageTransport, - val nostrPubkey: String? + val nostrPubkey: String?, + val identityAliases: Set, + val isConnected: Boolean = false, + val sourceGeohash: String? = null ) internal fun buildUnreadConversationSummaries( @@ -73,7 +76,9 @@ internal fun buildUnreadConversationSummaries( } else { DirectMessageTransport.MESH }, - nostrPubkey = nostrPubkey + nostrPubkey = nostrPubkey, + identityAliases = (aliases + conversationID) + .mapTo(mutableSetOf()) { it.lowercase() } ) }.sortedWith( compareByDescending { it.latestMessageAt } @@ -84,3 +89,17 @@ internal fun buildUnreadConversationSummaries( private fun isNostrConversationID(value: String): Boolean = value.startsWith("nostr_") || value.startsWith("nostr:") + +internal fun matchingUnreadAliases( + unreadConversationIDs: Set, + canonicalConversationID: String, + canonicalize: (String) -> String +): Set { + val normalizedCanonicalID = canonicalConversationID.lowercase() + return unreadConversationIDs + .filterTo(mutableSetOf()) { unreadID -> + canonicalize(unreadID).equals(normalizedCanonicalID, ignoreCase = true) + } + .plus(canonicalConversationID) + .mapTo(mutableSetOf()) { it.lowercase() } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt index 852411e8..8434cab1 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt @@ -104,4 +104,27 @@ class PrivateChatManagerTest { verify(meshService).sendReadReceipt(message.id, meshPeerID, "bob") } + + @Test + fun `opening canonical unread conversation clears all source aliases`() { + val canonicalID = "contact_alice" + val nostrAlias = "nostr_0123456789abcdef" + val meshAlias = "0123456789abcdef" + val unrelatedConversation = "other-contact" + val meshService = mock() + state.setUnreadPrivateMessages( + setOf(canonicalID, nostrAlias, meshAlias, unrelatedConversation) + ) + + manager.startPrivateChat( + peerID = canonicalID, + meshService = meshService, + unreadAliases = setOf(canonicalID, nostrAlias, meshAlias) + ) + + assertEquals( + setOf(unrelatedConversation), + state.getUnreadPrivateMessagesValue() + ) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt index e054d819..2b5f4613 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt @@ -57,6 +57,10 @@ class UnreadConversationSummaryTest { assertEquals(1, rows.size) assertEquals("contact_alice", rows.single().conversationID) assertEquals(DirectMessageTransport.NOSTR, rows.single().transport) + assertEquals( + setOf("mesh-alias", "nostr_alias", "contact_alice"), + rows.single().identityAliases + ) } @Test @@ -104,6 +108,22 @@ class UnreadConversationSummaryTest { assertTrue(row.displayName.isNotBlank()) } + @Test + fun `canonical unread lookup returns every matching source alias`() { + val aliases = matchingUnreadAliases( + unreadConversationIDs = setOf("mesh-alias", "nostr_alias", "other-contact"), + canonicalConversationID = "contact_alice", + canonicalize = { unreadID -> + if (unreadID == "other-contact") unreadID else "contact_alice" + } + ) + + assertEquals( + setOf("mesh-alias", "nostr_alias", "contact_alice"), + aliases + ) + } + private fun incoming( id: String, sender: String, From d8e380efae06f2d1ae7fcb063b0cc7cea989fb6c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:23:26 +0200 Subject: [PATCH 43/70] Fix background Nostr event processing --- .../android/nostr/GeohashMessageHandler.kt | 9 +- .../android/nostr/GeohashRepository.kt | 18 +++ .../nostr/NostrBackgroundEventProcessor.kt | 115 ++++++++++++++++++ .../android/nostr/NostrBackgroundRuntime.kt | 59 ++++----- .../nostr/NostrDirectMessageHandler.kt | 15 ++- .../bitchat/android/services/AppStateStore.kt | 16 +++ .../java/com/bitchat/android/ui/ChatState.kt | 2 + .../com/bitchat/android/ui/ChatViewModel.kt | 3 +- .../com/bitchat/android/ui/DataManager.kt | 6 + .../bitchat/android/ui/GeohashViewModel.kt | 53 ++++---- .../bitchat/android/ui/PrivateChatManager.kt | 10 +- .../NostrBackgroundEventProcessorTest.kt | 65 ++++++++++ .../android/ui/PrivateChatManagerTest.kt | 29 +++++ 13 files changed, 316 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index 1fc802d9..88d41466 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -3,8 +3,6 @@ package com.bitchat.android.nostr import android.app.Application import android.util.Log import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.ui.ChatState -import com.bitchat.android.ui.MessageManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.util.Date @@ -17,11 +15,10 @@ import java.util.Date */ class GeohashMessageHandler( private val application: Application, - private val state: ChatState, - private val messageManager: MessageManager, private val repo: GeohashRepository, private val scope: CoroutineScope, - private val dataManager: com.bitchat.android.ui.DataManager + private val dataManager: com.bitchat.android.ui.DataManager, + private val addChannelMessage: (String, BitchatMessage) -> Unit ) { companion object { private const val TAG = "GeohashMessageHandler" } @@ -103,7 +100,7 @@ class GeohashMessageHandler( if (hasNonce) NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } else null } catch (_: Exception) { null } ) - messageManager.addChannelMessage("geo:$subscribedGeohash", msg) + addChannelMessage("geo:$subscribedGeohash", msg) } catch (e: Exception) { Log.e(TAG, "onEvent error: ${e.message}") } diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt index 3a7ced32..50c72cd4 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt @@ -28,14 +28,17 @@ class GeohashRepository( // conversation key (e.g., "nostr_") -> source geohash it belongs to private val conversationGeohash: MutableMap = mutableMapOf() + @Synchronized fun setConversationGeohash(convKey: String, geohash: String) { if (geohash.isNotEmpty()) { conversationGeohash[convKey] = geohash } } + @Synchronized fun getConversationGeohash(convKey: String): String? = conversationGeohash[convKey] + @Synchronized fun findPubkeyByNickname(targetNickname: String): String? { return geoNicknames.entries.firstOrNull { (_, nickname) -> val base = nickname.split("#").firstOrNull() ?: nickname @@ -43,6 +46,7 @@ class GeohashRepository( }?.key } + @Synchronized fun findPubkeyByShortId(shortId: String): String? { // First check cached nicknames (fastest) var found = geoNicknames.keys.firstOrNull { it.startsWith(shortId, ignoreCase = true) } @@ -66,6 +70,7 @@ class GeohashRepository( fun setCurrentGeohash(geo: String?) { currentGeohash = geo } fun getCurrentGeohash(): String? = currentGeohash + @Synchronized fun clearAll() { geohashParticipants.clear() geoNicknames.clear() @@ -76,6 +81,7 @@ class GeohashRepository( currentGeohash = null } + @Synchronized fun cacheNickname(pubkeyHex: String, nickname: String) { val lower = pubkeyHex.lowercase() val previous = geoNicknames[lower] @@ -85,8 +91,10 @@ class GeohashRepository( } } + @Synchronized fun getCachedNickname(pubkeyHex: String): String? = geoNicknames[pubkeyHex.lowercase()] + @Synchronized fun markTeleported(pubkeyHex: String) { val set = state.getTeleportedGeoValue().toMutableSet() val key = pubkeyHex.lowercase() @@ -97,10 +105,12 @@ class GeohashRepository( } } + @Synchronized fun isPersonTeleported(pubkeyHex: String): Boolean { return state.getTeleportedGeoValue().contains(pubkeyHex.lowercase()) } + @Synchronized fun updateParticipant(geohash: String, participantId: String, lastSeen: Date) { val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() } // Cap to now: prevents future-timestamped events (clock skew / malicious created_at) @@ -117,6 +127,7 @@ class GeohashRepository( updateReactiveParticipantCounts() } + @Synchronized fun geohashParticipantCount(geohash: String): Int { val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) val participants = geohashParticipants[geohash] ?: return 0 @@ -130,6 +141,7 @@ class GeohashRepository( return participants.keys.count { !dataManager.isGeohashUserBlocked(it) } } + @Synchronized fun refreshGeohashPeople() { val geohash = currentGeohash if (geohash == null) { @@ -168,6 +180,7 @@ class GeohashRepository( state.setGeohashPeople(people) } + @Synchronized fun updateReactiveParticipantCounts() { val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) val counts = mutableMapOf() @@ -180,12 +193,15 @@ class GeohashRepository( state.setGeohashParticipantCounts(counts) } + @Synchronized fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) { nostrKeyMapping[tempKeyOrPeer] = pubkeyHex } + @Synchronized fun getNostrKeyMapping(): Map = nostrKeyMapping.toMap() + @Synchronized fun displayNameForNostrPubkey(pubkeyHex: String): String { val suffix = pubkeyHex.takeLast(4) val lower = pubkeyHex.lowercase() @@ -203,6 +219,7 @@ class GeohashRepository( return "$nick#$suffix" } + @Synchronized fun displayNameForNostrPubkeyUI(pubkeyHex: String): String { val lower = pubkeyHex.lowercase() val suffix = pubkeyHex.takeLast(4) @@ -234,6 +251,7 @@ class GeohashRepository( /** * Get display name for any geohash (not just current one) for header titles */ + @Synchronized fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String { val lower = pubkeyHex.lowercase() val suffix = pubkeyHex.takeLast(4) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt new file mode 100644 index 00000000..442840eb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt @@ -0,0 +1,115 @@ +package com.bitchat.android.nostr + +import android.app.Application +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.MessageManager +import com.bitchat.android.ui.NoiseSessionDelegate +import com.bitchat.android.ui.PrivateChatManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * Process-owned Nostr event processing. + * + * Relay subscriptions must remain useful when no Activity exists, so their handlers cannot be + * borrowed from a ViewModel. This processor owns only application-scoped collaborators and writes + * messages to [AppStateStore], which the next UI instance hydrates from. + */ +internal class NostrBackgroundEventProcessor( + application: Application, + parentScope: CoroutineScope +) { + private val scope = CoroutineScope( + parentScope.coroutineContext + Dispatchers.IO.limitedParallelism(1) + ) + private val state = ChatState(scope) + private val dataManager = DataManager(application.applicationContext).apply { + state.setNickname(loadNickname()) + loadBlockedUsers() + loadGeohashBlockedUsers() + } + private val messageManager = MessageManager(state) + private val geohashRepository = GeohashRepository(application, state, dataManager) + private val privateChatManager = PrivateChatManager( + state = state, + messageManager = messageManager, + dataManager = dataManager, + noiseSessionDelegate = object : NoiseSessionDelegate { + override fun hasEstablishedSession(peerID: String): Boolean = false + override fun initiateHandshake(peerID: String) = Unit + override fun getMyPeerID(): String = "" + }, + trackUnreadMessages = false + ) + private val geohashMessageHandler = GeohashMessageHandler( + application = application, + repo = geohashRepository, + scope = scope, + dataManager = dataManager, + addChannelMessage = AppStateStore::addChannelMessage + ) + private val directMessageHandler = NostrDirectMessageHandler( + application = application, + state = state, + privateChatManager = privateChatManager, + updateDeliveryStatus = ::updateDeliveryStatus, + scope = scope, + repo = geohashRepository, + dataManager = dataManager + ) + + init { + // Keep the headless state aligned with messages sent or received through other transports. + // This preserves duplicate detection and focused-conversation behavior without retaining UI. + scope.launch { + AppStateStore.privateMessages.collect(state::setPrivateChats) + } + scope.launch { + AppStateStore.nickname.collect(state::setNickname) + } + scope.launch { + AppStateStore.selectedPrivateChatPeer.collect(state::setSelectedPrivateChatPeer) + } + } + + fun onAccountDm(event: NostrEvent, identity: NostrIdentity) { + refreshBlockLists() + directMessageHandler.onGiftWrap(event, "", identity) + } + + fun onGeohashMessage(event: NostrEvent, geohash: String) { + refreshBlockLists() + geohashMessageHandler.onEvent(event, geohash) + } + + fun onGeohashDm(event: NostrEvent, geohash: String, identity: NostrIdentity) { + refreshBlockLists() + directMessageHandler.onGiftWrap(event, geohash, identity) + } + + fun conversationGeohash(conversationKey: String): String? = + geohashRepository.getConversationGeohash(conversationKey) + ?: GeohashConversationRegistry.get(conversationKey) + + fun displayNameForNostrPubkey(pubkeyHex: String): String = + geohashRepository.displayNameForNostrPubkeyUI(pubkeyHex) + + fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = + geohashRepository.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + + private fun updateDeliveryStatus(messageId: String, status: DeliveryStatus) { + messageManager.updateMessageDeliveryStatus(messageId, status) + // The headless state may not yet contain a just-sent UI message. Update the process store + // unconditionally so a delivery/read receipt can never be lost during Activity handoff. + AppStateStore.updatePrivateMessageStatus(messageId, status) + } + + private fun refreshBlockLists() { + dataManager.loadBlockedUsers() + dataManager.loadGeohashBlockedUsers() + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt index 3931ea72..a895f612 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt @@ -23,28 +23,16 @@ import kotlin.random.asKotlinRandom /** * Process-owned Nostr connectivity and low-volume background subscriptions. * - * Stable subscription handlers delegate to the most recently attached UI-independent handlers. - * This avoids relay subscriptions retaining a cleared ViewModel while keeping DMs and the selected - * geohash channel alive for the lifetime of the foreground-service process. + * Stable subscriptions dispatch directly to a process-owned event processor. The UI hydrates from + * the process state store, so relay events remain useful without retaining a cleared ViewModel. */ object NostrBackgroundRuntime { private const val TAG = "NostrBackground" - private const val MAX_PENDING_EVENTS = 256 - - data class Handlers( - val accountDm: (NostrEvent, NostrIdentity) -> Unit, - val geohashMessage: (NostrEvent, String) -> Unit, - val geohashDm: (NostrEvent, String, NostrIdentity) -> Unit - ) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - internal val eventScope: CoroutineScope - get() = scope private val random = SecureRandom().asKotlinRandom() private val lock = Any() - private val pendingEvents = ArrayDeque<(Handlers) -> Unit>() - @Volatile private var handlers: Handlers? = null @Volatile private var initialized = false @Volatile private var activeGeohash: String? = null @Volatile private var activeGeohashLiveToken: Long? = null @@ -52,17 +40,20 @@ object NostrBackgroundRuntime { private lateinit var application: Application private lateinit var subscriptions: NostrSubscriptionManager private lateinit var locationChannels: LocationChannelManager + private lateinit var eventProcessor: NostrBackgroundEventProcessor fun initialize(app: Application) { synchronized(lock) { if (initialized) return - initialized = true application = app locationChannels = LocationChannelManager.getInstance(app) + eventProcessor = NostrBackgroundEventProcessor(app, scope) subscriptions = NostrSubscriptionManager( app, owner = NostrRelayManager.OWNER_BACKGROUND ) + // Publish readiness only after every process-owned dependency is available. + initialized = true } subscriptions.connect() @@ -71,14 +62,6 @@ object NostrBackgroundRuntime { startPresenceScheduler() } - fun attachHandlers(newHandlers: Handlers) { - handlers = newHandlers - val pending = synchronized(lock) { - pendingEvents.toList().also { pendingEvents.clear() } - } - pending.forEach { event -> runCatching { event(newHandlers) } } - } - fun resetSubscriptions() { if (!initialized) return subscriptions.unsubscribeAllOwned() @@ -116,7 +99,7 @@ object NostrBackgroundRuntime { sinceMs = System.currentTimeMillis() - 172_800_000L, id = "chat-messages", handler = { event -> - dispatch { it.accountDm(event, identity) } + eventProcessor.onAccountDm(event, identity) } ) } @@ -164,7 +147,7 @@ object NostrBackgroundRuntime { sinceMs = System.currentTimeMillis() - 3_600_000L, limit = 200, id = "geohash-$geohash", - handler = { event -> dispatch { it.geohashMessage(event, geohash) } }, + handler = { event -> eventProcessor.onGeohashMessage(event, geohash) }, liveLocationToken = liveLocationToken ) subscribeGeohashDm(geohash, "geo-dm-$geohash", liveLocationToken) @@ -183,7 +166,7 @@ object NostrBackgroundRuntime { sinceMs = System.currentTimeMillis() - 172_800_000L, id = subscriptionId, handler = { event -> - dispatch { it.geohashDm(event, geohash, identity) } + eventProcessor.onGeohashDm(event, geohash, identity) }, liveLocationToken = liveLocationToken ) @@ -271,15 +254,19 @@ object NostrBackgroundRuntime { } } - private fun dispatch(event: (Handlers) -> Unit) { - val current = handlers - if (current != null) { - event(current) - return - } - synchronized(lock) { - if (pendingEvents.size >= MAX_PENDING_EVENTS) pendingEvents.removeFirst() - pendingEvents.addLast(event) - } + fun conversationGeohash(conversationKey: String): String? = + if (initialized) eventProcessor.conversationGeohash(conversationKey) + else GeohashConversationRegistry.get(conversationKey) + + fun displayNameForNostrPubkey(pubkeyHex: String): String? = + if (initialized) eventProcessor.displayNameForNostrPubkey(pubkeyHex) else null + + fun displayNameForGeohashConversation( + pubkeyHex: String, + sourceGeohash: String + ): String? = if (initialized) { + eventProcessor.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + } else { + null } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 7a49057d..ac830096 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -15,7 +15,6 @@ import com.bitchat.android.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver import com.bitchat.android.services.SeenMessageStore import com.bitchat.android.ui.ChatState -import com.bitchat.android.ui.MeshDelegateHandler import com.bitchat.android.ui.PrivateChatManager import com.bitchat.android.ui.PrivateMessageOrigin import kotlinx.coroutines.CoroutineScope @@ -28,7 +27,7 @@ class NostrDirectMessageHandler( private val application: Application, private val state: ChatState, private val privateChatManager: PrivateChatManager, - private val meshDelegateHandler: MeshDelegateHandler, + private val updateDeliveryStatus: (String, DeliveryStatus) -> Unit, private val scope: CoroutineScope, private val repo: GeohashRepository, private val dataManager: com.bitchat.android.ui.DataManager @@ -54,7 +53,7 @@ class NostrDirectMessageHandler( } fun onGiftWrap(giftWrap: NostrEvent, geohash: String, identity: NostrIdentity) { - scope.launch(Dispatchers.Default) { + scope.launch { try { if (dedupe(giftWrap.id)) return@launch @@ -178,13 +177,19 @@ class NostrDirectMessageHandler( NoisePayloadType.DELIVERED -> { val messageId = String(payload.data, Charsets.UTF_8) withContext(Dispatchers.Main) { - meshDelegateHandler.didReceiveDeliveryAck(messageId, conversationID) + updateDeliveryStatus( + messageId, + DeliveryStatus.Delivered(conversationID, Date()) + ) } } NoisePayloadType.READ_RECEIPT -> { val messageId = String(payload.data, Charsets.UTF_8) withContext(Dispatchers.Main) { - meshDelegateHandler.didReceiveReadReceipt(messageId, conversationID) + updateDeliveryStatus( + messageId, + DeliveryStatus.Read(conversationID, Date()) + ) } } NoisePayloadType.FILE_TRANSFER -> { diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index c88e1bc5..2007abf6 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -31,6 +31,12 @@ object AppStateStore { private val _privateMessages = MutableStateFlow>>(emptyMap()) val privateMessages: StateFlow>> = _privateMessages.asStateFlow() + private val _nickname = MutableStateFlow("") + val nickname: StateFlow = _nickname.asStateFlow() + + private val _selectedPrivateChatPeer = MutableStateFlow(null) + val selectedPrivateChatPeer: StateFlow = _selectedPrivateChatPeer.asStateFlow() + // Channel messages by channel name private val _channelMessages = MutableStateFlow>>(emptyMap()) val channelMessages: StateFlow>> = _channelMessages.asStateFlow() @@ -41,6 +47,14 @@ object AppStateStore { } } + fun setNickname(nickname: String) { + _nickname.value = nickname + } + + fun setSelectedPrivateChatPeer(peerID: String?) { + _selectedPrivateChatPeer.value = peerID + } + fun setTransportPeers(transportId: String, ids: List) { synchronized(this) { peerIdsByTransport[transportId] = ids.toSet() @@ -221,6 +235,8 @@ object AppStateStore { _publicMessages.value = emptyList() _privateMessages.value = emptyMap() _channelMessages.value = emptyMap() + _nickname.value = "" + _selectedPrivateChatPeer.value = null } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index 53a298ef..40593970 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -211,6 +211,7 @@ class ChatState( fun setNickname(nickname: String) { _nickname.value = nickname + com.bitchat.android.services.AppStateStore.setNickname(nickname) } fun setIsConnected(connected: Boolean) { @@ -223,6 +224,7 @@ class ChatState( fun setSelectedPrivateChatPeer(peerID: String?) { _selectedPrivateChatPeer.value = peerID + com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(peerID) } fun setUnreadPrivateMessages(unread: Set) { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 72507d8b..efdab660 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -154,8 +154,6 @@ class ChatViewModel( application = application, state = state, messageManager = messageManager, - privateChatManager = privateChatManager, - meshDelegateHandler = meshDelegateHandler, dataManager = dataManager, notificationManager = notificationManager ) @@ -341,6 +339,7 @@ class ChatViewModel( override fun onCleared() { geohashViewModel.shutdownUiSubscriptions() + com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(null) super.onCleared() // Note: Mesh service lifecycle is now managed by MainActivity } diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt index 4f37d47a..8517ec15 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -198,8 +198,10 @@ class DataManager(private val context: Context) { // MARK: - Blocked Users Management + @Synchronized fun loadBlockedUsers() { val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet() + _blockedUsers.clear() _blockedUsers.addAll(savedBlockedUsers) } @@ -217,6 +219,7 @@ class DataManager(private val context: Context) { saveBlockedUsers() } + @Synchronized fun isUserBlocked(fingerprint: String): Boolean { return _blockedUsers.contains(fingerprint) } @@ -226,8 +229,10 @@ class DataManager(private val context: Context) { private val _geohashBlockedUsers = mutableSetOf() // Set of nostr pubkey hex val geohashBlockedUsers: Set get() = _geohashBlockedUsers.toSet() + @Synchronized fun loadGeohashBlockedUsers() { val savedGeohashBlockedUsers = prefs.getStringSet("geohash_blocked_users", emptySet()) ?: emptySet() + _geohashBlockedUsers.clear() _geohashBlockedUsers.addAll(savedGeohashBlockedUsers) } @@ -245,6 +250,7 @@ class DataManager(private val context: Context) { saveGeohashBlockedUsers() } + @Synchronized fun isGeohashUserBlocked(pubkeyHex: String): Boolean { return _geohashBlockedUsers.contains(pubkeyHex) } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 46504649..d2b706ec 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -13,7 +13,6 @@ import com.bitchat.android.geohash.LiveLocationPrivacyGate import com.bitchat.android.nostr.GeohashMessageHandler import com.bitchat.android.nostr.GeohashRepository import com.bitchat.android.nostr.NostrBackgroundRuntime -import com.bitchat.android.nostr.NostrDirectMessageHandler import com.bitchat.android.nostr.NostrIdentityBridge import com.bitchat.android.nostr.NostrProtocol import com.bitchat.android.nostr.NostrRelayManager @@ -33,8 +32,6 @@ class GeohashViewModel( application: Application, private val state: ChatState, private val messageManager: MessageManager, - private val privateChatManager: PrivateChatManager, - private val meshDelegateHandler: MeshDelegateHandler, private val dataManager: DataManager, private val notificationManager: NotificationManager ) : AndroidViewModel(application), DefaultLifecycleObserver { @@ -49,20 +46,10 @@ class GeohashViewModel( ) private val geohashMessageHandler = GeohashMessageHandler( application = application, - state = state, - messageManager = messageManager, repo = repo, - scope = NostrBackgroundRuntime.eventScope, - dataManager = dataManager - ) - private val dmHandler = NostrDirectMessageHandler( - application = application, - state = state, - privateChatManager = privateChatManager, - meshDelegateHandler = meshDelegateHandler, - scope = NostrBackgroundRuntime.eventScope, - repo = repo, - dataManager = dataManager + scope = viewModelScope, + dataManager = dataManager, + addChannelMessage = messageManager::addChannelMessage ) // Presence heartbeat firehose (kind 20001). High-volume; paused while backgrounded. @@ -103,19 +90,6 @@ class GeohashViewModel( } try { locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) - NostrBackgroundRuntime.attachHandlers( - NostrBackgroundRuntime.Handlers( - accountDm = { event, identity -> - dmHandler.onGiftWrap(event, "", identity) - }, - geohashMessage = { event, geohash -> - geohashMessageHandler.onEvent(event, geohash) - }, - geohashDm = { event, geohash, identity -> - dmHandler.onGiftWrap(event, geohash, identity) - } - ) - ) viewModelScope.launch { locationChannelManager?.selectedChannel?.collect { channel -> state.setSelectedLocationChannel(channel) @@ -329,14 +303,29 @@ class GeohashViewModel( } fun ensureGeohashDMSubscriptionForConversation(conversationKey: String) { - val geohash = repo.getConversationGeohash(conversationKey) ?: return + val geohash = repo.getConversationGeohash(conversationKey) + ?: NostrBackgroundRuntime.conversationGeohash(conversationKey) + ?: return NostrBackgroundRuntime.ensureConversationDm(geohash) } - fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex) - fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + fun displayNameForNostrPubkeyUI(pubkeyHex: String): String { + val foregroundName = repo.displayNameForNostrPubkeyUI(pubkeyHex) + return foregroundName.takeUnless { it == "anon" } + ?: NostrBackgroundRuntime.displayNameForNostrPubkey(pubkeyHex) + ?: foregroundName + } + + fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String { + val foregroundName = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + return foregroundName.takeUnless { it == "anon" } + ?: NostrBackgroundRuntime.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + ?: foregroundName + } + fun conversationGeohash(conversationKey: String): String? = repo.getConversationGeohash(conversationKey) + ?: NostrBackgroundRuntime.conversationGeohash(conversationKey) fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity = PeerIdentity.nostr(pubkeyHex) diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index 1af48a04..61102664 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -33,7 +33,8 @@ class PrivateChatManager( private val state: ChatState, private val messageManager: MessageManager, private val dataManager: DataManager, - private val noiseSessionDelegate: NoiseSessionDelegate + private val noiseSessionDelegate: NoiseSessionDelegate, + private val trackUnreadMessages: Boolean = true ) { companion object { @@ -337,7 +338,7 @@ class PrivateChatManager( // Nostr messages originate here and must be added explicitly, even after their // sender alias has canonicalized to a contact_* conversation ID. if (origin == PrivateMessageOrigin.NOSTR) { - if (suppressUnread) { + if (suppressUnread || !trackUnreadMessages) { messageManager.addPrivateMessageNoUnread(conversationID, message) } else { messageManager.addPrivateMessage(conversationID, message) @@ -345,7 +346,10 @@ class PrivateChatManager( } // Track as unread for read receipt purposes if not focused - if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != conversationID) { + if (trackUnreadMessages && + !suppressUnread && + state.getSelectedPrivateChatPeerValue() != conversationID + ) { val unreadList = unreadReceivedMessages.getOrPut(conversationID) { mutableListOf() } unreadList.add(message) Log.d(TAG, "Queued unread from $conversationID (count=${unreadList.size})") diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt new file mode 100644 index 00000000..d7fffc2c --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt @@ -0,0 +1,65 @@ +package com.bitchat.android.nostr + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.services.AppStateStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class NostrBackgroundEventProcessorTest { + private lateinit var scope: CoroutineScope + + @Before + fun setUp() { + AppStateStore.clear() + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + } + + @After + fun tearDown() { + scope.cancel() + AppStateStore.clear() + } + + @Test + fun `cold start processes more events than the removed handoff queue capacity`() = runBlocking { + val application = ApplicationProvider.getApplicationContext() + val processor = NostrBackgroundEventProcessor(application, scope) + + repeat(300) { index -> + processor.onGeohashMessage( + event = NostrEvent( + id = "cold-start-$index", + pubkey = index.toString(16).padStart(64, '0'), + createdAt = 1, + kind = NostrKind.EPHEMERAL_EVENT, + tags = listOf(listOf("g", "u4pruy")), + content = "message-$index" + ), + geohash = "u4pruy" + ) + } + + withTimeout(5_000) { + while (AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().size < 300) { + kotlinx.coroutines.yield() + } + } + + assertEquals( + (0 until 300).map { "cold-start-$it" }.toSet(), + AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().map { it.id }.toSet() + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt index 852411e8..e8d1f19e 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -67,6 +68,34 @@ class PrivateChatManagerTest { assertEquals(listOf(message), state.getPrivateChatsValue()[conversationID]) } + @Test + fun `headless Nostr processing stores messages without retaining UI unread work`() { + val headlessManager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = DataManager(RuntimeEnvironment.getApplication()), + noiseSessionDelegate = mock(), + trackUnreadMessages = false + ) + val message = BitchatMessage( + id = "background-nostr-message", + sender = "alice", + content = "background", + timestamp = Date(1), + isPrivate = true, + senderPeerID = "nostr_background" + ) + + headlessManager.handleIncomingPrivateMessage( + message = message, + suppressUnread = false, + origin = PrivateMessageOrigin.NOSTR + ) + + assertEquals(listOf(message), AppStateStore.privateMessages.value["nostr_background"]) + assertTrue(state.getUnreadPrivateMessagesValue().isEmpty()) + } + @Test fun `canonical conversation sends read receipt through live mesh peer id`() { val noiseKey = ByteArray(32) { 9 } From fa63a98e7716a58cca20b65de9bb60ecf3d451a2 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:28:05 +0200 Subject: [PATCH 44/70] Update Nostr handler regression test --- .../com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt index 738b3231..2ddffff1 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt @@ -5,7 +5,6 @@ import com.bitchat.android.services.AppStateStore import com.bitchat.android.services.SeenMessageStore import com.bitchat.android.ui.ChatState import com.bitchat.android.ui.DataManager -import com.bitchat.android.ui.MeshDelegateHandler import com.bitchat.android.ui.MessageManager import com.bitchat.android.ui.NoiseSessionDelegate import com.bitchat.android.ui.PrivateChatManager @@ -72,7 +71,7 @@ class NostrDirectMessageHandlerTest { application = application, state = state, privateChatManager = privateChatManager, - meshDelegateHandler = mock(), + updateDeliveryStatus = { _, _ -> }, scope = scope, repo = GeohashRepository(application, state, dataManager), dataManager = dataManager, From 6dff0844bbc6971175d1401f286708c7360a2924 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:44:49 +0200 Subject: [PATCH 45/70] revert noise --- .../mesh/AuthenticatedBleLinkPolicy.kt | 14 -- .../mesh/BluetoothConnectionManager.kt | 4 +- .../mesh/BluetoothConnectionTracker.kt | 26 +-- .../android/mesh/BluetoothMeshService.kt | 97 +++------- .../java/com/bitchat/android/mesh/MeshCore.kt | 52 +----- .../bitchat/android/mesh/SecurityManager.kt | 2 +- .../com/bitchat/android/ui/ChatViewModel.kt | 7 +- .../AuthenticatedIngressLinkPolicy.kt | 39 ---- .../wifi-aware/WifiAwareConnectionTracker.kt | 6 +- .../wifi-aware/WifiAwareMeshService.kt | 170 ++++++------------ .../mesh/AuthenticatedBleLinkPolicyTest.kt | 40 ----- ...etoothConnectionTrackerLinkIdentityTest.kt | 53 ------ .../android/ui/PrivateChatManagerTest.kt | 27 +++ .../AuthenticatedIngressLinkPolicyTest.kt | 93 ---------- .../WifiAwareConnectionTrackerTest.kt | 10 +- 15 files changed, 143 insertions(+), 497 deletions(-) delete mode 100644 app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt delete mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt delete mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt delete mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt delete mode 100644 app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt deleted file mode 100644 index f2aa1db2..00000000 --- a/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.bitchat.android.mesh - -/** - * Ensures a Noise completion promotes only the BLE connection whose ANNOUNCE started that - * authentication attempt. - */ -internal object AuthenticatedBleLinkPolicy { - data class Claim(val deviceAddress: String, val linkID: String) - - fun matches(claim: Claim?, authenticatedAddress: String?, authenticatedLinkID: String?): Boolean = - claim != null && - claim.deviceAddress == authenticatedAddress && - claim.linkID == authenticatedLinkID -} diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 17e4ce28..3a75188c 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -92,8 +92,8 @@ class BluetoothConnectionManager( // Public property for address-peer mapping val addressPeerMap get() = connectionTracker.addressPeerMap - fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean = - connectionTracker.bindPeerIfCurrent(deviceAddress, linkID, peerID) + fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean = + connectionTracker.observePeerIfCurrent(deviceAddress, linkID, peerID) fun getCurrentLinkID(deviceAddress: String): String? = connectionTracker.getCurrentLinkID(deviceAddress) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt index f37d8574..94feb68d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -32,7 +32,7 @@ class BluetoothConnectionTracker( private val firstAnnounceSeen = ConcurrentHashMap() // RSSI tracking from scan results (for devices we discover but may connect as servers) private val scanRSSI = ConcurrentHashMap() - private val peerBindingLock = Any() + private val connectionStateLock = Any() /** * Consolidated device connection information @@ -77,9 +77,9 @@ class BluetoothConnectionTracker( */ fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) { Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}") - synchronized(peerBindingLock) { + synchronized(connectionStateLock) { connectedDevices[deviceAddress] = deviceConn - // A mapping authenticates a GATT connection, not a reusable Bluetooth address. + // A route observation belongs to this GATT generation, not its reusable address. addressPeerMap.remove(deviceAddress) } removePendingConnection(deviceAddress) @@ -91,7 +91,7 @@ class BluetoothConnectionTracker( * Update a device connection */ fun updateDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) { - synchronized(peerBindingLock) { + synchronized(connectionStateLock) { connectedDevices[deviceAddress] = deviceConn } } @@ -100,7 +100,7 @@ class BluetoothConnectionTracker( deviceAddress: String, linkID: String, update: (DeviceConnection) -> DeviceConnection - ): Boolean = synchronized(peerBindingLock) { + ): Boolean = synchronized(connectionStateLock) { val current = connectedDevices[deviceAddress] ?: return@synchronized false if (current.linkID != linkID) return@synchronized false connectedDevices[deviceAddress] = update(current) @@ -117,10 +117,16 @@ class BluetoothConnectionTracker( fun getCurrentLinkID(deviceAddress: String): String? = connectedDevices[deviceAddress]?.linkID - fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean = - synchronized(peerBindingLock) { + /** + * Records that the current link delivered a validated, non-relayed ANNOUNCE for [peerID]. + * + * A peer may be reachable over more than one link, so observing one link must not discard the + * other observations. The link generation check prevents a late packet from an old GATT + * connection from being applied to a replacement connection that reused the same address. + */ + fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean = + synchronized(connectionStateLock) { if (connectedDevices[deviceAddress]?.linkID != linkID) return@synchronized false - addressPeerMap.entries.removeIf { it.value == peerID && it.key != deviceAddress } addressPeerMap[deviceAddress] = peerID true } @@ -265,7 +271,7 @@ class BluetoothConnectionTracker( * Clean up a specific device connection */ fun cleanupDeviceConnection(deviceAddress: String) { - synchronized(peerBindingLock) { + synchronized(connectionStateLock) { connectedDevices.remove(deviceAddress) subscribedDevices.removeAll { it.address == deviceAddress } addressPeerMap.remove(deviceAddress) @@ -277,7 +283,7 @@ class BluetoothConnectionTracker( fun cleanupDeviceConnectionIfCurrent( deviceAddress: String, expectedLinkID: String - ): Boolean = synchronized(peerBindingLock) { + ): Boolean = synchronized(connectionStateLock) { val current = connectedDevices[deviceAddress] ?: return@synchronized false if (current.linkID != expectedLinkID) { return@synchronized false diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 55cdb6df..432d9a1e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -21,7 +21,6 @@ import com.bitchat.android.services.VerificationService import com.bitchat.android.service.TransportBridgeService import kotlinx.coroutines.* import java.util.* -import java.util.concurrent.ConcurrentHashMap import kotlin.math.sign import kotlin.random.Random @@ -43,7 +42,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic companion object { private const val TAG = "BluetoothMeshService" - private const val BLE_AUTHENTICATION_TIMEOUT_MS = 20_000L private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS } @@ -129,8 +127,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic private var announceJob: Job? = null // Tracks whether this instance has been terminated via stopServices() private var terminated = false - private val provisionalBleClaims = - ConcurrentHashMap() init { Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID") @@ -253,7 +249,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic delegate?.didUpdatePeerList(peerIDs) } override fun onPeerRemoved(peerID: String) { - provisionalBleClaims.remove(peerID) authenticatedPeerState.clear(peerID) try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } // Remove from mesh graph topology to prevent routing through stale peers @@ -282,22 +277,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic authenticatedRemoteStaticKey, authenticatedSessionToken ) - val expectedClaim = provisionalBleClaims.remove(peerID) - if (AuthenticatedBleLinkPolicy.matches(expectedClaim, directRelayAddress, ingressLinkID)) { - val authenticatedClaim = checkNotNull(expectedClaim) - if (connectionManager.bindPeerIfCurrent( - authenticatedClaim.deviceAddress, - authenticatedClaim.linkID, - peerID - ) - ) { - Log.i(TAG, "Authenticated BLE link $directRelayAddress as $peerID") - try { peerManager.refreshPeerList() } catch (_: Exception) { } - try { gossipSyncManager.scheduleInitialSyncToPeer(peerID, 1_000) } catch (_: Exception) { } - } else { - Log.w(TAG, "Ignoring Noise completion for stale BLE link $directRelayAddress") - } - } // Send announcement and cached messages after key exchange serviceScope.launch { delay(100) @@ -572,40 +551,23 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic val result = messageHandler.handleAnnounceWithResult(routed) if (result !is AnnounceHandlingResult.Accepted) return false - val deviceAddress = routed.relayAddress - val pid = routed.peerID - val linkID = routed.ingressLinkID - val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS - val alreadyAuthenticated = deviceAddress != null && - pid != null && - connectionManager.addressPeerMap[deviceAddress] == pid - if (deviceAddress != null && linkID != null && pid != null && isDirect && !alreadyAuthenticated) { - try { - val claim = AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID) - registerProvisionalBleClaim(pid, claim) - val handshakeData = encryptionService.initiateHandshake(pid, replaceEstablished = true) - if (handshakeData != null) { - val handshake = signPacketBeforeBroadcast( - BitchatPacket( - version = 1u, - type = MessageType.NOISE_HANDSHAKE.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(pid), - timestamp = System.currentTimeMillis().toULong(), - payload = handshakeData, - ttl = MAX_TTL - ) - ) - if (!connectionManager.sendPacketToLink(deviceAddress, linkID, handshake)) { - provisionalBleClaims.remove(pid, claim) - Log.w(TAG, "Could not send Noise handshake on BLE link $deviceAddress") - } - } else { - provisionalBleClaims.remove(pid, claim) - } - } catch (e: Exception) { - provisionalBleClaims.remove(pid, AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID)) - Log.w(TAG, "Could not authenticate provisional BLE claim for $pid: ${e.message}") + DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)?.let { observation -> + if (connectionManager.observePeerIfCurrent( + observation.relayAddress, + observation.ingressLinkID, + observation.peerID + ) + ) { + Log.d( + TAG, + "Observed direct BLE route ${observation.relayAddress} to ${observation.peerID}" + ) + try { peerManager.refreshPeerList() } catch (_: Exception) { } + try { + gossipSyncManager.scheduleInitialSyncToPeer(observation.peerID, 1_000) + } catch (_: Exception) { } + } else { + Log.d(TAG, "Ignoring ANNOUNCE from stale BLE link ${observation.relayAddress}") } } try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } @@ -710,13 +672,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic linkID: String? ) { Log.i(TAG, "Device disconnected: ${device.address}") - val addr = device.address - clearProvisionalBleClaimsForLink(addr, linkID) // refresh peer list on disconnect. try { peerManager.refreshPeerList() } catch (_: Exception) { } - // ConnectionTracker already removes an authenticated mapping only when this exact + // ConnectionTracker already removes an observed mapping only when this exact // link is still current. Do not remove by reusable address here: this may be a late // disconnect callback from a replaced GATT connection. } @@ -730,24 +690,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } } - private fun registerProvisionalBleClaim( - peerID: String, - claim: AuthenticatedBleLinkPolicy.Claim - ) { - provisionalBleClaims[peerID] = claim - serviceScope.launch { - delay(BLE_AUTHENTICATION_TIMEOUT_MS) - provisionalBleClaims.remove(peerID, claim) - } - } - - private fun clearProvisionalBleClaimsForLink(deviceAddress: String, linkID: String?) { - if (linkID == null) return - provisionalBleClaims.entries.removeIf { (_, claim) -> - claim.deviceAddress == deviceAddress && claim.linkID == linkID - } - } - /** * Start the mesh service */ @@ -1023,7 +965,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic */ fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { if (content.isEmpty() || recipientPeerID.isEmpty()) return - if (recipientNickname.isEmpty()) return + // Nicknames are presentation metadata. Routing and encryption are bound to the peer ID, + // so a temporarily unresolved nickname must never suppress a private message. serviceScope.launch { val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString() diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 375e6531..2387e939 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -44,7 +44,6 @@ class MeshCore( data class Hooks( val onMessageReceived: ((BitchatMessage) -> Unit)? = null, val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null, - val onDirectNoiseAuthenticated: ((String, String, String, ByteArray) -> Unit)? = null, val readReceiptInterceptor: ((String, String) -> Boolean)? = null, val onReadReceiptSent: ((String) -> Unit)? = null, val announcementNicknameProvider: (() -> String?)? = null, @@ -156,12 +155,14 @@ class MeshCore( isActive = false announceJob?.cancel() announceJob = null + directPeers.clear() if (ownsGossipManager) { gossipSyncManager.stop() } } fun shutdown() { + directPeers.clear() peerManager.shutdown() fragmentManager.shutdown() securityManager.shutdown() @@ -215,6 +216,7 @@ class MeshCore( } override fun onPeerRemoved(peerID: String) { + directPeers.remove(peerID) authenticatedPeerState.clear(peerID) try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } try { encryptionService.removePeer(peerID) } catch (_: Exception) { } @@ -235,14 +237,6 @@ class MeshCore( authenticatedRemoteStaticKey, authenticatedSessionToken ) - if (directRelayAddress != null && ingressLinkID != null) { - hooks.onDirectNoiseAuthenticated?.invoke( - peerID, - directRelayAddress, - ingressLinkID, - authenticatedRemoteStaticKey - ) - } scope.launch { delay(100) sendAnnouncementToPeer(peerID) @@ -845,6 +839,7 @@ class MeshCore( } fun removePeer(peerID: String) { + directPeers.remove(peerID) peerManager.removePeer(peerID) } @@ -898,44 +893,6 @@ class MeshCore( } } - /** - * Starts a fresh replacement handshake on one exact direct transport generation. - * This authenticates provisional transport claims without broadcasting the challenge or - * accidentally sending it through a socket that later reused the same alias. - */ - fun initiateNoiseHandshakeOnLink( - peerID: String, - relayAddress: String, - ingressLinkID: String - ): Boolean { - return try { - val handshakeData = encryptionService.initiateHandshake( - peerID, - replaceEstablished = true - ) ?: return false - val packet = BitchatPacket( - version = 1u, - type = MessageType.NOISE_HANDSHAKE.value, - senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), - recipientID = MeshPacketUtils.hexStringToByteArray(peerID), - timestamp = System.currentTimeMillis().toULong(), - payload = handshakeData, - ttl = maxTtl - ) - transport.sendPacketToLink( - relayAddress, - ingressLinkID, - signPacketBeforeBroadcast(packet) - ) - } catch (e: Exception) { - Log.e( - "MeshCore", - "Failed to initiate link-bound Noise handshake with $peerID: ${e.message}" - ) - false - } - } - fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID) fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) @@ -989,6 +946,7 @@ class MeshCore( } fun clearAllInternalData() { + directPeers.clear() fragmentManager.clearAllFragments() storeForwardManager.clearAllCache() securityManager.clearAllData() diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 5922ff5e..66698049 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -76,7 +76,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private if (processedMessages.contains(messageID)) { // Check for ANNOUNCE exception: allow if it looks like a direct neighbor (max TTL) - // This ensures we catch the "first announce" on a new connection for binding, + // This ensures we observe the same peer on a new direct transport connection, // while still dropping looped/relayed duplicates. val isFreshAnnounce = messageType == MessageType.ANNOUNCE && packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 3017b20c..d0358a78 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -664,8 +664,11 @@ class ChatViewModel( } private fun nicknameForPeer(peerID: String): String? { - return state.peerNicknames.value[peerID] - ?: try { mesh.getPeerNicknames()[peerID] } catch (_: Exception) { null } + val contact = ContactDirectory.resolve(peerID) + val meshPeerID = contact.meshPeerID ?: peerID + return contact.displayName + ?: state.peerNicknames.value[meshPeerID] + ?: try { mesh.getPeerNicknames()[meshPeerID] } catch (_: Exception) { null } } private fun sessionStateForPeer(peerID: String): NoiseSession.NoiseSessionState { diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt b/app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt deleted file mode 100644 index d522c812..00000000 --- a/app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.bitchat.android.wifiaware - -/** - * Resolves an authenticated callback to the exact still-active ingress link that completed Noise. - * A relay/discovery ID alone is not sufficient because a replacement socket may reuse it. - */ -internal object AuthenticatedIngressLinkPolicy { - data class Claim( - val relayAddress: String, - val linkID: String - ) - - data class Link( - val relayAddress: String, - val transport: T - ) - - fun matches( - expected: Claim?, - authenticatedRelayAddress: String?, - authenticatedLinkID: String? - ): Boolean = - expected != null && - expected.relayAddress == authenticatedRelayAddress && - expected.linkID == authenticatedLinkID - - fun resolve( - authenticatedLinkID: String?, - authenticatedRelayAddress: String?, - links: Map>, - currentTransportForRelay: (String) -> T? - ): Link? { - val linkID = authenticatedLinkID ?: return null - val relayAddress = authenticatedRelayAddress ?: return null - val link = links[linkID] ?: return null - if (link.relayAddress != relayAddress) return null - return link.takeIf { currentTransportForRelay(relayAddress) === it.transport } - } -} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt index 9376b078..2b5ab61f 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt @@ -100,9 +100,9 @@ class WifiAwareConnectionTracker( } /** - * Atomically require that [expectedSocket] is still the active provisional transport and, only - * then, promote it. This closes the gap where a replacement socket could land after validation - * but before mutation and the stale authenticated socket would become canonical. + * Atomically require that [expectedSocket] is still the active provisional transport before + * rebinding it. This closes the gap where a replacement socket could land after ANNOUNCE + * validation but before mutation and the stale socket would become canonical. */ fun rebindPeerIdIfCurrent( previousPeerId: String, diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index d6cbf981..c5014456 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -13,6 +13,7 @@ import android.util.Log import androidx.annotation.RequiresApi import androidx.annotation.RequiresPermission import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.mesh.DirectLinkAnnouncementPolicy import com.bitchat.android.mesh.FragmentingPacketSender import com.bitchat.android.mesh.MeshCore import com.bitchat.android.mesh.MeshService @@ -75,7 +76,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor private const val CLIENT_SOCKET_RETRY_DELAY_MS = 750L private const val CLIENT_SOCKET_ATTEMPTS = 3 private const val CLIENT_ROLE_REVERSAL_FAILURES = 3 - private const val WIFI_AUTHENTICATION_TIMEOUT_MS = 30_000L // Discovery freshness window for reconnection maintenance private const val DISCOVERY_STALE_MS = 5L * 60 * 1000 private const val DISCOVERY_IDLE_REFRESH_MS = 2L * 60 * 1000 @@ -125,12 +125,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm) private val ingressLinks = ConcurrentHashMap< String, - AuthenticatedIngressLinkPolicy.Link + IngressLinkPolicy.Link >() - private val provisionalWifiClaims = - ConcurrentHashMap() - private val authenticatedWifiLinks = - ConcurrentHashMap() private val handleToPeerId = ConcurrentHashMap() // discovery mapping private val discoveredTimestamps = ConcurrentHashMap() // peerID -> last seen time // Subscribe-session-scoped handles only. PeerHandles are session-scoped, so a handle obtained @@ -176,38 +172,13 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor onMessageReceived = { message -> handleMessageReceived(message) }, onAnnounceProcessed = { routed, _ -> routed.peerID?.let { pid -> - try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } - - // Discovery IDs from older clients can be provisional. A verified direct - // announce is enough to start a handshake for the canonical ID, but not to - // rebind the socket. A fresh challenge is sent through the exact transport - // generation, and only its same-link completion may promote that alias. - val relay = routed.relayAddress - val linkID = routed.ingressLinkID - if ( - routed.packet.ttl == MAX_TTL && - relay != null && - linkID != null - ) { - val claim = AuthenticatedIngressLinkPolicy.Claim(relay, linkID) - if (!AuthenticatedIngressLinkPolicy.matches( - authenticatedWifiLinks[pid], - relay, - linkID - ) - ) { - registerProvisionalWifiClaim(pid, claim) - if (!meshCore.initiateNoiseHandshakeOnLink(pid, relay, linkID)) { - provisionalWifiClaims.remove(pid, claim) - Log.w(TAG, "Could not send Noise challenge on Wi-Fi link for ${pid.take(8)}") - } - } - } + DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL) + ?.let(::observeDirectIngressLink) + try { + meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) + } catch (_: Exception) { } } }, - onDirectNoiseAuthenticated = { peerID, relayAddress, ingressLinkID, _ -> - promoteAuthenticatedIngressLink(peerID, relayAddress, ingressLinkID) - }, announcementNicknameProvider = { try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { null } }, @@ -587,8 +558,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor publishHandles.clear() discoveredTimestamps.clear() ingressLinks.clear() - provisionalWifiClaims.clear() - authenticatedWifiLinks.clear() meshCore.shutdown() @@ -634,8 +603,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor publishHandles.clear() discoveredTimestamps.clear() ingressLinks.clear() - provisionalWifiClaims.clear() - authenticatedWifiLinks.clear() } } finally { recoveryInProgress = false @@ -901,7 +868,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor // presence makes hasOpenServerSocket() true for the life of the process) // and so we free the fd/port promptly. connectionTracker.closeServerSocket(peerId) - try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {} try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} listenerExec.execute { listenToPeer(synced, peerId) } handleSubscriberKeepAlive(synced, peerId, pubSession, peerHandle) @@ -1156,7 +1122,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor activeSocket = synced connectionTracker.onClientConnected(peerId, synced) clientSocketFailures.remove(peerId) - try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {} try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} listenerExec.execute { listenToPeer(synced, peerId) } handleServerKeepAlive(synced, peerId, peerHandle) @@ -1235,70 +1200,74 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor } /** - * Promote a provisional discovery alias only when the exact, still-active socket delivered the - * Noise frame that completed authentication for the canonical peer ID. + * Records a validated, non-relayed ANNOUNCE as a direct route. The exact-link check keeps stale + * socket readers from rebinding a replacement connection, but Noise remains peer-scoped and is + * not restarted or coupled to this routing observation. */ - private fun promoteAuthenticatedIngressLink( - canonicalPeerId: String, - relayAddress: String, - ingressLinkID: String + private fun observeDirectIngressLink( + observation: DirectLinkAnnouncementPolicy.Observation ) { - val expectedClaim = provisionalWifiClaims[canonicalPeerId] - if (!AuthenticatedIngressLinkPolicy.matches( - expectedClaim, - relayAddress, - ingressLinkID - ) - ) { - Log.w(TAG, "Ignoring unsolicited or cross-link Noise promotion for ${canonicalPeerId.take(8)}") - return - } - provisionalWifiClaims.remove(canonicalPeerId, expectedClaim) - - val link = AuthenticatedIngressLinkPolicy.resolve( - authenticatedLinkID = ingressLinkID, - authenticatedRelayAddress = relayAddress, + val link = IngressLinkPolicy.resolve( + ingressLinkID = observation.ingressLinkID, + relayAddress = observation.relayAddress, links = ingressLinks, currentTransportForRelay = connectionTracker::getSocketForPeer ) ?: run { - Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: ingress link is stale or mismatched") + Log.d( + TAG, + "Ignoring direct ANNOUNCE for ${observation.peerID.take(8)}: ingress link is stale" + ) return } val provisionalPeerId = link.relayAddress val existingCanonical = connectionTracker.canonicalPeerId(provisionalPeerId) - if (existingCanonical == canonicalPeerId) { - authenticatedWifiLinks[canonicalPeerId] = - AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID) - try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { } + if (existingCanonical == observation.peerID) { + try { meshCore.setDirectConnection(observation.peerID, true) } catch (_: Exception) { } return } if (existingCanonical != provisionalPeerId) { - Log.w(TAG, "Refusing authenticated Wi-Fi rebind ${existingCanonical.take(8)} -> ${canonicalPeerId.take(8)} on existing alias") + Log.w( + TAG, + "Refusing Wi-Fi route change ${existingCanonical.take(8)} -> ${observation.peerID.take(8)} on existing alias" + ) return } - if (!connectionTracker.rebindPeerIdIfCurrent(provisionalPeerId, canonicalPeerId, link.transport)) { - Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: provisional socket changed") + if (!connectionTracker.rebindPeerIdIfCurrent( + provisionalPeerId, + observation.peerID, + link.transport + ) + ) { + Log.d( + TAG, + "Ignoring direct ANNOUNCE for ${observation.peerID.take(8)}: provisional socket changed" + ) return } - authenticatedWifiLinks[canonicalPeerId] = - AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID) handleToPeerId.forEach { (handle, peerId) -> - if (peerId == provisionalPeerId) handleToPeerId[handle] = canonicalPeerId + if (peerId == provisionalPeerId) handleToPeerId[handle] = observation.peerID } - subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[canonicalPeerId] = it } - publishHandles.remove(provisionalPeerId)?.let { publishHandles[canonicalPeerId] = it } + subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[observation.peerID] = it } + publishHandles.remove(provisionalPeerId)?.let { publishHandles[observation.peerID] = it } val discoveredAt = discoveredTimestamps.remove(provisionalPeerId) ?: System.currentTimeMillis() - discoveredTimestamps[canonicalPeerId] = discoveredAt + discoveredTimestamps[observation.peerID] = discoveredAt try { meshCore.setDirectConnection(provisionalPeerId, false) } catch (_: Exception) { } try { meshCore.removePeer(provisionalPeerId) } catch (_: Exception) { } - try { meshCore.addOrUpdatePeer(canonicalPeerId, meshCore.getPeerNickname(canonicalPeerId) ?: canonicalPeerId) } catch (_: Exception) { } - try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { } - try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(canonicalPeerId, 1_000) } catch (_: Exception) { } + try { + meshCore.addOrUpdatePeer( + observation.peerID, + meshCore.getPeerNickname(observation.peerID) ?: observation.peerID + ) + } catch (_: Exception) { } + try { meshCore.setDirectConnection(observation.peerID, true) } catch (_: Exception) { } - Log.i(TAG, "Noise-authenticated Wi-Fi peer ${provisionalPeerId.take(8)} -> ${canonicalPeerId.take(8)}") + Log.i( + TAG, + "Observed direct Wi-Fi route ${provisionalPeerId.take(8)} -> ${observation.peerID.take(8)}" + ) } /** @@ -1311,7 +1280,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) { val logicalPeerId = initialLogicalPeerId val ingressLinkID = UUID.randomUUID().toString() - val ingressLink = AuthenticatedIngressLinkPolicy.Link(logicalPeerId, socket) + val ingressLink = IngressLinkPolicy.Link(logicalPeerId, socket) ingressLinks[ingressLinkID] = ingressLink while (isActive) { val raw = socket.read() ?: break @@ -1326,10 +1295,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue if (pkt.type == MessageType.ANNOUNCE.value && pkt.ttl >= MAX_TTL && senderPeerHex != logicalPeerId) { - // The socket's discovery identity remains provisional until Noise proves possession - // of the claimed static key on this link. A canonical self-signed announcement is - // only TOFU and cannot safely rebind/remove transport state on its own. - Log.d(TAG, "RX: deferred Wi-Fi peer rebind ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending Noise proof") + // Rebinding happens only after MeshCore validates and accepts this ANNOUNCE. + Log.d( + TAG, + "RX: Wi-Fi peer observation ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending ANNOUNCE validation" + ) } // Route the packet: @@ -1339,7 +1309,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor } ingressLinks.remove(ingressLinkID, ingressLink) - clearProvisionalWifiClaimsForLink(logicalPeerId, ingressLinkID) // Breaking out of the loop means the socket is dead or service is stopping. Log.i(TAG, "Disconnected from ${logicalPeerId.take(8)} (socket closed)") @@ -1347,27 +1316,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor socket.close() } - private fun registerProvisionalWifiClaim( - peerID: String, - claim: AuthenticatedIngressLinkPolicy.Claim - ) { - provisionalWifiClaims[peerID] = claim - serviceScope.launch { - delay(WIFI_AUTHENTICATION_TIMEOUT_MS) - if (provisionalWifiClaims.remove(peerID, claim)) { - Log.d(TAG, "Expired provisional Wi-Fi authentication claim for ${peerID.take(8)}") - } } - } - - private fun clearProvisionalWifiClaimsForLink(relayAddress: String, linkID: String) { - provisionalWifiClaims.entries.removeIf { (_, claim) -> - claim.relayAddress == relayAddress && claim.linkID == linkID - } - authenticatedWifiLinks.entries.removeIf { (_, claim) -> - claim.relayAddress == relayAddress && claim.linkID == linkID - } - } - private fun handleNetworkFailure(peerId: String) { serviceScope.launch { if (!connectionTracker.isConnected(peerId)) { @@ -1655,9 +1603,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor ingressLinkID: String, packet: BitchatPacket ): Boolean { - val link = AuthenticatedIngressLinkPolicy.resolve( - authenticatedLinkID = ingressLinkID, - authenticatedRelayAddress = relayAddress, + val link = IngressLinkPolicy.resolve( + ingressLinkID = ingressLinkID, + relayAddress = relayAddress, links = ingressLinks, currentTransportForRelay = connectionTracker::getSocketForPeer ) ?: return false diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt deleted file mode 100644 index 81ba5f7a..00000000 --- a/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.bitchat.android.mesh - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class AuthenticatedBleLinkPolicyTest { - private val claim = AuthenticatedBleLinkPolicy.Claim( - deviceAddress = "AA:BB:CC:DD:EE:FF", - linkID = "connection-a" - ) - - @Test - fun `accepts completion from exact claimed connection`() { - assertTrue( - AuthenticatedBleLinkPolicy.matches( - claim, - authenticatedAddress = claim.deviceAddress, - authenticatedLinkID = claim.linkID - ) - ) - } - - @Test - fun `rejects replacement connection reusing device address`() { - assertFalse( - AuthenticatedBleLinkPolicy.matches( - claim, - authenticatedAddress = claim.deviceAddress, - authenticatedLinkID = "connection-b" - ) - ) - } - - @Test - fun `rejects completion on another address or without a claim`() { - assertFalse(AuthenticatedBleLinkPolicy.matches(claim, "11:22:33:44:55:66", claim.linkID)) - assertFalse(AuthenticatedBleLinkPolicy.matches(null, claim.deviceAddress, claim.linkID)) - } -} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt deleted file mode 100644 index 16d2d286..00000000 --- a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.bitchat.android.mesh - -import android.bluetooth.BluetoothDevice -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertSame -import org.junit.Assert.assertTrue -import org.junit.Test -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -class BluetoothConnectionTrackerLinkIdentityTest { - private val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) - private val tracker = BluetoothConnectionTracker(scope, mock()) - - @After - fun tearDown() { - scope.cancel() - } - - @Test - fun `stale connection callbacks cannot mutate or remove replacement link`() { - val address = "AA:BB:CC:DD:EE:FF" - val device = mock() - whenever(device.address).thenReturn(address) - - tracker.addDeviceConnection( - address, - BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-a") - ) - tracker.addDeviceConnection( - address, - BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-b") - ) - - assertFalse( - tracker.updateDeviceConnectionIfCurrent(address, "link-a") { - it.copy(rssi = -10) - } - ) - assertFalse(tracker.cleanupDeviceConnectionIfCurrent(address, "link-a")) - assertEquals("link-b", tracker.getCurrentLinkID(address)) - - assertTrue(tracker.bindPeerIfCurrent(address, "link-b", "0011223344556677")) - assertEquals("0011223344556677", tracker.addressPeerMap[address]) - assertSame(device, tracker.getDeviceConnection(address)?.device) - } -} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt index 852411e8..8e590949 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -104,4 +105,30 @@ class PrivateChatManagerTest { verify(meshService).sendReadReceipt(message.id, meshPeerID, "bob") } + + @Test + fun `canonical conversation send does not require resolved nickname`() { + val conversationID = + ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 4 }) + var callbackInvoked = false + + manager.sendPrivateMessage( + content = "hello", + peerID = conversationID, + recipientNickname = null, + senderNickname = "bob", + myPeerID = "self" + ) { content, recipientID, nickname, _ -> + callbackInvoked = true + assertEquals("hello", content) + assertEquals(conversationID, recipientID) + assertEquals("", nickname) + } + + assertTrue(callbackInvoked) + assertEquals( + "hello", + state.getPrivateChatsValue()[conversationID]?.single()?.content + ) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt deleted file mode 100644 index 1f483543..00000000 --- a/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.bitchat.android.wifiaware - -import org.junit.Assert.assertNull -import org.junit.Assert.assertSame -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class AuthenticatedIngressLinkPolicyTest { - @Test - fun `promotion claim must match the challenged relay and link`() { - val claim = AuthenticatedIngressLinkPolicy.Claim("provisional", "challenged-link") - - assertTrue( - AuthenticatedIngressLinkPolicy.matches( - claim, - authenticatedRelayAddress = "provisional", - authenticatedLinkID = "challenged-link" - ) - ) - assertFalse( - AuthenticatedIngressLinkPolicy.matches( - claim, - authenticatedRelayAddress = "provisional", - authenticatedLinkID = "different-link" - ) - ) - assertFalse( - AuthenticatedIngressLinkPolicy.matches( - expected = null, - authenticatedRelayAddress = "provisional", - authenticatedLinkID = "challenged-link" - ) - ) - } - - @Test - fun `authentication promotes only the exact ingress link`() { - val attackerSocket = Any() - val victimSocket = Any() - val links = mapOf( - "attacker-link" to AuthenticatedIngressLinkPolicy.Link("provisional-attacker", attackerSocket), - "victim-link" to AuthenticatedIngressLinkPolicy.Link("provisional-victim", victimSocket) - ) - val current = mapOf( - "provisional-attacker" to attackerSocket, - "provisional-victim" to victimSocket - ) - - val resolved = AuthenticatedIngressLinkPolicy.resolve( - authenticatedLinkID = "victim-link", - authenticatedRelayAddress = "provisional-victim", - links = links, - currentTransportForRelay = current::get - ) - - assertSame(victimSocket, resolved?.transport) - } - - @Test - fun `stale replaced or mismatched ingress links cannot be promoted`() { - val completedSocket = Any() - val replacementSocket = Any() - val links = mapOf( - "completed-link" to AuthenticatedIngressLinkPolicy.Link("provisional", completedSocket) - ) - - assertNull( - AuthenticatedIngressLinkPolicy.resolve( - authenticatedLinkID = "missing-link", - authenticatedRelayAddress = "provisional", - links = links, - currentTransportForRelay = { completedSocket } - ) - ) - assertNull( - AuthenticatedIngressLinkPolicy.resolve( - authenticatedLinkID = "completed-link", - authenticatedRelayAddress = "different-provisional", - links = links, - currentTransportForRelay = { completedSocket } - ) - ) - assertNull( - AuthenticatedIngressLinkPolicy.resolve( - authenticatedLinkID = "completed-link", - authenticatedRelayAddress = "provisional", - links = links, - currentTransportForRelay = { replacementSocket } - ) - ) - } -} diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt index b1b12d0e..5cd2cbf7 100644 --- a/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt @@ -17,21 +17,21 @@ import java.net.Socket class WifiAwareConnectionTrackerTest { @Test - fun `compare and rebind rejects stale authenticated socket after replacement`() { + fun `compare and rebind rejects stale observed socket after replacement`() { val tracker = WifiAwareConnectionTracker( CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), mock() ) - val authenticatedSocket = syncedSocket() + val observedSocket = syncedSocket() val replacementSocket = syncedSocket() - tracker.onClientConnected("provisional", authenticatedSocket) + tracker.onClientConnected("provisional", observedSocket) tracker.onClientConnected("provisional", replacementSocket) assertFalse( tracker.rebindPeerIdIfCurrent( previousPeerId = "provisional", resolvedPeerId = "canonical", - expectedSocket = authenticatedSocket + expectedSocket = observedSocket ) ) assertSame(replacementSocket, tracker.getSocketForPeer("provisional")) @@ -49,7 +49,7 @@ class WifiAwareConnectionTrackerTest { } @Test - fun `authenticated provisional socket cannot displace existing canonical socket`() { + fun `observed provisional socket cannot displace existing canonical socket`() { val tracker = WifiAwareConnectionTracker( CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), mock() From 58c56a8082f5f0166b879bad2c692fa541946c96 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:45:29 +0200 Subject: [PATCH 46/70] ui: open lock until Noise session is established MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show ic_spec_lock_open while idle or handshaking, then Crossfade to the closed lock on success or failure — timed with the existing tint wash so the shackle settling reads as one smooth transition. Co-authored-by: Cursor --- .../java/com/bitchat/android/ui/ChatHeader.kt | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 8d4fdfa1..f8820e91 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -4,6 +4,7 @@ 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.Crossfade import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.RepeatMode @@ -294,10 +295,10 @@ internal fun TorAwareHeaderIcon( /** * Noise session status for private-chat headers. * - * Same visual language as the main header's Tor-aware globe: one lock glyph throughout, tint - * cross-fades between states, and a soft radial glow pulse while the handshake is in flight. - * The old sync/recycle glyph is gone — progress is carried by colour and motion, not by swapping - * icons. + * Same visual language as the main header's Tor-aware globe: tint cross-fades between states, + * and a soft radial glow pulse while the handshake is in flight. The glyph itself is the open + * lock until a session is established (or fails), then the closed lock — both share the same + * baseline so a [Crossfade] reads as the shackle settling shut rather than an icon swap. */ @Composable fun NoiseSessionIcon( @@ -324,28 +325,42 @@ fun NoiseSessionIcon( stringResource(R.string.cd_handshake_failed) ) else -> Triple( - // Not yet started — quiet grey lock, same glyph as every other state. + // Not yet started — quiet grey open lock. colorScheme.onSurfaceVariant, false, stringResource(R.string.cd_ready_for_handshake) ) } - // Longer than the usual chrome tint so grey → orange → green reads as a continuous wash, - // not a snap between discrete states. + // Closed once the handshake resolves (success or failure); open while idle or in flight. + val lockIconRes = when { + sessionState == "established" || sessionState?.startsWith("failed") == true -> + R.drawable.ic_spec_lock + else -> R.drawable.ic_spec_lock_open + } + + // Match the tint wash so open → closed and grey → orange → green land together. + val lockTransitionMs = 480 + val animatedTint by animateColorAsState( targetValue = targetTint, - animationSpec = tween(durationMillis = 480, easing = FastOutSlowInEasing), + animationSpec = tween(durationMillis = lockTransitionMs, easing = FastOutSlowInEasing), label = "noiseSessionTint" ) - TorAwareHeaderIcon( - painter = painterResource(R.drawable.ic_spec_lock), - tint = animatedTint, - isProgress = isProgress, - contentDescription = contentDescription, - modifier = modifier - ) + Crossfade( + targetState = lockIconRes, + animationSpec = tween(durationMillis = lockTransitionMs, easing = FastOutSlowInEasing), + modifier = modifier, + label = "noiseLockGlyph" + ) { iconRes -> + TorAwareHeaderIcon( + painter = painterResource(iconRes), + tint = animatedTint, + isProgress = isProgress, + contentDescription = contentDescription, + ) + } } /** From 62dd3ca90e09fc193ad2b45011ad87b462895b69 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:47:17 +0200 Subject: [PATCH 47/70] fix: complete direct-link routing rollback --- .../mesh/DirectLinkAnnouncementPolicy.kt | 26 ++++++ .../android/wifi-aware/IngressLinkPolicy.kt | 22 +++++ ...othConnectionTrackerLinkObservationTest.kt | 85 +++++++++++++++++++ .../mesh/DirectLinkAnnouncementPolicyTest.kt | 64 ++++++++++++++ .../wifi-aware/IngressLinkPolicyTest.kt | 64 ++++++++++++++ 5 files changed, 261 insertions(+) create mode 100644 app/src/main/java/com/bitchat/android/mesh/DirectLinkAnnouncementPolicy.kt create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/IngressLinkPolicy.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/DirectLinkAnnouncementPolicyTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/wifi-aware/IngressLinkPolicyTest.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/DirectLinkAnnouncementPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/DirectLinkAnnouncementPolicy.kt new file mode 100644 index 00000000..e0be2835 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/DirectLinkAnnouncementPolicy.kt @@ -0,0 +1,26 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket + +/** + * Describes transport reachability learned from an already-validated ANNOUNCE. + * + * This is deliberately only a routing observation. Noise authenticates the peer independently and + * must not be restarted merely to associate the current transport link with that peer. + */ +internal object DirectLinkAnnouncementPolicy { + data class Observation( + val peerID: String, + val relayAddress: String, + val ingressLinkID: String + ) + + fun observationFor(routed: RoutedPacket, maxTtl: UByte): Observation? { + if (routed.packet.ttl != maxTtl) return null + return Observation( + peerID = routed.peerID ?: return null, + relayAddress = routed.relayAddress ?: return null, + ingressLinkID = routed.ingressLinkID ?: return null + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/IngressLinkPolicy.kt b/app/src/main/java/com/bitchat/android/wifi-aware/IngressLinkPolicy.kt new file mode 100644 index 00000000..f5572e17 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/IngressLinkPolicy.kt @@ -0,0 +1,22 @@ +package com.bitchat.android.wifiaware + +/** Resolves a packet to the exact still-active ingress link that delivered it. */ +internal object IngressLinkPolicy { + data class Link( + val relayAddress: String, + val transport: T + ) + + fun resolve( + ingressLinkID: String?, + relayAddress: String?, + links: Map>, + currentTransportForRelay: (String) -> T? + ): Link? { + val linkID = ingressLinkID ?: return null + val relayAddress = relayAddress ?: return null + val link = links[linkID] ?: return null + if (link.relayAddress != relayAddress) return null + return link.takeIf { currentTransportForRelay(relayAddress) === it.transport } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt new file mode 100644 index 00000000..bde80cdc --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt @@ -0,0 +1,85 @@ +package com.bitchat.android.mesh + +import android.bluetooth.BluetoothDevice +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class BluetoothConnectionTrackerLinkObservationTest { + private val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + private val tracker = BluetoothConnectionTracker(scope, mock()) + + @After + fun tearDown() { + scope.cancel() + } + + @Test + fun `stale connection callbacks cannot mutate or remove replacement link`() { + val address = "AA:BB:CC:DD:EE:FF" + val device = mock() + whenever(device.address).thenReturn(address) + + tracker.addDeviceConnection( + address, + BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-a") + ) + tracker.addDeviceConnection( + address, + BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-b") + ) + + assertFalse( + tracker.updateDeviceConnectionIfCurrent(address, "link-a") { + it.copy(rssi = -10) + } + ) + assertFalse(tracker.cleanupDeviceConnectionIfCurrent(address, "link-a")) + assertEquals("link-b", tracker.getCurrentLinkID(address)) + + assertTrue(tracker.observePeerIfCurrent(address, "link-b", "0011223344556677")) + assertEquals("0011223344556677", tracker.addressPeerMap[address]) + assertSame(device, tracker.getDeviceConnection(address)?.device) + } + + @Test + fun `one peer can remain directly observed over multiple current links`() { + val firstAddress = "AA:BB:CC:DD:EE:01" + val secondAddress = "AA:BB:CC:DD:EE:02" + val firstDevice = mock() + val secondDevice = mock() + whenever(firstDevice.address).thenReturn(firstAddress) + whenever(secondDevice.address).thenReturn(secondAddress) + + tracker.addDeviceConnection( + firstAddress, + BluetoothConnectionTracker.DeviceConnection(device = firstDevice, linkID = "link-a") + ) + tracker.addDeviceConnection( + secondAddress, + BluetoothConnectionTracker.DeviceConnection(device = secondDevice, linkID = "link-b") + ) + + assertTrue(tracker.observePeerIfCurrent(firstAddress, "link-a", PEER_ID)) + assertTrue(tracker.observePeerIfCurrent(secondAddress, "link-b", PEER_ID)) + assertTrue(tracker.observePeerIfCurrent(secondAddress, "link-b", PEER_ID)) + assertEquals(2, tracker.addressPeerMap.values.count { it == PEER_ID }) + + assertTrue(tracker.cleanupDeviceConnectionIfCurrent(firstAddress, "link-a")) + assertEquals(PEER_ID, tracker.addressPeerMap[secondAddress]) + assertTrue(tracker.addressPeerMap.containsValue(PEER_ID)) + } + + private companion object { + const val PEER_ID = "0011223344556677" + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/DirectLinkAnnouncementPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/DirectLinkAnnouncementPolicyTest.kt new file mode 100644 index 00000000..8c0f2ad4 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/DirectLinkAnnouncementPolicyTest.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DirectLinkAnnouncementPolicyTest { + @Test + fun `accepted max ttl announce is a direct routing observation`() { + val routed = announce(ttl = MAX_TTL) + + assertEquals( + DirectLinkAnnouncementPolicy.Observation(PEER_ID, RELAY_ADDRESS, LINK_ID), + DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL) + ) + } + + @Test + fun `relayed announce is not a direct routing observation`() { + assertNull( + DirectLinkAnnouncementPolicy.observationFor( + announce(ttl = (MAX_TTL - 1u).toUByte()), + MAX_TTL + ) + ) + } + + @Test + fun `repeated announce remains the same observation without transport authentication state`() { + val routed = announce(ttl = MAX_TTL) + + val first = DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL) + val second = DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL) + + assertEquals(first, second) + } + + private fun announce(ttl: UByte) = RoutedPacket( + packet = BitchatPacket( + version = 1u, + type = MessageType.ANNOUNCE.value, + senderID = PEER_ID.hexToBytes(), + timestamp = 1u, + payload = byteArrayOf(1), + ttl = ttl + ), + peerID = PEER_ID, + relayAddress = RELAY_ADDRESS, + ingressLinkID = LINK_ID + ) + + private fun String.hexToBytes(): ByteArray = + chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private companion object { + const val PEER_ID = "0011223344556677" + const val RELAY_ADDRESS = "transport-neighbor" + const val LINK_ID = "current-link" + val MAX_TTL: UByte = 7u + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/IngressLinkPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/IngressLinkPolicyTest.kt new file mode 100644 index 00000000..cf352d7c --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/IngressLinkPolicyTest.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.wifiaware + +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class IngressLinkPolicyTest { + @Test + fun `observation resolves only the exact ingress link`() { + val attackerSocket = Any() + val victimSocket = Any() + val links = mapOf( + "attacker-link" to IngressLinkPolicy.Link("provisional-attacker", attackerSocket), + "victim-link" to IngressLinkPolicy.Link("provisional-victim", victimSocket) + ) + val current = mapOf( + "provisional-attacker" to attackerSocket, + "provisional-victim" to victimSocket + ) + + val resolved = IngressLinkPolicy.resolve( + ingressLinkID = "victim-link", + relayAddress = "provisional-victim", + links = links, + currentTransportForRelay = current::get + ) + + assertSame(victimSocket, resolved?.transport) + } + + @Test + fun `stale replaced or mismatched ingress links cannot be observed`() { + val completedSocket = Any() + val replacementSocket = Any() + val links = mapOf( + "completed-link" to IngressLinkPolicy.Link("provisional", completedSocket) + ) + + assertNull( + IngressLinkPolicy.resolve( + ingressLinkID = "missing-link", + relayAddress = "provisional", + links = links, + currentTransportForRelay = { completedSocket } + ) + ) + assertNull( + IngressLinkPolicy.resolve( + ingressLinkID = "completed-link", + relayAddress = "different-provisional", + links = links, + currentTransportForRelay = { completedSocket } + ) + ) + assertNull( + IngressLinkPolicy.resolve( + ingressLinkID = "completed-link", + relayAddress = "provisional", + links = links, + currentTransportForRelay = { replacementSocket } + ) + ) + } +} From 5fd686a00a5c616756217116c70fa885df8bc5fd Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:11:37 +0200 Subject: [PATCH 48/70] ui: celebrate being favorited in private chats - header star wobbles and its outline turns orange when the peer favorites you; it only fills once you favorite them back - mirror the 'favorited you' system notice into the private conversation (mesh path), matching the main chat - expose reactive peerFavoritedUs state driven by FavoritesPersistenceService; keep system notices silent (no unread badge, read receipt or push) --- .../android/mesh/BluetoothMeshService.kt | 2 +- .../bitchat/android/mesh/MessageHandler.kt | 20 +++++- .../java/com/bitchat/android/ui/ChatState.kt | 9 +++ .../com/bitchat/android/ui/ChatViewModel.kt | 32 ++++++++- .../bitchat/android/ui/MeshDelegateHandler.kt | 39 ++++++----- .../bitchat/android/ui/MeshPeerListSheet.kt | 68 ++++++++++++++++--- 6 files changed, 140 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 432d9a1e..2e0507ab 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -482,7 +482,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic delegate?.didReceiveMessage(message) // If no UI delegate attached (app closed), show DM notification via service manager - if (delegate == null && message.isPrivate) { + if (delegate == null && message.isPrivate && message.sender != "system") { try { val senderPeerID = message.senderPeerID if (senderPeerID != null) { diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index d3356e85..b0f75a94 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -575,13 +575,31 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro } val action = if (control.isFavorite) "favorited" else "unfavorited" + val notice = "${peerInfo.nickname} $action you$guidance" val sys = com.bitchat.android.model.BitchatMessage( sender = "system", - content = "${peerInfo.nickname} $action you$guidance", + content = notice, timestamp = java.util.Date(), isRelay = false ) delegate?.onMessageReceived(sys) + + // Mirror the notice into the private conversation so it's visible while chatting + try { + val conversationID = com.bitchat.android.services.ContactDirectory + .canonicalConversationId(fromPeerID) + val sysPrivate = com.bitchat.android.model.BitchatMessage( + sender = "system", + content = notice, + timestamp = java.util.Date(), + isRelay = false, + isPrivate = true, + senderPeerID = conversationID + ) + delegate?.onMessageReceived(sysPrivate) + } catch (_: Exception) { + // Best-effort; public notice already delivered + } } } catch (_: Exception) { // Best-effort; ignore errors diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index 53a298ef..13c754a8 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -94,6 +94,10 @@ class ChatState( // Favorites private val _favoritePeers = MutableStateFlow>(emptySet()) val favoritePeers: StateFlow> = _favoritePeers.asStateFlow() + + // Fingerprints of peers who favorited us (drives "favorited you" UI celebrations) + private val _peerFavoritedUs = MutableStateFlow>(emptySet()) + val peerFavoritedUs: StateFlow> = _peerFavoritedUs.asStateFlow() // Noise session states for peers (for reactive UI updates) private val _peerSessionStates = MutableStateFlow>(emptyMap()) @@ -174,6 +178,7 @@ class ChatState( fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value fun getJoinedChannelsValue() = _joinedChannels.value + fun getPeerFavoritedUsValue() = _peerFavoritedUs.value fun getCurrentChannelValue() = _currentChannel.value fun getChannelMessagesValue() = _channelMessages.value fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value @@ -285,6 +290,10 @@ class ChatState( Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}") } + + fun setPeerFavoritedUs(fingerprints: Set) { + _peerFavoritedUs.value = fingerprints + } fun setPeerSessionStates(states: Map) { _peerSessionStates.value = states diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index d0358a78..eb9e8ee5 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -185,6 +185,7 @@ class ChatViewModel( val showMentionSuggestions: StateFlow = state.showMentionSuggestions val mentionSuggestions: StateFlow> = state.mentionSuggestions val favoritePeers: StateFlow> = state.favoritePeers + val peerFavoritedUs: StateFlow> = state.peerFavoritedUs val peerSessionStates: StateFlow> = state.peerSessionStates val peerFingerprints: StateFlow> = state.peerFingerprints val peerNicknames: StateFlow> = state.peerNicknames @@ -243,7 +244,7 @@ class ChatViewModel( val myNick = state.getNicknameValue() ?: mesh.myPeerID val unread = mutableSetOf() canonicalChats.forEach { (peer, list) -> - if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer) + if (list.any { msg -> msg.sender != myNick && msg.sender != "system" && !seen.hasRead(msg.id) }) unread.add(peer) } state.setUnreadPrivateMessages(unread) } catch (_: Exception) { } @@ -324,6 +325,17 @@ class ChatViewModel( // Initialize favorites persistence service com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) + // Reflect "they favorited us" changes into reactive UI state (drives star celebrations) + refreshPeerFavoritedUs() + try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener( + object : com.bitchat.android.favorites.FavoritesChangeListener { + override fun onFavoriteChanged(noiseKeyHex: String) = refreshPeerFavoritedUs() + override fun onAllCleared() = refreshPeerFavoritedUs() + } + ) + } catch (_: Exception) { } + // Load verified fingerprints from secure storage verificationHandler.loadVerifiedFingerprints() @@ -630,8 +642,22 @@ class ChatViewModel( logCurrentFavoriteState() } - private fun logCurrentFavoriteState() { - Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") + private fun refreshPeerFavoritedUs() { + try { + val fingerprints = com.bitchat.android.favorites.FavoritesPersistenceService.shared + .getAllRelationships() + .filter { it.theyFavoritedUs } + .mapNotNull { relationship -> + runCatching { + ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey) + }.getOrNull() + } + .toSet() + state.setPeerFavoritedUs(fingerprints) + } catch (_: Exception) { } + } + + private fun logCurrentFavoriteState() { Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}") Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}") Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}") diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index 8d18563a..f98c5c87 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -44,24 +44,29 @@ class MeshDelegateHandler( onHapticFeedback() if (message.isPrivate) { - // Private message - privateChatManager.handleIncomingPrivateMessage(message) + if (message.sender == "system") { + // System notices (e.g. "x favorited you"): no unread badge, read receipt or push + privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = true) + } else { + // Private message + privateChatManager.handleIncomingPrivateMessage(message) - // Reactive read receipts: if chat is focused, send immediately for this message - message.senderPeerID?.let { senderPeerID -> - sendReadReceiptIfFocused(message) - } - - // Show notification with enhanced information - now includes senderPeerID - message.senderPeerID?.let { senderPeerID -> - // Use nickname if available, fall back to sender or senderPeerID - val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID - val preview = NotificationTextUtils.buildPrivateMessagePreview(message) - notificationManager.showPrivateMessageNotification( - senderPeerID = senderPeerID, - senderNickname = senderNickname, - messageContent = preview - ) + // Reactive read receipts: if chat is focused, send immediately for this message + message.senderPeerID?.let { senderPeerID -> + sendReadReceiptIfFocused(message) + } + + // Show notification with enhanced information - now includes senderPeerID + message.senderPeerID?.let { senderPeerID -> + // Use nickname if available, fall back to sender or senderPeerID + val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID + val preview = NotificationTextUtils.buildPrivateMessagePreview(message) + notificationManager.showPrivateMessageNotification( + senderPeerID = senderPeerID, + senderNickname = senderNickname, + messageContent = preview + ) + } } } else if (message.channel != null) { // Channel message: AppStateStore is the source of truth for list; only manage unread diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index c496938e..21c82b8f 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -8,8 +8,12 @@ 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.animateColorAsState +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -27,6 +31,7 @@ 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.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -54,6 +59,7 @@ import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver import com.bitchat.android.util.hexEncodedString +import kotlinx.coroutines.launch /** @@ -352,7 +358,7 @@ fun PeopleSection( // Observe reactive state for favorites and fingerprints val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() - val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() @@ -799,6 +805,7 @@ fun PrivateChatSheet( val peerDirectMap by viewModel.peerDirect.collectAsStateWithLifecycle() val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle() val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() @@ -815,7 +822,7 @@ fun PrivateChatSheet( } val isNostrPeer = peerID.startsWith("nostr_") || peerID.startsWith("nostr:") - val favoriteRelationship = remember(peerID, favoritePeers) { + val favoriteRelationship = remember(peerID, favoritePeers, peerFavoritedUs) { try { FavoritesPersistenceService.shared.getFavoriteStatus(peerID) } catch (_: Exception) { @@ -859,12 +866,55 @@ fun PrivateChatSheet( val isFavorite = remember(favoritePeers, fingerprint, peerID, favoriteRelationship) { if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID) } + val theyFavoritedUs = remember(peerFavoritedUs, fingerprint, favoriteRelationship) { + (fingerprint != null && peerFavoritedUs.contains(fingerprint)) || + favoriteRelationship?.theyFavoritedUs == true + } + + // Celebrate being favorited: a springy wobble of the header star. Springs rather than + // keyframed tweens, matching the app's press feedback, so the settle overshoots slightly. + val starWobbleRotation = remember { Animatable(0f) } + val starWobbleScale = remember { Animatable(1f) } + var previousTheyFavoritedUs by remember { mutableStateOf(null) } + LaunchedEffect(theyFavoritedUs) { + val wasFavoritedUs = previousTheyFavoritedUs + previousTheyFavoritedUs = theyFavoritedUs + if (theyFavoritedUs && wasFavoritedUs == false) { + starWobbleRotation.snapTo(-16f) + starWobbleScale.snapTo(1.35f) + launch { + starWobbleRotation.animateTo( + targetValue = 0f, + animationSpec = spring(dampingRatio = 0.3f, stiffness = Spring.StiffnessMedium) + ) + } + launch { + starWobbleScale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ) + ) + } + } + } val isVerified = remember(peerID, verifiedFingerprints) { viewModel.isPeerVerified(peerID, verifiedFingerprints) } val palette = LocalBitchatPalette.current + // Three-state star: grey outline (no relation), orange outline (they favorited us), + // filled orange (we favorited them, mutual or not). + val favoriteStarTint by animateColorAsState( + targetValue = when { + isFavorite || theyFavoritedUs -> palette.accentOrange + else -> colorScheme.onSurfaceVariant + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "favoriteStarTint" + ) val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true ) @@ -984,12 +1034,14 @@ fun PrivateChatSheet( } ), contentDescription = null, - modifier = Modifier.size(HeaderIconSize), - tint = if (isFavorite) { - palette.accentOrange - } else { - colorScheme.onSurfaceVariant - } + modifier = Modifier + .size(HeaderIconSize) + .graphicsLayer { + rotationZ = starWobbleRotation.value + scaleX = starWobbleScale.value + scaleY = starWobbleScale.value + }, + tint = favoriteStarTint ) } From 290d72f2b61e7a757261e4f9d37ad72ef355a157 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:03:17 +0200 Subject: [PATCH 49/70] fix: make read receipts reliable --- .../android/mesh/BluetoothMeshService.kt | 49 +++++-- .../java/com/bitchat/android/mesh/MeshCore.kt | 34 ++++- .../mesh/RetryingControlPacketSender.kt | 82 ++++++++++++ .../nostr/NostrDirectMessageHandler.kt | 5 +- .../android/services/SeenMessageStore.kt | 59 +++++++-- .../com/bitchat/android/ui/ChatViewModel.kt | 27 +++- .../bitchat/android/ui/MeshDelegateHandler.kt | 77 +++++------ .../bitchat/android/ui/PrivateChatManager.kt | 5 +- .../mesh/RetryingControlPacketSenderTest.kt | 121 ++++++++++++++++++ .../nostr/NostrDirectMessageHandlerTest.kt | 2 +- .../android/services/AppStateStoreTest.kt | 35 +++++ .../MeshDelegateHandlerStateContractTest.kt | 40 +++++- .../android/ui/PrivateChatManagerTest.kt | 49 +++++++ 13 files changed, 512 insertions(+), 73 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/RetryingControlPacketSenderTest.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 2e0507ab..76ff2dbe 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -53,6 +53,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic private val peerManager = PeerManager() private val fragmentManager = FragmentManager() private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val readReceiptRetrySender = RetryingControlPacketSender(serviceScope) private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context) private val authenticatedPeerState by lazy { AuthenticatedPeerStateCoordinator( @@ -500,10 +501,24 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } override fun onDeliveryAckReceived(messageID: String, peerID: String) { + // Status events can arrive while MainActivity has detached the UI delegate. + // Persist first so the next UI collector observes the advancement. + try { + com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Delivered(peerID, Date()) + ) + } catch (_: Exception) { } delegate?.didReceiveDeliveryAck(messageID, peerID) } override fun onReadReceiptReceived(messageID: String, peerID: String) { + try { + com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Read(peerID, Date()) + ) + } catch (_: Exception) { } delegate?.didReceiveReadReceipt(messageID, peerID) } @@ -1043,12 +1058,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } try { - // Avoid duplicate read receipts: check persistent store first - val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null } - if (seenStore?.hasRead(messageID) == true) { - return@launch - } - // Create read receipt payload using NoisePayloadType exactly like iOS val readReceiptPayload = com.bitchat.android.model.NoisePayload( type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT, @@ -1072,10 +1081,30 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - broadcastRoutedPacket(RoutedPacket(signedPacket)) - - // Persist as read after successful send - try { seenStore?.markRead(messageID) } catch (_: Exception) { } + val retryKey = "$recipientPeerID:$messageID" + readReceiptRetrySender.enqueue( + key = retryKey, + sendAttempt = { attempt -> + // Keep the addressed packet on the normal broadcaster actor so receipt + // attempts are ordered with other BLE traffic and can use mesh routing. + val accepted = broadcastRoutedPacket(RoutedPacket(signedPacket)) + Log.d( + TAG, + "Read receipt attempt $attempt accepted=$accepted " + + "peer=${recipientPeerID.take(8)} message=${messageID.take(8)}" + ) + accepted + }, + onComplete = { accepted -> + if (accepted) { + try { + com.bitchat.android.services.SeenMessageStore + .getInstance(context.applicationContext) + .markReadReceiptSent(messageID) + } catch (_: Exception) { } + } + } + ) } catch (e: Exception) { Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}") diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 2387e939..d1185428 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -52,6 +52,7 @@ class MeshCore( private val peerManager = PeerManager() val fragmentManager = FragmentManager() + private val readReceiptRetrySender = RetryingControlPacketSender(scope) private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context) private val authenticatedPeerState by lazy { AuthenticatedPeerStateCoordinator( @@ -398,10 +399,22 @@ class MeshCore( } override fun onDeliveryAckReceived(messageID: String, peerID: String) { + try { + com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Delivered(peerID, java.util.Date()) + ) + } catch (_: Exception) { } delegate?.didReceiveDeliveryAck(messageID, peerID) } override fun onReadReceiptReceived(messageID: String, peerID: String) { + try { + com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Read(peerID, java.util.Date()) + ) + } catch (_: Exception) { } delegate?.didReceiveReadReceipt(messageID, peerID) } @@ -691,8 +704,25 @@ class MeshCore( signature = null, ttl = maxTtl ) - dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) - hooks.onReadReceiptSent?.invoke(messageID) + val signedPacket = signPacketBeforeBroadcast(packet) + val retryKey = "$recipientPeerID:$messageID" + readReceiptRetrySender.enqueue( + key = retryKey, + sendAttempt = { + dispatchGlobal(RoutedPacket(signedPacket)) + true + }, + onComplete = { accepted -> + if (accepted) { + try { + com.bitchat.android.services.SeenMessageStore + .getInstance(context.applicationContext) + .markReadReceiptSent(messageID) + } catch (_: Exception) { } + hooks.onReadReceiptSent?.invoke(messageID) + } + } + ) } catch (e: Exception) { Log.e("MeshCore", "Failed to send read receipt: ${e.message}") } diff --git a/app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt new file mode 100644 index 00000000..ba81800c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt @@ -0,0 +1,82 @@ +package com.bitchat.android.mesh + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Sends small idempotent control packets redundantly while serializing the attempts submitted + * through this sender. Android BLE only reports that a GATT write/notification was accepted; + * it does not prove that the remote application processed the packet. Reusing the exact encoded + * packet makes retries safe: the receiver's packet/replay protection drops copies it already saw. + */ +internal class RetryingControlPacketSender( + private val scope: CoroutineScope, + private val maxAttempts: Int = 3, + private val retryDelayMs: Long = 750L, + private val interSendDelayMs: Long = 75L +) { + private val sendMutex = Mutex() + private val jobsLock = Any() + private val activeJobs = mutableMapOf() + + init { + require(maxAttempts > 0) + require(retryDelayMs >= 0) + require(interSendDelayMs >= 0) + } + + /** + * Coalesces concurrent requests for the same logical packet. Once the retry window finishes, + * a later user action may enqueue the packet again; duplicate receipt processing is idempotent. + */ + fun enqueue( + key: String, + sendAttempt: suspend (attempt: Int) -> Boolean, + onComplete: (acceptedAtLeastOnce: Boolean) -> Unit = {} + ) { + val job = synchronized(jobsLock) { + if (activeJobs[key]?.isActive == true) return + + scope.launch(start = CoroutineStart.LAZY) { + var acceptedAtLeastOnce = false + var completed = false + try { + repeat(maxAttempts) { index -> + if (!isActive) return@launch + val attempt = index + 1 + sendMutex.withLock { + try { + val accepted = try { + sendAttempt(attempt) + } catch (_: Exception) { + false + } + acceptedAtLeastOnce = accepted || acceptedAtLeastOnce + } finally { + if (interSendDelayMs > 0) delay(interSendDelayMs) + } + } + if (attempt < maxAttempts && retryDelayMs > 0) { + delay(retryDelayMs) + } + } + completed = true + } finally { + synchronized(jobsLock) { + activeJobs.remove(key) + } + if (completed) { + try { onComplete(acceptedAtLeastOnce) } catch (_: Exception) { } + } + } + }.also { activeJobs[key] = it } + } + job.start() + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 469972c8..fce95c2d 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -156,7 +156,7 @@ class NostrDirectMessageHandler( ) val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID - val suppressUnread = seenStore.hasRead(pm.messageID) + val suppressUnread = seenStore.hasBeenReadLocally(pm.messageID) withContext(Dispatchers.Main) { privateChatManager.handleIncomingPrivateMessage( @@ -175,7 +175,8 @@ class NostrDirectMessageHandler( if (isViewing && !suppressUnread) { val nostrTransport = NostrTransport.getInstance(application) nostrTransport.sendReadReceiptGeohash(pm.messageID, senderPubkey, recipientIdentity) - seenStore.markRead(pm.messageID) + seenStore.markReadLocally(pm.messageID) + seenStore.markReadReceiptSent(pm.messageID) } } NoisePayloadType.DELIVERED -> { diff --git a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt index 15e1c7ca..3b531846 100644 --- a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt +++ b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt @@ -4,9 +4,14 @@ import android.content.Context import android.util.Log import com.bitchat.android.identity.SecureIdentityStateManager import com.google.gson.Gson +import com.google.gson.annotations.SerializedName /** - * Persistent store for message IDs we've already acknowledged (DELIVERED) or READ. + * Persistent store for message IDs we've already acknowledged as delivered, read locally, or + * admitted to a completed read-receipt send window. + * + * Local read state must not be used as proof that a read-receipt packet reached the sender. + * Transport delivery is best-effort and retryable, while local read state drives unread UI. * Limits to last MAX_IDS entries per set to avoid memory bloat. */ class SeenMessageStore private constructor(private val context: Context) { @@ -27,12 +32,14 @@ class SeenMessageStore private constructor(private val context: Context) { private val secure = SecureIdentityStateManager(context) private val delivered = LinkedHashSet(MAX_IDS) - private val read = LinkedHashSet(MAX_IDS) + private val locallyRead = LinkedHashSet(MAX_IDS) + private val readReceiptsSent = LinkedHashSet(MAX_IDS) init { load() } @Synchronized fun hasDelivered(id: String) = delivered.contains(id) - @Synchronized fun hasRead(id: String) = read.contains(id) + @Synchronized fun hasBeenReadLocally(id: String) = locallyRead.contains(id) + @Synchronized fun hasReadReceiptBeenSent(id: String) = readReceiptsSent.contains(id) @Synchronized fun markDelivered(id: String) { if (delivered.remove(id)) delivered.add(id) else { @@ -42,17 +49,26 @@ class SeenMessageStore private constructor(private val context: Context) { persist() } - @Synchronized fun markRead(id: String) { - if (read.remove(id)) read.add(id) else { - read.add(id) - trim(read) + @Synchronized fun markReadLocally(id: String) { + if (locallyRead.remove(id)) locallyRead.add(id) else { + locallyRead.add(id) + trim(locallyRead) + } + persist() + } + + @Synchronized fun markReadReceiptSent(id: String) { + if (readReceiptsSent.remove(id)) readReceiptsSent.add(id) else { + readReceiptsSent.add(id) + trim(readReceiptsSent) } persist() } @Synchronized fun clear() { delivered.clear() - read.clear() + locallyRead.clear() + readReceiptsSent.clear() persist() } @@ -68,10 +84,19 @@ class SeenMessageStore private constructor(private val context: Context) { try { val json = secure.getSecureValue(STORAGE_KEY) ?: return val data = gson.fromJson(json, StorePayload::class.java) ?: return - delivered.clear(); read.clear() + delivered.clear(); locallyRead.clear(); readReceiptsSent.clear() data.delivered.takeLast(MAX_IDS).forEach { delivered.add(it) } - data.read.takeLast(MAX_IDS).forEach { read.add(it) } - Log.d(TAG, "Loaded delivered=${delivered.size}, read=${read.size}") + data.locallyRead.takeLast(MAX_IDS).forEach { locallyRead.add(it) } + // Older payloads used the local-read set to suppress receipt sends. Seed the new + // explicit set once during migration to avoid replaying an entire chat history. + (data.readReceiptsSent ?: data.locallyRead) + .takeLast(MAX_IDS) + .forEach { readReceiptsSent.add(it) } + Log.d( + TAG, + "Loaded delivered=${delivered.size}, locallyRead=${locallyRead.size}, " + + "readReceiptsSent=${readReceiptsSent.size}" + ) } catch (e: Exception) { Log.e(TAG, "Failed to load SeenMessageStore: ${e.message}") } @@ -79,7 +104,11 @@ class SeenMessageStore private constructor(private val context: Context) { @Synchronized private fun persist() { try { - val payload = StorePayload(delivered.toList(), read.toList()) + val payload = StorePayload( + delivered = delivered.toList(), + locallyRead = locallyRead.toList(), + readReceiptsSent = readReceiptsSent.toList() + ) val json = gson.toJson(payload) secure.storeSecureValue(STORAGE_KEY, json) } catch (e: Exception) { @@ -89,6 +118,10 @@ class SeenMessageStore private constructor(private val context: Context) { private data class StorePayload( val delivered: List = emptyList(), - val read: List = emptyList() + // Keep the existing JSON field name for backward-compatible secure-store migration. + @SerializedName("read") + val locallyRead: List = emptyList(), + @SerializedName("read_receipts_sent") + val readReceiptsSent: List? = null ) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index eb9e8ee5..c2ef7ae8 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -99,6 +99,9 @@ class ChatViewModel( // Specialized managers private val dataManager = DataManager(application.applicationContext) private val identityManager by lazy { SecureIdentityStateManager(getApplication()) } + private val seenMessageStore by lazy { + com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) + } private val messageManager = MessageManager(state) private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope) @@ -109,7 +112,13 @@ class ChatViewModel( override fun getMyPeerID(): String = mesh.myPeerID } - val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate) + val privateChatManager = PrivateChatManager( + state, + messageManager, + dataManager, + noiseSessionDelegate, + hasReadReceiptBeenSent = seenMessageStore::hasReadReceiptBeenSent + ) private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager) private val notificationManager = NotificationManager( application.applicationContext, @@ -146,7 +155,8 @@ class ChatViewModel( coroutineScope = viewModelScope, onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) }, getMyPeerID = { mesh.myPeerID }, - getMeshService = { mesh } + getMeshService = { mesh }, + markMessageReadLocally = seenMessageStore::markReadLocally ) // New Geohash architecture ViewModel (replaces God object service usage in UI path) @@ -240,11 +250,17 @@ class ChatViewModel( state.setPrivateChats(canonicalChats) // Recompute unread set using SeenMessageStore for robustness across Activity recreation try { - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) val myNick = state.getNicknameValue() ?: mesh.myPeerID val unread = mutableSetOf() canonicalChats.forEach { (peer, list) -> - if (list.any { msg -> msg.sender != myNick && msg.sender != "system" && !seen.hasRead(msg.id) }) unread.add(peer) + if (list.any { msg -> + msg.sender != myNick && + msg.sender != "system" && + !seenMessageStore.hasBeenReadLocally(msg.id) + } + ) { + unread.add(peer) + } } state.setUnreadPrivateMessages(unread) } catch (_: Exception) { } @@ -405,11 +421,10 @@ class ChatViewModel( // Persistently mark all messages in this conversation as read so Nostr fetches // after app restarts won't re-mark them as unread. try { - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) val chats = state.getPrivateChatsValue() val messages = chats[conversationID] ?: emptyList() messages.forEach { msg -> - try { seen.markRead(msg.id) } catch (_: Exception) { } + try { seenMessageStore.markReadLocally(msg.id) } catch (_: Exception) { } } } catch (_: Exception) { } } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index f98c5c87..6b371710 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -22,7 +22,8 @@ class MeshDelegateHandler( private val coroutineScope: CoroutineScope, private val onHapticFeedback: () -> Unit, private val getMyPeerID: () -> String, - private val getMeshService: () -> MeshService + private val getMeshService: () -> MeshService, + private val markMessageReadLocally: (messageID: String) -> Unit = {} ) : BluetoothMeshDelegate { override fun didReceiveMessage(message: BitchatMessage) { @@ -247,46 +248,50 @@ class MeshDelegateHandler( val shouldSendReadReceipt = !isAppInBackground && senderConversationID != null && focusedConversationID == senderConversationID - - if (shouldSendReadReceipt) { - android.util.Log.d( - "MeshDelegateHandler", - "Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})" - ) - val nickname = state.getNicknameValue() ?: "unknown" - val mesh = getMeshService() - val sent = try { - val meshPeerID = senderConversationID - ?.let { ContactDirectory.resolve(it).meshPeerID } - ?: senderPeerID?.takeIf { - com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it) - } - if (meshPeerID != null && - mesh.getPeerInfo(meshPeerID)?.isConnected == true && - mesh.hasEstablishedSession(meshPeerID) - ) { - mesh.sendReadReceipt(message.id, meshPeerID, nickname) - true - } else { - false + + if (shouldSendReadReceipt) { + android.util.Log.d( + "MeshDelegateHandler", + "Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})" + ) + val nickname = state.getNicknameValue().ifBlank { "unknown" } + val mesh = getMeshService() + val sent = try { + val meshPeerID = ContactDirectory.resolve(senderConversationID).meshPeerID + ?: senderPeerID.takeIf { + com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it) } - } catch (_: Exception) { + if (meshPeerID != null && + mesh.getPeerInfo(meshPeerID)?.isConnected == true && + mesh.hasEstablishedSession(meshPeerID) + ) { + mesh.sendReadReceipt(message.id, meshPeerID, nickname) + true + } else { false } - if (sent) { - // Ensure unread badge is cleared for this peer immediately - try { - val current = state.getUnreadPrivateMessagesValue().toMutableSet() - val changed = current.remove(senderPeerID) or current.remove(senderConversationID) - if (changed) { - state.setUnreadPrivateMessages(current) - } - } catch (_: Exception) { } - } - } else { - android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") + } catch (_: Exception) { + false } + if (sent) { + // Receipt scheduling and local-read persistence are separate facts. Record + // the UI state only after the retryable receipt has been admitted. + try { markMessageReadLocally(message.id) } catch (_: Exception) { } + + // Ensure unread badge is cleared for this peer immediately + try { + val current = state.getUnreadPrivateMessagesValue().toMutableSet() + val changed = current.remove(senderPeerID) or current.remove(senderConversationID) + if (changed) { + state.setUnreadPrivateMessages(current) + } + } catch (_: Exception) { } + } + } else { + android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") } + } + /** * Expose mesh peer info for components that need to resolve identities (e.g., Nostr mapping) */ diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index 1af48a04..ff182cf8 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -33,7 +33,8 @@ class PrivateChatManager( private val state: ChatState, private val messageManager: MessageManager, private val dataManager: DataManager, - private val noiseSessionDelegate: NoiseSessionDelegate + private val noiseSessionDelegate: NoiseSessionDelegate, + private val hasReadReceiptBeenSent: (messageID: String) -> Boolean = { false } ) { companion object { @@ -398,7 +399,7 @@ class PrivateChatManager( senderPeerID == meshPeerID || ContactDirectory.canonicalConversationId(senderPeerID) == canonicalConversationID ) - if (isFromTarget && meshPeerID != null) { + if (isFromTarget && meshPeerID != null && !hasReadReceiptBeenSent(msg.id)) { try { if (hasMesh) { meshService.sendReadReceipt(msg.id, meshPeerID, myNickname) diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/RetryingControlPacketSenderTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/RetryingControlPacketSenderTest.kt new file mode 100644 index 00000000..7503b6bb --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/RetryingControlPacketSenderTest.kt @@ -0,0 +1,121 @@ +package com.bitchat.android.mesh + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RetryingControlPacketSenderTest { + @Test + fun `control packet is sent for the full redundant retry window`() = runTest { + val attempts = mutableListOf() + val sender = RetryingControlPacketSender( + scope = this, + maxAttempts = 3, + retryDelayMs = 10, + interSendDelayMs = 1 + ) + + sender.enqueue( + key = "peer:message", + sendAttempt = { attempt -> + attempts += attempt + true + } + ) + advanceUntilIdle() + + assertEquals(listOf(1, 2, 3), attempts) + } + + @Test + fun `duplicate enqueue is coalesced while receipt retry is active`() = runTest { + var firstRequestAttempts = 0 + var duplicateRequestAttempts = 0 + val sender = RetryingControlPacketSender( + scope = this, + maxAttempts = 3, + retryDelayMs = 10, + interSendDelayMs = 1 + ) + + sender.enqueue( + key = "peer:message", + sendAttempt = { + firstRequestAttempts += 1 + true + } + ) + sender.enqueue( + key = "peer:message", + sendAttempt = { + duplicateRequestAttempts += 1 + true + } + ) + advanceUntilIdle() + + assertEquals(3, firstRequestAttempts) + assertEquals(0, duplicateRequestAttempts) + } + + @Test + fun `transport writes for different receipts are serialized`() = runTest { + var activeWrites = 0 + var maximumActiveWrites = 0 + val sender = RetryingControlPacketSender( + scope = this, + maxAttempts = 1, + retryDelayMs = 0, + interSendDelayMs = 0 + ) + + fun enqueue(key: String) { + sender.enqueue( + key = key, + sendAttempt = { + activeWrites += 1 + maximumActiveWrites = maxOf(maximumActiveWrites, activeWrites) + delay(10) + activeWrites -= 1 + true + } + ) + } + + enqueue("peer:first") + enqueue("peer:second") + advanceUntilIdle() + + assertEquals(1, maximumActiveWrites) + } + + @Test + fun `completion reports whether transport accepted any attempt`() = runTest { + val completions = mutableListOf() + val sender = RetryingControlPacketSender( + scope = this, + maxAttempts = 3, + retryDelayMs = 1, + interSendDelayMs = 0 + ) + + sender.enqueue( + key = "peer:rejected", + sendAttempt = { false }, + onComplete = completions::add + ) + sender.enqueue( + key = "peer:eventually-accepted", + sendAttempt = { attempt -> attempt == 2 }, + onComplete = completions::add + ) + advanceUntilIdle() + + assertEquals(2, completions.size) + assertEquals(setOf(false, true), completions.toSet()) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt index 738b3231..a9ded74d 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt @@ -67,7 +67,7 @@ class NostrDirectMessageHandlerTest { ) val seenStore = mock() whenever(seenStore.hasDelivered(any())).thenReturn(true) - whenever(seenStore.hasRead(any())).thenReturn(false) + whenever(seenStore.hasBeenReadLocally(any())).thenReturn(false) val handler = NostrDirectMessageHandler( application = application, state = state, diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index 577823ac..a70d4fa9 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -1,8 +1,10 @@ package com.bitchat.android.services import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.util.Date @@ -137,4 +139,37 @@ class AppStateStoreTest { assertEquals(listOf(earlier, later), AppStateStore.privateMessages.value[contactID]) } + + @Test + fun `background receipt status persists and cannot be downgraded`() { + val message = BitchatMessage( + id = "outgoing-message", + sender = "bob", + content = "hello", + timestamp = Date(1), + isPrivate = true, + deliveryStatus = DeliveryStatus.Sending + ) + AppStateStore.addPrivateMessage("peer-a", message) + + AppStateStore.updatePrivateMessageStatus( + message.id, + DeliveryStatus.Delivered("peer-a", Date(2)) + ) + AppStateStore.updatePrivateMessageStatus( + message.id, + DeliveryStatus.Read("peer-a", Date(3)) + ) + AppStateStore.updatePrivateMessageStatus( + message.id, + DeliveryStatus.Delivered("peer-a", Date(4)) + ) + + val status = AppStateStore.privateMessages.value + .values + .flatten() + .single() + .deliveryStatus + assertTrue(status is DeliveryStatus.Read) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt index 63ba28ef..64d37810 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt @@ -1,6 +1,7 @@ package com.bitchat.android.ui import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.PeerInfo import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -16,6 +17,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import java.util.Date import java.util.concurrent.atomic.AtomicInteger @@ -29,6 +31,7 @@ class MeshDelegateHandlerStateContractTest { private lateinit var mesh: MeshService private lateinit var handler: MeshDelegateHandler private lateinit var haptics: AtomicInteger + private lateinit var locallyReadMessageIDs: MutableList @Before fun setUp() { @@ -41,6 +44,7 @@ class MeshDelegateHandlerStateContractTest { notifications = mock() mesh = mock() haptics = AtomicInteger() + locallyReadMessageIDs = mutableListOf() handler = MeshDelegateHandler( state = state, messageManager = messages, @@ -50,7 +54,8 @@ class MeshDelegateHandlerStateContractTest { coroutineScope = scope, onHapticFeedback = { haptics.incrementAndGet() }, getMyPeerID = { "self" }, - getMeshService = { mesh } + getMeshService = { mesh }, + markMessageReadLocally = locallyReadMessageIDs::add ) } @@ -89,6 +94,39 @@ class MeshDelegateHandlerStateContractTest { assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Read) } + @Test + fun `focused private message schedules receipt before recording local read`() { + val peerID = "1122334455667788" + val incoming = BitchatMessage( + id = "focused-private-message", + sender = "alice", + content = "hello", + timestamp = Date(1), + isPrivate = true, + senderPeerID = peerID + ) + whenever(notifications.getAppBackgroundState()).thenReturn(false) + whenever(notifications.getCurrentPrivateChatPeer()).thenReturn(peerID) + whenever(mesh.getPeerInfo(peerID)).thenReturn( + PeerInfo( + id = peerID, + nickname = "alice", + isConnected = true, + isDirectConnection = true, + noisePublicKey = ByteArray(32) { 1 }, + signingPublicKey = null, + isVerifiedNickname = false, + lastSeen = System.currentTimeMillis() + ) + ) + whenever(mesh.hasEstablishedSession(peerID)).thenReturn(true) + + handler.didReceiveMessage(incoming) + + verify(mesh).sendReadReceipt(incoming.id, peerID, "Résumé") + assertEquals(listOf(incoming.id), locallyReadMessageIDs) + } + @Test fun `unicode mention notifies once and duplicate transport delivery is suppressed`() { val incoming = message( diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt index 8e590949..dde48484 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt @@ -15,6 +15,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner @@ -106,6 +107,54 @@ class PrivateChatManagerTest { verify(meshService).sendReadReceipt(message.id, meshPeerID, "bob") } + @Test + fun `opening chat skips messages whose receipt send already completed`() { + val noiseKey = ByteArray(32) { 8 } + val meshPeerID = ContactIdentityResolver.peerIdForNoiseKey(noiseKey) + val conversationID = ContactIdentityResolver.contactConversationIdForNoiseKey(noiseKey) + val oldMessage = BitchatMessage( + id = "already-read", + sender = "alice", + content = "old", + timestamp = Date(1), + isPrivate = true, + senderPeerID = meshPeerID + ) + val unreadMessage = oldMessage.copy( + id = "still-unread", + content = "new", + timestamp = Date(2) + ) + val meshService = mock() + manager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = DataManager(RuntimeEnvironment.getApplication()), + noiseSessionDelegate = mock(), + hasReadReceiptBeenSent = { it == oldMessage.id } + ) + state.setNickname("bob") + state.setPrivateChats(mapOf(conversationID to listOf(oldMessage, unreadMessage))) + whenever(meshService.getPeerInfo(meshPeerID)).thenReturn( + PeerInfo( + id = meshPeerID, + nickname = "alice", + isConnected = true, + isDirectConnection = true, + noisePublicKey = noiseKey, + signingPublicKey = null, + isVerifiedNickname = false, + lastSeen = System.currentTimeMillis() + ) + ) + whenever(meshService.hasEstablishedSession(meshPeerID)).thenReturn(true) + + manager.sendReadReceiptsForPeer(conversationID, meshPeerID, meshService) + + verify(meshService, never()).sendReadReceipt(oldMessage.id, meshPeerID, "bob") + verify(meshService).sendReadReceipt(unreadMessage.id, meshPeerID, "bob") + } + @Test fun `canonical conversation send does not require resolved nickname`() { val conversationID = From 32d1916ec05b45c8af08453d7d4197eddc8a8d14 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:11:37 +0200 Subject: [PATCH 50/70] ui: show three-state favorite stars in people sheet Mirror the private-chat header so peers who favorited us get an orange outline until we favorite back. Co-authored-by: Cursor --- .../bitchat/android/ui/MeshPeerListSheet.kt | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 21c82b8f..27a6098f 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -358,7 +358,8 @@ fun PeopleSection( // Observe reactive state for favorites and fingerprints val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() - val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle() val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() @@ -370,6 +371,22 @@ fun PeopleSection( } } + // Same "they favorited us" signal the private-chat header uses for orange outline stars. + val peerTheyFavoritedUsStates = remember(peerFavoritedUs, peerFingerprints, connectedPeers) { + connectedPeers.associateWith { peerID -> + val fingerprint = peerFingerprints[peerID] + if (fingerprint != null && peerFavoritedUs.contains(fingerprint)) { + true + } else { + try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.theyFavoritedUs == true + } catch (_: Exception) { + false + } + } + } + } + val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) { connectedPeers.associateWith { peerID -> viewModel.isPeerVerified(peerID, verifiedFingerprints) @@ -474,6 +491,7 @@ fun PeopleSection( val peerID = connectedPeerForRow val conversationID = ContactDirectory.canonicalConversationId(peerID) val isFavorite = peerFavoriteStates[peerID] ?: false + val theyFavoritedUs = peerTheyFavoritedUsStates[peerID] ?: false val isVerified = peerVerifiedStates[peerID] ?: false // fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below @@ -499,6 +517,7 @@ fun PeopleSection( isWifiAware = peerID in wifiAwarePeerIDs, isSelected = conversationID == selectedPrivatePeer || peerID == selectedPrivatePeer, isFavorite = isFavorite, + theyFavoritedUs = theyFavoritedUs, isVerified = isVerified, hasUnreadDM = combinedHasUnread, colorScheme = colorScheme, @@ -548,6 +567,7 @@ fun PeopleSection( isDirect = false, isSelected = conversationID == selectedPrivatePeer || (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, isFavorite = true, + theyFavoritedUs = fav.theyFavoritedUs, isVerified = isVerified, hasUnreadDM = hasUnread, colorScheme = colorScheme, @@ -577,6 +597,7 @@ private fun PeerItem( isWifiAware: Boolean = false, isSelected: Boolean, isFavorite: Boolean, + theyFavoritedUs: Boolean = false, isVerified: Boolean, hasUnreadDM: Boolean, colorScheme: ColorScheme, @@ -703,13 +724,15 @@ private fun PeerItem( .clickable(onClick = onToggleFavorite), contentAlignment = Alignment.Center ) { + // Three-state star (matches private-chat header): grey outline (no relation), + // orange outline (they favorited us), filled orange (we favorited them). 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 + tint = if (isFavorite || theyFavoritedUs) palette.accentOrange else palette.textTertiary ) } } From 7a26fccff7e676ec4ff615359f826c2883566395 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:18:49 +0200 Subject: [PATCH 51/70] ui: move enable location services into nearby section Keep disable at the sheet footer so enabling is next to nearby channels while turning location off stays a secondary action. Co-authored-by: Cursor --- .../android/ui/LocationChannelsSheet.kt | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 19156485..505ea5b0 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -399,6 +399,18 @@ fun LocationChannelsSheet( AboutSectionLabel( text = stringResource(R.string.location_channels_nearby) ) + if (!appLocationEnabled) { + SheetDestructiveButton( + text = stringResource(R.string.enable_location_services), + isDestructive = false, + onClick = { locationManager.enableLocationServices() }, + modifier = Modifier.padding( + start = AboutHorizontalPadding, + end = AboutHorizontalPadding, + bottom = 10.dp + ) + ) + } Surface( modifier = Modifier .fillMaxWidth() @@ -561,27 +573,19 @@ fun LocationChannelsSheet( } } - item(key = "location_toggle") { - SheetDestructiveButton( - text = if (appLocationEnabled) { - stringResource(R.string.disable_location_services) - } else { - stringResource(R.string.enable_location_services) - }, - isDestructive = appLocationEnabled, - onClick = { - if (appLocationEnabled) { - locationManager.disableLocationServices() - } else { - locationManager.enableLocationServices() - } - }, - modifier = Modifier.padding( - start = AboutHorizontalPadding, - end = AboutHorizontalPadding, - top = 24.dp + if (appLocationEnabled) { + item(key = "location_toggle") { + SheetDestructiveButton( + text = stringResource(R.string.disable_location_services), + isDestructive = true, + onClick = { locationManager.disableLocationServices() }, + modifier = Modifier.padding( + start = AboutHorizontalPadding, + end = AboutHorizontalPadding, + top = 24.dp + ) ) - ) + } } } From c76c233aaea5e1ec6260f252652c35de533e6fb2 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:46:24 +0200 Subject: [PATCH 52/70] handshake robustness --- .../mesh/BluetoothConnectionManager.kt | 6 +- .../mesh/BluetoothGattClientManager.kt | 4 +- .../mesh/BluetoothGattServerManager.kt | 4 +- .../android/mesh/BluetoothMeshService.kt | 44 ++++- .../java/com/bitchat/android/mesh/MeshCore.kt | 12 +- .../bitchat/android/mesh/MessageHandler.kt | 68 ++++++-- .../bitchat/android/mesh/PacketProcessor.kt | 9 +- .../bitchat/android/mesh/SecurityManager.kt | 23 ++- .../android/noise/NoiseSessionManager.kt | 48 +++++- .../com/bitchat/android/util/AppConstants.kt | 2 + .../android/mesh/MessageHandlerTest.kt | 71 ++++++++ .../PacketProcessorAnnounceSideEffectTest.kt | 2 +- .../android/mesh/SecurityManagerTest.kt | 22 +++ ...NoiseSessionManagerHandshakeTimeoutTest.kt | 153 ++++++++++++++++++ 14 files changed, 430 insertions(+), 38 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerHandshakeTimeoutTest.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 3a75188c..c584455e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -67,8 +67,8 @@ class BluetoothConnectionManager( delegate?.onDeviceConnected(device) } - override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?) { - delegate?.onDeviceDisconnected(device, linkID) + override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) { + delegate?.onDeviceDisconnected(device, linkID, peerID) } override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { @@ -512,6 +512,6 @@ interface BluetoothConnectionManagerDelegate { ingressLinkID: String ) fun onDeviceConnected(device: BluetoothDevice) - fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?) + fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) fun onRSSIUpdated(deviceAddress: String, rssi: Int) } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index 66971ea4..cc13d5df 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -527,10 +527,12 @@ class BluetoothGattClientManager( } else { Log.i(TAG, "Disconnected from $deviceAddress (client)") } + // Capture the observed peer before cleanup drops the address mapping. + val disconnectedPeerID = connectionTracker.addressPeerMap[deviceAddress] connectionTracker.cleanupDeviceConnectionIfCurrent(deviceAddress, linkID) // Notify higher layers about device disconnection to update direct flags - delegate?.onDeviceDisconnected(gatt.device, linkID) + delegate?.onDeviceDisconnected(gatt.device, linkID, disconnectedPeerID) connectionScope.launch { delay(500) // CLEANUP_DELAY diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt index 53ae88ef..7a15b963 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -203,11 +203,13 @@ class BluetoothGattServerManager( BluetoothProfile.STATE_DISCONNECTED -> { Log.i(TAG, "Disconnected from ${device.address} (server)") val linkID = serverLinkIDs.remove(device.address) + // Capture the observed peer before cleanup drops the address mapping. + val disconnectedPeerID = connectionTracker.addressPeerMap[device.address] if (linkID != null) { connectionTracker.cleanupDeviceConnectionIfCurrent(device.address, linkID) } // Notify delegate about device disconnection so higher layers can update direct flags - delegate?.onDeviceDisconnected(device, linkID) + delegate?.onDeviceDisconnected(device, linkID, disconnectedPeerID) } } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 2e0507ab..37bcfffd 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -43,6 +43,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic companion object { private const val TAG = "BluetoothMeshService" private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS + private const val PEER_DISCONNECT_GRACE_MS = com.bitchat.android.util.AppConstants.Mesh.PEER_DISCONNECT_GRACE_MS } // Core components - each handling specific responsibilities @@ -410,6 +411,14 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun hasNoiseSession(peerID: String): Boolean { return encryptionService.hasEstablishedSession(peerID) } + + override fun removeNoiseSession(peerID: String) { + try { + encryptionService.removePeer(peerID) + } catch (e: Exception) { + Log.w(TAG, "Failed to remove Noise session for $peerID: ${e.message}") + } + } override fun initiateNoiseHandshake(peerID: String) { try { @@ -543,8 +552,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic return runBlocking { securityManager.handleNoiseHandshake(routed) } } - override fun handleNoiseEncrypted(routed: RoutedPacket) { - serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) } + override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { + return runBlocking { messageHandler.handleNoiseEncrypted(routed) } } override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { @@ -669,16 +678,41 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun onDeviceDisconnected( device: android.bluetooth.BluetoothDevice, - linkID: String? + linkID: String?, + peerID: String? ) { - Log.i(TAG, "Device disconnected: ${device.address}") + Log.i(TAG, "Device disconnected: ${device.address} (peerID: $peerID)") - // refresh peer list on disconnect. + // refresh peer list on disconnect. try { peerManager.refreshPeerList() } catch (_: Exception) { } // ConnectionTracker already removes an observed mapping only when this exact // link is still current. Do not remove by reusable address here: this may be a late // disconnect callback from a replaced GATT connection. + + // If the peer that used this link does not come back within a short grace + // period (no other link, no traffic), tear down their Noise session instead of + // waiting for the 3-minute stale-peer sweep. + if (peerID != null) { + val deviceAddress = device.address + val disconnectedAt = System.currentTimeMillis() + serviceScope.launch { + delay(PEER_DISCONNECT_GRACE_MS) + try { + val linkBack = + connectionManager.addressPeerMap.containsKey(deviceAddress) || + connectionManager.addressPeerMap.containsValue(peerID) + val lastSeen = peerManager.getPeerInfo(peerID)?.lastSeen ?: 0L + val seenAfterDisconnect = lastSeen > disconnectedAt + if (!linkBack && !seenAfterDisconnect) { + Log.i(TAG, "Peer $peerID did not return after disconnect; removing peer and Noise session") + peerManager.removePeer(peerID) + } + } catch (e: Exception) { + Log.w(TAG, "Disconnect grace check failed for $peerID: ${e.message}") + } + } + } } override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 2387e939..6fbddacc 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -364,6 +364,14 @@ class MeshCore( return encryptionService.hasEstablishedSession(peerID) } + override fun removeNoiseSession(peerID: String) { + try { + encryptionService.removePeer(peerID) + } catch (e: Exception) { + Log.w("MeshCore", "Failed to remove Noise session for $peerID: ${e.message}") + } + } + override fun initiateNoiseHandshake(peerID: String) { this@MeshCore.initiateNoiseHandshake(peerID) } @@ -439,8 +447,8 @@ class MeshCore( return runBlocking { securityManager.handleNoiseHandshake(routed) } } - override fun handleNoiseEncrypted(routed: RoutedPacket) { - scope.launch { messageHandler.handleNoiseEncrypted(routed) } + override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { + return runBlocking { messageHandler.handleNoiseEncrypted(routed) } } override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index b0f75a94..05a5bf00 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -27,52 +27,62 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro companion object { private const val TAG = "MessageHandler" private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L + private const val MAX_CONSECUTIVE_DECRYPT_FAILURES = 3 } - + // Delegate for callbacks var delegate: MessageHandlerDelegate? = null - + // Reference to PacketProcessor for recursive packet handling var packetProcessor: PacketProcessor? = null - + // Coroutines private val handlerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - + + // Consecutive decrypt failures per peer; only signature-verified packets reach this path, + // so repeated failures mean the established session is stale (peer re-handshaked elsewhere). + private val consecutiveDecryptFailures = java.util.concurrent.ConcurrentHashMap() + /** * Handle Noise encrypted transport message - SIMPLIFIED iOS-compatible version * Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService + * + * Returns false when the payload could not be decrypted, so callers can treat the + * packet as not liveness-proving (no lastSeen refresh, no relay). */ - suspend fun handleNoiseEncrypted(routed: RoutedPacket) { + suspend fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { val packet = routed.packet val peerID = routed.peerID ?: "unknown" - + // Skip our own messages - if (peerID == myPeerID) return - + if (peerID == myPeerID) return true + // Check if this message is for us val recipientID = packet.recipientID?.toHexString() if (recipientID != myPeerID) { - return + return true } - + try { // Decrypt the message using the Noise service val decryption = delegate?.decryptFromPeer(packet.payload, peerID) if (decryption == null) { Log.w(TAG, "Failed to decrypt Noise message from $peerID - may need handshake") - return + registerDecryptFailure(peerID) + return false } + consecutiveDecryptFailures.remove(peerID) val decryptedData = decryption.plaintext - + if (decryptedData.isEmpty()) { Log.w(TAG, "Decrypted data is empty from $peerID") - return + return true } - + val noisePayload = com.bitchat.android.model.NoisePayload.decode(decryptedData) if (noisePayload == null) { Log.w(TAG, "Failed to parse NoisePayload from $peerID") - return + return true } when (noisePayload.type) { @@ -86,7 +96,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro handleFavoriteNotificationFromMesh(pmContent, peerID) // Acknowledge delivery for UX parity sendDeliveryAck(privateMessage.messageID, peerID) - return + return true } // Create BitchatMessage - preserve source packet timestamp @@ -180,6 +190,31 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro } catch (e: Exception) { Log.e(TAG, "Error processing Noise encrypted message from $peerID: ${e.message}") } + return true + } + + /** + * Count consecutive decrypt failures from a signature-verified peer that we still hold an + * established session for. After repeated failures the session is stale (the peer completed + * a new handshake elsewhere), so destroy it and start a fresh handshake; the peer's side + * will finish via the responder-candidate path and evict its own stale session. + */ + private fun registerDecryptFailure(peerID: String) { + if (peerID == "unknown" || peerID == myPeerID) return + if (delegate?.hasNoiseSession(peerID) != true) return + val failures = (consecutiveDecryptFailures[peerID] ?: 0) + 1 + if (failures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) { + consecutiveDecryptFailures.remove(peerID) + Log.w(TAG, "Noise session with $peerID stale after $failures decrypt failures; resetting and re-handshaking") + try { delegate?.removeNoiseSession(peerID) } catch (e: Exception) { + Log.w(TAG, "Failed to reset Noise session for $peerID: ${e.message}") + } + try { delegate?.initiateNoiseHandshake(peerID) } catch (e: Exception) { + Log.w(TAG, "Failed to re-initiate handshake with $peerID: ${e.message}") + } + } else { + consecutiveDecryptFailures[peerID] = failures + } } /** @@ -646,6 +681,7 @@ interface MessageHandlerDelegate { // Noise protocol operations fun hasNoiseSession(peerID: String): Boolean fun initiateNoiseHandshake(peerID: String) + fun removeNoiseSession(peerID: String) {} fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? fun onAuthenticatedPeerStateReceived( peerID: String, diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index a5c59901..b00c6a31 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -144,7 +144,7 @@ class PacketProcessor(private val myPeerID: String) { if (packetRelayManager.isPacketAddressedToMe(packet)) { when (messageType) { MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed) - MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed) + MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed) MessageType.FILE_TRANSFER -> handleMessage(routed) else -> { validPacket = false @@ -175,9 +175,10 @@ class PacketProcessor(private val myPeerID: String) { /** * Handle Noise encrypted transport message + * Returns false when decryption fails so undecryptable packets do not prove liveness. */ - private suspend fun handleNoiseEncrypted(routed: RoutedPacket) { - delegate?.handleNoiseEncrypted(routed) + private suspend fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { + return delegate?.handleNoiseEncrypted(routed) ?: false } /** @@ -292,7 +293,7 @@ interface PacketProcessorDelegate { // Message type handlers fun handleNoiseHandshake(routed: RoutedPacket): Boolean - fun handleNoiseEncrypted(routed: RoutedPacket) + fun handleNoiseEncrypted(routed: RoutedPacket): Boolean suspend fun handleAnnounce(routed: RoutedPacket): Boolean fun handleMessage(routed: RoutedPacket) fun handleLeave(routed: RoutedPacket) diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 66698049..e3c0388a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -25,12 +25,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Security.CLEANUP_INTERVAL_MS // 5 minutes private const val MAX_PROCESSED_MESSAGES = com.bitchat.android.util.AppConstants.Security.MAX_PROCESSED_MESSAGES private const val MAX_PROCESSED_KEY_EXCHANGES = com.bitchat.android.util.AppConstants.Security.MAX_PROCESSED_KEY_EXCHANGES + private const val KEY_EXCHANGE_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.Security.KEY_EXCHANGE_DEDUP_TIMEOUT_MS } // Security tracking private val processedMessages = Collections.synchronizedSet(mutableSetOf()) private val processedKeyExchanges = Collections.synchronizedSet(mutableSetOf()) private val messageTimestamps = Collections.synchronizedMap(mutableMapOf()) + private val keyExchangeTimestamps = Collections.synchronizedMap(mutableMapOf()) // Delegate for callbacks var delegate: SecurityManagerDelegate? = null @@ -134,6 +136,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private // from ambient session state: a rejected replacement may leave the old session active. val result = encryptionService.processHandshakeMessageWithResult(packet.payload, peerID) processedKeyExchanges.add(exchangeKey) + keyExchangeTimestamps[exchangeKey] = System.currentTimeMillis() if (result.response != null) { // Send handshake response through delegate @@ -393,8 +396,8 @@ class SecurityManager(private val encryptionService: EncryptionService, private /** * Clean up old processed messages and timestamps */ - private fun cleanupOldData() { - val cutoffTime = System.currentTimeMillis() - MESSAGE_TIMEOUT + internal fun cleanupOldData(nowMs: Long = System.currentTimeMillis()) { + val cutoffTime = nowMs - MESSAGE_TIMEOUT // Clean up old message timestamps and corresponding processed messages val messagesToRemove = messageTimestamps.entries.filter { (_, timestamp) -> @@ -413,12 +416,25 @@ class SecurityManager(private val encryptionService: EncryptionService, private processedMessages.removeAll(toRemove.toSet()) removeFromMessageTimestamps(toRemove) } - + + // Expire handshake dedup entries by time so a delayed same-ephemeral delivery + // (e.g. a re-handshake retry after a failed attempt) is not blocked forever. + val keyExchangeCutoff = nowMs - KEY_EXCHANGE_DEDUP_TIMEOUT + val keyExchangesToRemove = keyExchangeTimestamps.entries.filter { (_, timestamp) -> + timestamp < keyExchangeCutoff + }.map { it.key } + + keyExchangesToRemove.forEach { exchangeKey -> + keyExchangeTimestamps.remove(exchangeKey) + processedKeyExchanges.remove(exchangeKey) + } + // Limit the size of processed key exchanges set if (processedKeyExchanges.size > MAX_PROCESSED_KEY_EXCHANGES) { val excess = processedKeyExchanges.size - MAX_PROCESSED_KEY_EXCHANGES val toRemove = processedKeyExchanges.take(excess) processedKeyExchanges.removeAll(toRemove.toSet()) + toRemove.forEach { keyExchangeTimestamps.remove(it) } } } @@ -438,6 +454,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private processedMessages.clear() processedKeyExchanges.clear() messageTimestamps.clear() + keyExchangeTimestamps.clear() } /** diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt index c089255f..dd39eb91 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt @@ -2,6 +2,8 @@ package com.bitchat.android.noise import android.util.Log import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit data class NoiseHandshakeProcessingResult( val response: ByteArray?, @@ -50,15 +52,30 @@ class NoiseSessionManager( companion object { private const val TAG = "NoiseSessionManager" - private const val HANDSHAKE_TIMEOUT_MS = 20_000L + private const val HANDSHAKE_TIMEOUT_MS = 10_000L + private const val HANDSHAKE_SWEEP_INTERVAL_MS = 2_000L private const val HANDSHAKE_MESSAGE_1_SIZE = 32 private const val SESSION_TOKEN_SIZE = 32 } - + private val sessions = ConcurrentHashMap() // An inbound replacement handshake must prove its authenticated static-key binding before it // can evict a working transport session. Keep responder candidates outside the active map. private val responderCandidates = ConcurrentHashMap() + + private val sweepScheduler = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "NoiseHandshakeSweeper").apply { isDaemon = true } + } + + init { + sweepScheduler.scheduleWithFixedDelay({ + try { + cleanupStaleHandshakes(System.currentTimeMillis()) + } catch (e: Exception) { + Log.w(TAG, "Handshake sweep failed: ${e.message}") + } + }, HANDSHAKE_SWEEP_INTERVAL_MS, HANDSHAKE_SWEEP_INTERVAL_MS, TimeUnit.MILLISECONDS) + } // Callbacks var onSessionEstablished: ((String, ByteArray) -> Unit)? = null @@ -289,6 +306,31 @@ class NoiseSessionManager( if (lastActivity == null) return false return (nowMs - lastActivity) > HANDSHAKE_TIMEOUT_MS } + + /** + * Actively expire handshakes that stopped progressing (lost response, abandoned + * responder candidates). Established sessions are never touched here. + */ + @Synchronized + fun cleanupStaleHandshakes(nowMs: Long) { + sessions.entries.toList().forEach { (peerID, session) -> + if (session.isHandshaking() && isHandshakeStale(session, nowMs)) { + Log.d(TAG, "Expiring stale handshake with $peerID") + if (sessions.remove(peerID, session)) { + session.destroy() + runCatching { onSessionFailed?.invoke(peerID, NoiseSessionError.HandshakeTimeout) } + } + } + } + responderCandidates.entries.toList().forEach { (peerID, session) -> + if (session.isHandshaking() && isHandshakeStale(session, nowMs)) { + Log.d(TAG, "Expiring stale responder candidate for $peerID") + if (responderCandidates.remove(peerID, session)) { + session.destroy() + } + } + } + } /** * SIMPLIFIED: Encrypt data @@ -427,6 +469,7 @@ class NoiseSessionManager( */ @Synchronized fun shutdown() { + sweepScheduler.shutdownNow() sessions.values.forEach { it.destroy() } responderCandidates.values.forEach { it.destroy() } sessions.clear() @@ -443,6 +486,7 @@ sealed class NoiseSessionError(message: String, cause: Throwable? = null) : Exce object SessionNotEstablished : NoiseSessionError("Session not established") object InvalidState : NoiseSessionError("Session in invalid state") object HandshakeFailed : NoiseSessionError("Handshake failed") + object HandshakeTimeout : NoiseSessionError("Handshake timed out") object AlreadyEstablished : NoiseSessionError("Session already established") object SessionGenerationChanged : NoiseSessionError("Noise session generation changed") class PeerIdentityMismatch(claimedPeerID: String, derivedPeerID: String?) : NoiseSessionError( diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 4905c927..4dec775f 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -14,6 +14,7 @@ object AppConstants { // Peer lifecycle const val STALE_PEER_TIMEOUT_MS: Long = 180_000L // 3 minutes const val PEER_CLEANUP_INTERVAL_MS: Long = 60_000L + const val PEER_DISCONNECT_GRACE_MS: Long = 10_000L // BLE connection tracking const val CONNECTION_RETRY_DELAY_MS: Long = 5_000L @@ -52,6 +53,7 @@ object AppConstants { const val CLEANUP_INTERVAL_MS: Long = 300_000L const val MAX_PROCESSED_MESSAGES: Int = 10_000 const val MAX_PROCESSED_KEY_EXCHANGES: Int = 1_000 + const val KEY_EXCHANGE_DEDUP_TIMEOUT_MS: Long = 60_000L } object Noise { diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt index 945155fd..79bc4c2d 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt @@ -374,6 +374,77 @@ class MessageHandlerTest { Unit } + @Test + fun `repeated decrypt failures reset stale session and re-handshake`() = runBlocking { + whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(null) + whenever(delegate.hasNoiseSession(peerID)).thenReturn(true) + val packet = encryptedPacket() + + repeat(2) { + assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link"))) + } + verify(delegate, never()).removeNoiseSession(any()) + verify(delegate, never()).initiateNoiseHandshake(any()) + + assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link"))) + verify(delegate).removeNoiseSession(peerID) + verify(delegate).initiateNoiseHandshake(peerID) + } + + @Test + fun `successful decrypt resets the failure counter`() = runBlocking { + whenever(delegate.hasNoiseSession(peerID)).thenReturn(true) + val plaintext = NoisePayload(NoisePayloadType.DELIVERED, "id-1".toByteArray()).encode() + whenever(delegate.decryptFromPeer(any(), eq(peerID))) + .thenReturn(null) + .thenReturn(null) + .thenReturn(NoiseDecryptionResult(plaintext, authenticatedSession)) + .thenReturn(null) + .thenReturn(null) + val packet = encryptedPacket() + + repeat(5) { + handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link")) + } + + verify(delegate, never()).removeNoiseSession(any()) + verify(delegate, never()).initiateNoiseHandshake(any()) + } + + @Test + fun `decrypt failures without an established session never reset`() = runBlocking { + whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(null) + whenever(delegate.hasNoiseSession(peerID)).thenReturn(false) + val packet = encryptedPacket() + + repeat(4) { + assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link"))) + } + + verify(delegate, never()).removeNoiseSession(any()) + verify(delegate, never()).initiateNoiseHandshake(any()) + } + + @Test + fun `successful decrypt reports the packet as valid`() = runBlocking { + val plaintext = NoisePayload(NoisePayloadType.DELIVERED, "id-2".toByteArray()).encode() + whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn( + NoiseDecryptionResult(plaintext, authenticatedSession) + ) + + assertTrue(handler.handleNoiseEncrypted(RoutedPacket(encryptedPacket(), peerID, "direct-link"))) + } + + private fun encryptedPacket(): BitchatPacket = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = peerID.hexToBytes(), + recipientID = myPeerID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = byteArrayOf(0x41, 0x42, 0x43), + ttl = 7u + ) + private fun announcePacket( ageMs: Long, ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(), diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt index df0344cf..3a676382 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt @@ -116,7 +116,7 @@ class PacketProcessorAnnounceSideEffectTest { handshakeHandled.complete(Unit) return acceptHandshake } - override fun handleNoiseEncrypted(routed: RoutedPacket) = Unit + override fun handleNoiseEncrypted(routed: RoutedPacket) = true override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { handled.complete(Unit) return acceptAnnounce diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt index 17156e00..2a56e708 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt @@ -542,6 +542,28 @@ class SecurityManagerTest { ) } + @Test + fun `handshake dedup entries expire by time and allow delayed retry`() = runBlocking { + val payload = byteArrayOf(0x71, 0x72, 0x73) + val routed = handshakePacket(payload) + + assertTrue(securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.handshakeCalls == 1) + + assertFalse("Identical frame inside the dedup window must be dropped", + securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.handshakeCalls == 1) + + securityManager.cleanupOldData( + System.currentTimeMillis() + + com.bitchat.android.util.AppConstants.Security.KEY_EXCHANGE_DEDUP_TIMEOUT_MS + 1_000 + ) + + assertTrue("Expired dedup entries must not block a delayed retry", + securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.handshakeCalls == 2) + } + private fun setupKnownPeer(peerID: String, signingKey: ByteArray) { val info = PeerInfo( id = peerID, diff --git a/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerHandshakeTimeoutTest.kt b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerHandshakeTimeoutTest.kt new file mode 100644 index 00000000..f180ea8e --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerHandshakeTimeoutTest.kt @@ -0,0 +1,153 @@ +package com.bitchat.android.noise + +import com.bitchat.android.noise.southernstorm.protocol.Noise +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicReference + +class NoiseSessionManagerHandshakeTimeoutTest { + private data class TestIdentity( + val privateKey: ByteArray, + val publicKey: ByteArray, + val peerID: String + ) + + private val managers = mutableListOf() + + @After + fun tearDown() { + managers.forEach(NoiseSessionManager::shutdown) + } + + @Test + fun `stale initiator handshake is expired and reported as timeout`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val failure = AtomicReference() + aliceManager.onSessionFailed = { peerID, error -> + if (peerID == bob.peerID) failure.set(error) + } + + assertNotNull(aliceManager.initiateHandshake(bob.peerID)) + assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking()) + + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000) + + assertNull(aliceManager.getSession(bob.peerID)) + assertTrue(failure.get() is NoiseSessionError.HandshakeTimeout) + } + + @Test + fun `fresh handshake is not expired`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + + assertNotNull(aliceManager.initiateHandshake(bob.peerID)) + + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 9_000) + + assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking()) + } + + @Test + fun `expired handshake can be re-initiated immediately`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + + assertNotNull(aliceManager.initiateHandshake(bob.peerID)) + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000) + + assertNotNull("Handshake must restart after expiry", aliceManager.initiateHandshake(bob.peerID)) + assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking()) + } + + @Test + fun `established session is never expired by the sweep`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + val aliceSession = aliceManager.getSession(bob.peerID) + val bobSession = bobManager.getSession(alice.peerID) + + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 60_000) + bobManager.cleanupStaleHandshakes(System.currentTimeMillis() + 60_000) + + assertSame(aliceSession, aliceManager.getSession(bob.peerID)) + assertSame(bobSession, bobManager.getSession(alice.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + assertTrue(bobManager.hasEstablishedSession(alice.peerID)) + + val plaintext = "still alive".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID)) + } + + @Test + fun `stale responder candidate expires while established session survives`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + val established = aliceManager.getSession(bob.peerID) + + // Bob comes back and starts a replacement handshake: alice keeps the established + // session and parks the new handshake as a responder candidate. + val replacementManager = manager(bob) + val message1 = replacementManager.initiateHandshake(alice.peerID)!! + assertNotNull(aliceManager.processHandshakeMessage(bob.peerID, message1)) + assertSame(established, aliceManager.getSession(bob.peerID)) + + // Bob never finishes (message 2 lost): the candidate must expire without touching + // the working session. + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000) + + assertSame(established, aliceManager.getSession(bob.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + val plaintext = "unharmed".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID)) + } + + private fun completeHandshake( + initiator: NoiseSessionManager, + initiatorPeerID: String, + responder: NoiseSessionManager, + responderPeerID: String + ) { + val message1 = initiator.initiateHandshake(responderPeerID)!! + val message2 = responder.processHandshakeMessage(initiatorPeerID, message1)!! + val message3 = initiator.processHandshakeMessage(responderPeerID, message2)!! + assertNull(responder.processHandshakeMessage(initiatorPeerID, message3)) + } + + private fun manager(identity: TestIdentity): NoiseSessionManager = NoiseSessionManager( + localStaticPrivateKey = identity.privateKey, + localStaticPublicKey = identity.publicKey, + localPeerID = identity.peerID + ).also { managers += it } + + private fun identity(): TestIdentity { + val dh = Noise.createDH("25519") + return try { + dh.generateKeyPair() + val privateKey = ByteArray(32) + val publicKey = ByteArray(32) + dh.getPrivateKey(privateKey, 0) + dh.getPublicKey(publicKey, 0) + TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!) + } finally { + dh.destroy() + } + } +} From 83b2c5130184a6cea2c12270c607cdacc52bc71d Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:58:17 +0200 Subject: [PATCH 53/70] fix: keep contact nickname in private chat title when peer goes offline --- .../android/services/ContactDirectory.kt | 20 ++++++- .../bitchat/android/ui/VerificationHandler.kt | 4 ++ .../android/services/ContactDirectoryTest.kt | 59 +++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt diff --git a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt index 5301df6d..b3ffc429 100644 --- a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt +++ b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt @@ -26,6 +26,10 @@ object ContactDirectory { @Volatile private var meshProvider: (() -> MeshService?)? = null + @Volatile + internal var identityManagerProvider: (Context) -> SecureIdentityStateManager = + { SecureIdentityStateManager(it) } + fun initialize(context: Context, meshProvider: () -> MeshService?) { appContext = context.applicationContext this.meshProvider = meshProvider @@ -79,7 +83,8 @@ object ContactDirectory { noisePublicKey = noiseKey ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.noisePublicKey }, nostrPubkey = favorite?.peerNostrPublicKey, displayName = favorite?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } - ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname }, + ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname } + ?: contactFingerprint?.let { cachedFingerprintNickname(it) }, isMutualFavorite = favorite?.isMutual == true ) } @@ -132,7 +137,7 @@ object ContactDirectory { private fun cachedNoiseKey(peerID: String): ByteArray? { val context = appContext ?: return null return try { - SecureIdentityStateManager(context) + identityManagerProvider(context) .getCachedNoiseKey(peerID) ?.let { ContactIdentityResolver.bytesFromHex(it) } } catch (_: Exception) { @@ -140,6 +145,17 @@ object ContactDirectory { } } + private fun cachedFingerprintNickname(fingerprint: String): String? { + val context = appContext ?: return null + return try { + identityManagerProvider(context) + .getCachedFingerprintNickname(fingerprint) + ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } + } catch (_: Exception) { + null + } + } + private fun favoriteForMeshPeerID(peerID: String): FavoriteRelationship? = try { FavoritesPersistenceService.shared.getFavoriteStatus(peerID) diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt index 28c051bc..8003508e 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt @@ -8,6 +8,7 @@ import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.noise.NoiseSession import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.services.ContactIdentityResolver import com.bitchat.android.services.VerificationService import com.bitchat.android.util.dataFromHexString import com.bitchat.android.util.hexEncodedString @@ -185,6 +186,9 @@ class VerificationHandler( val hexRegex = Regex("^[0-9a-fA-F]+$") return try { when { + ContactIdentityResolver.isContactConversationId(peerID) -> { + ContactIdentityResolver.fingerprintFromContactConversationId(peerID) + } peerID.length == 64 && peerID.matches(hexRegex) -> { identityManager.getCachedNoiseFingerprint(peerID)?.let { return it } fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) } diff --git a/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt new file mode 100644 index 00000000..e16996f1 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt @@ -0,0 +1,59 @@ +package com.bitchat.android.services + +import android.content.Context +import android.os.Build +import com.bitchat.android.identity.SecureIdentityStateManager +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class ContactDirectoryTest { + + private lateinit var identityManager: SecureIdentityStateManager + + @Before + fun setup() { + val context = RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences( + "contact-directory-test-${UUID.randomUUID()}", + Context.MODE_PRIVATE + ) + identityManager = SecureIdentityStateManager(prefs, testOnly = true) + ContactDirectory.initialize(context) { null } + ContactDirectory.identityManagerProvider = { identityManager } + } + + @After + fun tearDown() { + ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) } + } + + @Test + fun `offline contact resolves display name from cached fingerprint nickname`() { + val fingerprint = "ab".repeat(32) + identityManager.cacheFingerprintNickname(fingerprint, "Alice") + + val resolution = ContactDirectory.resolve("contact_$fingerprint") + + assertEquals("Alice", resolution.displayName) + assertNull(resolution.meshPeerID) + } + + @Test + fun `offline contact without cached nickname has no display name`() { + val fingerprint = "cd".repeat(32) + + val resolution = ContactDirectory.resolve("contact_$fingerprint") + + assertNull(resolution.displayName) + } +} From 719b3d1895ac400bd16321089f44b5d57c5f76ae Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:06:46 +0200 Subject: [PATCH 54/70] ui: mute theme-aware peer colors for contrast Extract PeerColorStyle so each palette owns saturation/value, keeping hues stable while dark mode stays bright-but-muted and light mode avoids neon labels. New themes only need to supply their own style. Co-authored-by: Cursor --- .../android/ui/theme/BitchatPalette.kt | 15 ++++----- .../bitchat/android/ui/theme/PeerColors.kt | 32 +++++++++++++++++-- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 21 ++++++++---- 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt index 96ef57d4..8dfb87b0 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -41,10 +41,11 @@ data class BitchatPalette( 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, + /** + * Saturation/value applied after deriving a peer's stable hue. Swap this when adding a + * new theme — see [PeerColorStyle] for contrast guidelines. + */ + val peerColors: PeerColorStyle, ) val DarkBitchatPalette = BitchatPalette( @@ -56,8 +57,7 @@ val DarkBitchatPalette = BitchatPalette( textTertiary = Color(0xFF6B776B), accentOrange = Color(0xFFFF9F0A), accentPurple = Color(0xFFBF5AF2), - peerColorSaturation = 1f, - peerColorValue = 1f, + peerColors = PeerColorStyle.Dark, ) val LightBitchatPalette = BitchatPalette( @@ -69,8 +69,7 @@ val LightBitchatPalette = BitchatPalette( textTertiary = Color(0xFF757F75), accentOrange = Color(0xFFFF9500), accentPurple = Color(0xFFAF52DE), - peerColorSaturation = 0.85f, - peerColorValue = 0.45f, + peerColors = PeerColorStyle.Light, ) val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt index b0f1ed7d..168c9863 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt @@ -1,9 +1,36 @@ package com.bitchat.android.ui.theme +import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color import com.bitchat.android.ui.PeerIdentity import kotlin.math.abs +/** + * Theme-specific chroma applied after a peer's stable hue is derived. + * + * Hue stays identity-stable across themes (and byte-identical to iOS). Only saturation + * and value change so peer labels remain readable on each background. + * + * Guidelines when adding a future theme: + * - Dim / dark backgrounds: keep [value] high so colors are not lost against the surface; + * prefer muted [saturation] over neon. + * - Light backgrounds: keep [value] moderate-low so colors are not blinding; avoid + * near-full saturation. + */ +@Immutable +data class PeerColorStyle( + val saturation: Float, + val value: Float, +) { + companion object { + /** Soft pastels that stay bright enough on near-black chat surfaces. */ + val Dark = PeerColorStyle(saturation = 0.55f, value = 0.82f) + + /** Deeper, less saturated tones that stay readable on near-white surfaces. */ + val Light = PeerColorStyle(saturation = 0.70f, value = 0.42f) + } +} + /** * The single identity-to-color boundary used by chat, people sheets, and mentions. * @@ -22,9 +49,10 @@ fun colorForPeer(identity: PeerIdentity, palette: BitchatPalette): Color { hue = (hue + 0.12) % 1.0 } + val style = palette.peerColors return Color.hsv( hue = (hue * 360).toFloat(), - saturation = palette.peerColorSaturation, - value = palette.peerColorValue + saturation = style.saturation, + value = style.value ) } diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index e64448c3..69693b7e 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -14,6 +14,7 @@ import com.bitchat.android.ui.theme.LightBitchatColorScheme import com.bitchat.android.ui.theme.LightBitchatPalette import com.bitchat.android.ui.theme.MessageBodyTextStyle import com.bitchat.android.ui.theme.MessageSenderTextStyle +import com.bitchat.android.ui.theme.PeerColorStyle import com.bitchat.android.ui.theme.colorForPeer import java.text.SimpleDateFormat import java.util.Date @@ -444,8 +445,8 @@ class ChatUIUtilsTest { @Test fun `peer color hue is stable across light and dark, only chroma differs`() { - // Hue derivation must stay byte-identical to iOS; only saturation/value are tuned for - // the redesigned neutral message body. + // Hue derivation must stay byte-identical to iOS; only saturation/value are tuned per + // theme so dark mode stays muted-but-bright and light mode stays deep-but-readable. val identity = PeerIdentity.mesh("abc") val dark = colorForPeer(identity, DarkBitchatPalette) val light = colorForPeer(identity, LightBitchatPalette) @@ -456,10 +457,16 @@ class ChatUIUtilsTest { rgbToHsv(light.red, light.green, light.blue, lightHsv) assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0) - assertEquals(1.0, darkHsv[1].toDouble(), 0.01) - assertEquals(1.0, darkHsv[2].toDouble(), 0.01) - assertEquals(0.85, lightHsv[1].toDouble(), 0.01) - assertEquals(0.45, lightHsv[2].toDouble(), 0.01) + assertEquals(PeerColorStyle.Dark.saturation.toDouble(), darkHsv[1].toDouble(), 0.01) + assertEquals(PeerColorStyle.Dark.value.toDouble(), darkHsv[2].toDouble(), 0.01) + assertEquals(PeerColorStyle.Light.saturation.toDouble(), lightHsv[1].toDouble(), 0.01) + assertEquals(PeerColorStyle.Light.value.toDouble(), lightHsv[2].toDouble(), 0.01) + // Dark theme: muted chroma, never dark (readable on near-black). + assertTrue(darkHsv[1] < 0.75f) + assertTrue(darkHsv[2] >= 0.75f) + // Light theme: avoid neon / near-white peer labels. + assertTrue(lightHsv[1] < 0.85f) + assertTrue(lightHsv[2] <= 0.55f) } @Test @@ -485,7 +492,7 @@ class ChatUIUtilsTest { assertEquals(Color(0xFFF5F5F5), DarkBitchatColorScheme.onSurface) assertTrue(LightBitchatColorScheme.onSurface != DarkBitchatColorScheme.onSurface) assertTrue( - LightBitchatPalette.peerColorValue != DarkBitchatPalette.peerColorValue + LightBitchatPalette.peerColors != DarkBitchatPalette.peerColors ) } From 7dbd65b4d0940140ada5dc241b402df009f4161a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:08:10 +0200 Subject: [PATCH 55/70] fix: honor receipt transport acceptance --- .../mesh/BluetoothConnectionManager.kt | 10 +++ .../android/mesh/BluetoothMeshService.kt | 17 ++++- .../mesh/BluetoothPacketBroadcaster.kt | 54 ++++++++++++-- .../java/com/bitchat/android/mesh/MeshCore.kt | 14 +++- .../com/bitchat/android/mesh/MeshTransport.kt | 5 +- .../android/service/TransportBridgeService.kt | 73 ++++++++++++++++++- .../com/bitchat/android/ui/ChatViewModel.kt | 13 +--- .../bitchat/android/ui/MeshDelegateHandler.kt | 34 ++++----- .../bitchat/android/ui/PrivateChatManager.kt | 10 ++- .../wifi-aware/WifiAwareMeshService.kt | 23 +++--- .../android/ui/CommandProcessorTest.kt | 41 +++++++++++ .../service/TransportBridgeServiceTest.kt | 44 +++++++++++ .../MeshDelegateHandlerStateContractTest.kt | 24 +++++- 13 files changed, 305 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 3a75188c..d16a24b3 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -329,6 +329,16 @@ class BluetoothConnectionManager( ) } + suspend fun broadcastControlPacketAndAwaitAcceptance(routed: RoutedPacket): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + + return packetBroadcaster.broadcastControlPacketAndAwaitAcceptance( + routed, + serverManager.getGattServer(), + serverManager.getCharacteristic() + ) + } + fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { if (!isActive || !isBleTransportEnabled()) return false return packetBroadcaster.sendToPeer( diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 76ff2dbe..512dff98 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -174,6 +174,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic connectionManager.broadcastPacket(packet) } + override suspend fun sendAndReport(packet: RoutedPacket): Boolean { + if (!isBleTransportEnabled()) return false + return connectionManager.broadcastControlPacketAndAwaitAcceptance(packet) + } + override fun sendToPeer(peerID: String, packet: BitchatPacket) { if (!isBleTransportEnabled()) return connectionManager.sendPacketToPeer(peerID, packet) @@ -187,6 +192,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic return true } + private suspend fun broadcastRoutedPacketAndReport(routed: RoutedPacket): Boolean { + if (!isBleTransportEnabled()) return false + val acceptedByBle = + connectionManager.broadcastControlPacketAndAwaitAcceptance(routed) + val acceptedByBridgedTransport = + TransportBridgeService.broadcastAndReport("BLE", routed) + return acceptedByBle || acceptedByBridgedTransport + } + private fun isBleTransportEnabled(): Boolean { return try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value @@ -1087,7 +1101,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic sendAttempt = { attempt -> // Keep the addressed packet on the normal broadcaster actor so receipt // attempts are ordered with other BLE traffic and can use mesh routing. - val accepted = broadcastRoutedPacket(RoutedPacket(signedPacket)) + val accepted = + broadcastRoutedPacketAndReport(RoutedPacket(signedPacket)) Log.d( TAG, "Read receipt attempt $attempt accepted=$accepted " + diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 0e9b0c76..1ec1baea 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -10,6 +10,8 @@ import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.util.toHexString +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -110,7 +112,8 @@ class BluetoothPacketBroadcaster( private data class BroadcastRequest( val routed: RoutedPacket, val gattServer: BluetoothGattServer?, - val characteristic: BluetoothGattCharacteristic? + val characteristic: BluetoothGattCharacteristic?, + val accepted: CompletableDeferred? = null ) // Actor scope for the broadcaster @@ -123,7 +126,17 @@ class BluetoothPacketBroadcaster( capacity = Channel.UNLIMITED ) { for (request in channel) { - broadcastSinglePacketInternal(request.routed, request.gattServer, request.characteristic) + val accepted = try { + broadcastSinglePacketInternal( + request.routed, + request.gattServer, + request.characteristic + ) + } catch (e: Exception) { + Log.w(TAG, "Broadcast request failed: ${e.message}") + false + } + request.accepted?.complete(accepted) } } @@ -242,6 +255,29 @@ class BluetoothPacketBroadcaster( } } + /** + * Serializes a small control packet with normal BLE traffic and waits for the platform write + * API to accept at least one notification/write. + */ + suspend fun broadcastControlPacketAndAwaitAcceptance( + routed: RoutedPacket, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean { + val accepted = CompletableDeferred() + return try { + broadcasterActor.send( + BroadcastRequest(routed, gattServer, characteristic, accepted) + ) + accepted.await() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Failed to queue control packet: ${e.message}") + broadcastSinglePacketInternal(routed, gattServer, characteristic) + } + } + /** * Targeted send to a specific peer (by peerID) if directly connected. * Returns true if sent to at least one matching connection. @@ -274,11 +310,11 @@ class BluetoothPacketBroadcaster( routed: RoutedPacket, gattServer: BluetoothGattServer?, characteristic: BluetoothGattCharacteristic? - ) { + ): Boolean { val packet = routed.packet // iOS-compatible: Use selective padding policy for BLE val padForBLE = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type) - val data = packet.toBinaryData(padding = padForBLE) ?: return + val data = packet.toBinaryData(padding = padForBLE) ?: return false val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString() val senderPeerID = routed.peerID ?: packet.senderID.toHexString() val incomingAddr = routed.relayAddress @@ -320,7 +356,7 @@ class BluetoothPacketBroadcaster( } } - if (sent) return + if (sent) return true Log.d(TAG, "Source Routing: First hop $firstHop not connected. Falling back to standard broadcast logic.") } @@ -337,7 +373,7 @@ class BluetoothPacketBroadcaster( if (notifyDevice(targetDevice, data, gattServer, characteristic)) { val toPeer = connectionTracker.addressPeerMap[targetDevice.address] logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl, packet.version, routeInfo) - return // Sent, no need to continue + return true } } @@ -350,7 +386,7 @@ class BluetoothPacketBroadcaster( if (writeToDeviceConn(targetDeviceConn, data)) { val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address] logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl, packet.version, routeInfo) - return // Sent, no need to continue + return true } } } @@ -360,6 +396,7 @@ class BluetoothPacketBroadcaster( val connectedDevices = connectionTracker.getConnectedDevices() val senderID = packet.senderID.toHexString() + var accepted = false // Send to server connections (devices connected to our GATT server) subscribedDevices.forEach { device -> @@ -371,6 +408,7 @@ class BluetoothPacketBroadcaster( } val sent = notifyDevice(device, data, gattServer, characteristic) if (sent) { + accepted = true val toPeer = connectionTracker.addressPeerMap[device.address] logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl, packet.version, routeInfo) } @@ -387,11 +425,13 @@ class BluetoothPacketBroadcaster( } val sent = writeToDeviceConn(deviceConn, data) if (sent) { + accepted = true val toPeer = connectionTracker.addressPeerMap[deviceConn.device.address] logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl, packet.version, routeInfo) } } } + return accepted } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index d1185428..b2b95029 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -192,11 +192,22 @@ class MeshCore( transport.broadcastPacket(packet) } + fun sendFromBridgeAndReport(packet: RoutedPacket): Boolean { + return transport.broadcastPacket(packet) + } + private fun dispatchGlobal(routed: RoutedPacket) { transport.broadcastPacket(routed) TransportBridgeService.broadcast(transport.id, routed) } + private suspend fun dispatchGlobalAndReport(routed: RoutedPacket): Boolean { + val acceptedByLocalTransport = transport.broadcastPacket(routed) + val acceptedByBridgedTransport = + TransportBridgeService.broadcastAndReport(transport.id, routed) + return acceptedByLocalTransport || acceptedByBridgedTransport + } + private fun startPeriodicBroadcastAnnounce() { announceJob?.cancel() announceJob = scope.launch { @@ -709,8 +720,7 @@ class MeshCore( readReceiptRetrySender.enqueue( key = retryKey, sendAttempt = { - dispatchGlobal(RoutedPacket(signedPacket)) - true + dispatchGlobalAndReport(RoutedPacket(signedPacket)) }, onComplete = { accepted -> if (accepted) { diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt index b3f2fcc4..354fcd7e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt @@ -9,7 +9,10 @@ import com.bitchat.android.protocol.BitchatPacket interface MeshTransport { val id: String - fun broadcastPacket(routed: RoutedPacket) + /** + * Broadcasts a packet and reports whether at least one concrete transport write was accepted. + */ + fun broadcastPacket(routed: RoutedPacket): Boolean fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt index f8b10766..67a7a1d8 100644 --- a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt +++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt @@ -4,6 +4,7 @@ import android.util.Log import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.util.toHexString +import kotlinx.coroutines.CancellationException import java.security.MessageDigest import java.util.Collections import java.util.LinkedHashMap @@ -31,6 +32,14 @@ object TransportBridgeService { */ fun send(packet: RoutedPacket) + /** + * Send a packet and report whether at least one concrete transport write was accepted. + * + * Receipt retries use this path so a registered-but-disconnected transport cannot be + * mistaken for a successful send. + */ + suspend fun sendAndReport(packet: RoutedPacket): Boolean = false + /** * Send a packet to a specific peer via this transport (optional). */ @@ -45,6 +54,11 @@ object TransportBridgeService { } } ) + private data class PreparedForward( + val packet: BitchatPacket, + val seenKey: String, + val reservedAtMs: Long + ) /** * Register a transport layer to receive bridged packets. @@ -74,7 +88,8 @@ object TransportBridgeService { fun broadcast(sourceId: String, packet: RoutedPacket) { val targets = transports.filterKeys { it != sourceId } if (targets.isEmpty()) return - val forwardedPacket = prepareForwardedPacket("broadcast", packet.packet) ?: return + val prepared = prepareForwardedPacket("broadcast", packet.packet) ?: return + val forwardedPacket = prepared.packet // Prepared private-media fragments must remain the admitted plan when // crossing transports, but relay TTL still has to advance on every // hop. TTL is excluded from the signature and does not affect size. @@ -96,13 +111,51 @@ object TransportBridgeService { } } + /** + * Broadcasts through every other active transport and reports whether any concrete write was + * accepted. Failed attempts release their duplicate-suppression reservation so a later retry + * can use a transport that reconnects during the retry window. + */ + suspend fun broadcastAndReport(sourceId: String, packet: RoutedPacket): Boolean { + val targets = transports.filterKeys { it != sourceId } + if (targets.isEmpty()) return false + val kind = "broadcast" + val prepared = prepareForwardedPacket(kind, packet.packet) ?: return false + val forwardedPacket = prepared.packet + val forwarded = packet.copy( + packet = forwardedPacket, + preparedPackets = packet.preparedPackets?.map { prepared -> + prepared.copy(ttl = forwardedPacket.ttl) + } + ) + + var accepted = false + targets.forEach { (id, layer) -> + val targetAccepted = try { + layer.sendAndReport(forwarded) + } catch (e: CancellationException) { + releaseSeenPacket(prepared) + throw e + } catch (e: Exception) { + Log.e(TAG, "Failed to bridge packet to $id: ${e.message}") + false + } + accepted = targetAccepted || accepted + } + if (!accepted) { + releaseSeenPacket(prepared) + } + return accepted + } + /** * Send a packet to a specific peer across all other transports. */ fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) { val targets = transports.filterKeys { it != sourceId } if (targets.isEmpty()) return - val forwardedPacket = prepareForwardedPacket("peer:$peerID", packet) ?: return + val forwardedPacket = + prepareForwardedPacket("peer:$peerID", packet)?.packet ?: return targets.forEach { (id, layer) -> try { @@ -147,7 +200,7 @@ object TransportBridgeService { } } - private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): BitchatPacket? { + private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): PreparedForward? { if (packet.ttl == 0u.toUByte()) { Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired") return null @@ -165,7 +218,19 @@ object TransportBridgeService { seenPackets[key] = now } - return packet.copy(ttl = (packet.ttl - 1u).toUByte()) + return PreparedForward( + packet = packet.copy(ttl = (packet.ttl - 1u).toUByte()), + seenKey = key, + reservedAtMs = now + ) + } + + private fun releaseSeenPacket(prepared: PreparedForward) { + synchronized(seenPackets) { + if (seenPackets[prepared.seenKey] == prepared.reservedAtMs) { + seenPackets.remove(prepared.seenKey) + } + } } private fun pruneSeen(now: Long) { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index c2ef7ae8..1f2d7980 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -117,7 +117,8 @@ class ChatViewModel( messageManager, dataManager, noiseSessionDelegate, - hasReadReceiptBeenSent = seenMessageStore::hasReadReceiptBeenSent + hasReadReceiptBeenSent = seenMessageStore::hasReadReceiptBeenSent, + markMessageReadLocally = seenMessageStore::markReadLocally ) private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager) private val notificationManager = NotificationManager( @@ -417,16 +418,6 @@ class ChatViewModel( setCurrentPrivateChatPeer(conversationID) // Clear notifications for this sender since user is now viewing the chat clearNotificationsForSender(conversationID) - - // Persistently mark all messages in this conversation as read so Nostr fetches - // after app restarts won't re-mark them as unread. - try { - val chats = state.getPrivateChatsValue() - val messages = chats[conversationID] ?: emptyList() - messages.forEach { msg -> - try { seenMessageStore.markReadLocally(msg.id) } catch (_: Exception) { } - } - } catch (_: Exception) { } } } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index 6b371710..de2f12c5 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -254,9 +254,13 @@ class MeshDelegateHandler( "MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})" ) + // UI focus is the source of truth for local read state. Transport acceptance is a + // separate fact and may remain retryable when the peer disconnects. + try { markMessageReadLocally(message.id) } catch (_: Exception) { } + val nickname = state.getNicknameValue().ifBlank { "unknown" } val mesh = getMeshService() - val sent = try { + try { val meshPeerID = ContactDirectory.resolve(senderConversationID).meshPeerID ?: senderPeerID.takeIf { com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it) @@ -266,27 +270,17 @@ class MeshDelegateHandler( mesh.hasEstablishedSession(meshPeerID) ) { mesh.sendReadReceipt(message.id, meshPeerID, nickname) - true - } else { - false } - } catch (_: Exception) { - false - } - if (sent) { - // Receipt scheduling and local-read persistence are separate facts. Record - // the UI state only after the retryable receipt has been admitted. - try { markMessageReadLocally(message.id) } catch (_: Exception) { } + } catch (_: Exception) { } - // Ensure unread badge is cleared for this peer immediately - try { - val current = state.getUnreadPrivateMessagesValue().toMutableSet() - val changed = current.remove(senderPeerID) or current.remove(senderConversationID) - if (changed) { - state.setUnreadPrivateMessages(current) - } - } catch (_: Exception) { } - } + // Ensure unread badge is cleared for this peer immediately. + try { + val current = state.getUnreadPrivateMessagesValue().toMutableSet() + val changed = current.remove(senderPeerID) or current.remove(senderConversationID) + if (changed) { + state.setUnreadPrivateMessages(current) + } + } catch (_: Exception) { } } else { android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") } diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index ff182cf8..d4fec2a9 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -34,7 +34,8 @@ class PrivateChatManager( private val messageManager: MessageManager, private val dataManager: DataManager, private val noiseSessionDelegate: NoiseSessionDelegate, - private val hasReadReceiptBeenSent: (messageID: String) -> Boolean = { false } + private val hasReadReceiptBeenSent: (messageID: String) -> Boolean = { false }, + private val markMessageReadLocally: (messageID: String) -> Unit = {} ) { companion object { @@ -399,6 +400,13 @@ class PrivateChatManager( senderPeerID == meshPeerID || ContactDirectory.canonicalConversationId(senderPeerID) == canonicalConversationID ) + if (isFromTarget) { + try { + markMessageReadLocally(msg.id) + } catch (e: Exception) { + Log.w(TAG, "Failed to persist local read for message ${msg.id}: ${e.message}") + } + } if (isFromTarget && meshPeerID != null && !hasReadReceiptBeenSent(msg.id)) { try { if (hasMesh) { diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index c5014456..d2be00e2 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -222,14 +222,17 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor /** * Broadcasts raw bytes to currently connected peer. */ - private fun broadcastRaw(bytes: ByteArray) { + private fun broadcastRaw(bytes: ByteArray): Boolean { + var accepted = false connectionTracker.peerSockets.forEach { (pid, sock) -> try { sock.write(bytes) + accepted = true } catch (e: IOException) { Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}") } } + return accepted } // TransportLayer implementation @@ -239,6 +242,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor meshCore.sendFromBridge(packet) } + override suspend fun sendAndReport(packet: RoutedPacket): Boolean { + return meshCore.sendFromBridgeAndReport(packet) + } + override fun sendToPeer(peerID: String, packet: BitchatPacket) { sendPacketToPeer(peerID, packet) } @@ -246,23 +253,23 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor /** * Broadcasts routed packet to currently connected peers. */ - private fun broadcastPacket(routed: RoutedPacket) { + private fun broadcastPacket(routed: RoutedPacket): Boolean { val packet = routed.packet if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) { val firstHop = packet.route!![0].toHexString() if (sendRoutedPacketToPeer(firstHop, routed)) { - return + return true } } val recipientId = packet.recipientID?.toHexString() if (recipientId != null && !packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) { if (sendRoutedPacketToPeer(recipientId, routed)) { - return + return true } } - fragmentingSender.send(routed, "Wi-Fi Aware broadcast") { single -> + return fragmentingSender.send(routed, "Wi-Fi Aware broadcast") { single -> broadcastSinglePacket(single) } } @@ -290,8 +297,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor private fun broadcastSinglePacket(routed: RoutedPacket): Boolean { val data = routed.packet.toBinaryData() ?: return false - broadcastRaw(data) - return true + return broadcastRaw(data) } private fun sendSinglePacketToPeer(peerID: String, packet: BitchatPacket): Boolean { @@ -1592,9 +1598,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor private inner class WifiAwareTransport : MeshTransport { override val id: String = "WIFI" - override fun broadcastPacket(routed: RoutedPacket) { + override fun broadcastPacket(routed: RoutedPacket): Boolean = this@WifiAwareMeshService.broadcastPacket(routed) - } override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) } diff --git a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt index 431d8500..9eff027c 100644 --- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -5,6 +5,7 @@ import androidx.test.core.app.ApplicationProvider import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -14,6 +15,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import java.util.Date @@ -95,4 +97,43 @@ class CommandProcessorTest() { assertEquals(result, true) } + + @Test + fun `msg command persists incoming messages as locally read through shared chat opening`() { + val peerID = "0102030405060708" + val message = BitchatMessage( + id = "message-opened-by-command", + sender = "alice", + content = "hello", + timestamp = Date(1), + isPrivate = true, + senderPeerID = peerID + ) + val locallyRead = mutableListOf() + chatState.setPrivateChats(mapOf(peerID to listOf(message))) + whenever(meshService.getPeerNicknames()).thenReturn(mapOf(peerID to "alice")) + + commandProcessor = CommandProcessor( + state = chatState, + messageManager = messageManager, + channelManager = channelManager, + privateChatManager = PrivateChatManager( + state = chatState, + messageManager = messageManager, + dataManager = DataManager(context = context), + noiseSessionDelegate = mock(), + markMessageReadLocally = locallyRead::add + ) + ) + + commandProcessor.processCommand( + command = "/msg alice", + meshService = meshService, + myPeerID = "self", + onSendMessage = { _, _, _ -> }, + viewModel = null + ) + + assertTrue(locallyRead.contains(message.id)) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt index 7e774dc3..cd8bf40d 100644 --- a/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt @@ -4,8 +4,10 @@ import android.os.Build import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test @@ -33,6 +35,11 @@ class TransportBridgeServiceTest { override fun send(packet: RoutedPacket) { captured = packet } + + override suspend fun sendAndReport(packet: RoutedPacket): Boolean { + captured = packet + return true + } } ) val packet = BitchatPacket( @@ -65,4 +72,41 @@ class TransportBridgeServiceTest { assertEquals(original.type, actual.type) } } + + @Test + fun `rejected bridge send remains eligible after transport reconnects`() = runTest { + var transportConnected = false + var attempts = 0 + TransportBridgeService.register( + targetId, + object : TransportBridgeService.TransportLayer { + override fun send(packet: RoutedPacket) = Unit + + override suspend fun sendAndReport(packet: RoutedPacket): Boolean { + attempts += 1 + return transportConnected + } + } + ) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = ByteArray(8) { 1 }, + recipientID = ByteArray(8) { 2 }, + timestamp = System.nanoTime().toULong(), + payload = byteArrayOf(3, 4, 5), + signature = ByteArray(64) { 6 }, + ttl = 7u + ) + val sourceId = "source-${UUID.randomUUID()}" + + assertFalse( + TransportBridgeService.broadcastAndReport(sourceId, RoutedPacket(packet)) + ) + transportConnected = true + assertTrue( + TransportBridgeService.broadcastAndReport(sourceId, RoutedPacket(packet)) + ) + assertEquals(2, attempts) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt index 64d37810..162a5ef6 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt @@ -15,6 +15,7 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -95,7 +96,7 @@ class MeshDelegateHandlerStateContractTest { } @Test - fun `focused private message schedules receipt before recording local read`() { + fun `focused private message schedules receipt and records local read independently`() { val peerID = "1122334455667788" val incoming = BitchatMessage( id = "focused-private-message", @@ -127,6 +128,27 @@ class MeshDelegateHandlerStateContractTest { assertEquals(listOf(incoming.id), locallyReadMessageIDs) } + @Test + fun `focused private message remains locally read when transport is disconnected`() { + val peerID = "1122334455667788" + val incoming = BitchatMessage( + id = "focused-private-message-offline", + sender = "alice", + content = "hello", + timestamp = Date(1), + isPrivate = true, + senderPeerID = peerID + ) + whenever(notifications.getAppBackgroundState()).thenReturn(false) + whenever(notifications.getCurrentPrivateChatPeer()).thenReturn(peerID) + whenever(mesh.getPeerInfo(peerID)).thenReturn(null) + + handler.didReceiveMessage(incoming) + + verify(mesh, never()).sendReadReceipt(any(), any(), any()) + assertEquals(listOf(incoming.id), locallyReadMessageIDs) + } + @Test fun `unicode mention notifies once and duplicate transport delivery is suppressed`() { val incoming = message( From e9bdf8aefc1fe83f14474789994b18f9966e3ce2 Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 12 Jul 2026 10:29:30 -0400 Subject: [PATCH 56/70] Bound pre-auth compressed payload expansion --- .../android/protocol/BinaryProtocol.kt | 56 ++- .../android/protocol/CompressionUtil.kt | 121 ++++-- .../android/protocol/BinaryProtocolTest.kt | 359 ++++++++++++++++++ docs/file_transfer.md | 20 + 4 files changed, 509 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index a952d5fa..6b1b5325 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -324,21 +324,36 @@ object BinaryProtocol { } } - fun decode(data: ByteArray): BitchatPacket? { + fun decode(data: ByteArray): BitchatPacket? = + decode(data, CompressionUtil::decompress) + + /** Test seam used to prove rejected expansion sizes never reach inflation. */ + internal fun decodeForTesting( + data: ByteArray, + decompress: (ByteArray, Int) -> ByteArray? + ): BitchatPacket? = decode(data, decompress) + + private fun decode( + data: ByteArray, + decompress: (ByteArray, Int) -> ByteArray? + ): BitchatPacket? { // Try decode as-is first (robust when padding wasn't applied) - iOS fix - decodeCore(data)?.let { return it } + decodeCore(data, decompress)?.let { return it } // If that fails, try after removing padding val unpadded = MessagePadding.unpad(data) if (unpadded.contentEquals(data)) return null // No padding was removed, already failed - return decodeCore(unpadded) + return decodeCore(unpadded, decompress) } /** * Core decoding implementation used by decode() with and without padding removal - iOS fix */ - private fun decodeCore(raw: ByteArray): BitchatPacket? { + private fun decodeCore( + raw: ByteArray, + decompress: (ByteArray, Int) -> ByteArray? + ): BitchatPacket? { try { if (raw.size < HEADER_SIZE_V1 + SENDER_ID_SIZE) return null @@ -435,23 +450,42 @@ object BinaryProtocol { } else { buffer.getShort().toUShort().toInt() } + + val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + if (originalSize <= 0 || originalSize > maxExpandedSize) { + Log.w( + "BinaryProtocol", + "Expanded payload size $originalSize is outside the allowed range 1..$maxExpandedSize" + ) + return null + } // Compressed payload val compressedSize = payloadLength.toInt() - lengthFieldBytes + if (compressedSize == 0) { + Log.w("BinaryProtocol", "Compressed payload has no deflate bytes") + return null + } val compressedPayload = ByteArray(compressedSize) buffer.get(compressedPayload) // Security check: Compression bomb protection - if (compressedSize > 0) { - val ratio = originalSize.toDouble() / compressedSize.toDouble() - if (ratio > 50_000.0) { - Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1") - return null - } + val ratio = originalSize.toDouble() / compressedSize.toDouble() + if (ratio > 50_000.0) { + Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1") + return null } // Decompress - CompressionUtil.decompress(compressedPayload, originalSize) ?: return null + val expandedPayload = decompress(compressedPayload, originalSize) ?: return null + if (expandedPayload.size != originalSize) { + Log.w( + "BinaryProtocol", + "Expanded payload size ${expandedPayload.size} did not match declared size $originalSize" + ) + return null + } + expandedPayload } else { val payloadBytes = ByteArray(payloadLength.toInt()) buffer.get(payloadBytes) diff --git a/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt b/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt index e8b59254..34e583fb 100644 --- a/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt +++ b/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt @@ -2,6 +2,7 @@ package com.bitchat.android.protocol import android.util.Log import java.io.ByteArrayOutputStream +import java.util.zip.DataFormatException import java.util.zip.Deflater import java.util.zip.Inflater @@ -11,6 +12,10 @@ import java.util.zip.Inflater */ object CompressionUtil { private const val COMPRESSION_THRESHOLD = com.bitchat.android.util.AppConstants.Protocol.COMPRESSION_THRESHOLD_BYTES // bytes - same as iOS + + // Inflation allocates the full declared output buffer. Keep that allocation single-flight so + // concurrent packets cannot multiply the bounded per-packet memory cost. + private val decompressionLock = Any() /** * Helper to check if compression is worth it - exact same logic as iOS @@ -73,49 +78,93 @@ object CompressionUtil { * iOS COMPRESSION_ZLIB produces raw deflate data (no headers) */ fun decompress(compressedData: ByteArray, originalSize: Int): ByteArray? { - // iOS COMPRESSION_ZLIB produces raw deflate format (no headers) - try { - val inflater = Inflater(true) // true = raw deflate, no headers - inflater.setInput(compressedData) - - val decompressedBuffer = ByteArray(originalSize) - val actualSize = inflater.inflate(decompressedBuffer) - inflater.end() - - // Verify decompressed size matches expected (same validation as iOS) - return if (actualSize == originalSize) { - decompressedBuffer - } else if (actualSize > 0) { - // Handle case where actual size is different - decompressedBuffer.copyOfRange(0, actualSize) - } else { + val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + if (compressedData.isEmpty()) { + Log.w("CompressionUtil", "Refusing an empty compressed payload") + return null + } + if (originalSize <= 0 || originalSize > maxExpandedSize) { + Log.w( + "CompressionUtil", + "Refusing expanded payload size $originalSize outside 1..$maxExpandedSize" + ) + return null + } + + return synchronized(decompressionLock) { + // iOS COMPRESSION_ZLIB produces raw deflate format (no headers). + val rawResult = try { + inflateExact(compressedData, originalSize, nowrap = true) + } catch (e: Exception) { + Log.d( + "CompressionUtil", + "Raw deflate decompression failed: ${e.message}" + ) null } - } catch (e: Exception) { - Log.d("CompressionUtil", "Raw deflate decompression failed: ${e.message}, trying with zlib headers...") - - // Fallback: try with zlib headers in case of mixed usage - try { - val inflater = Inflater(false) // false = expect zlib headers - inflater.setInput(compressedData) - - val decompressedBuffer = ByteArray(originalSize) - val actualSize = inflater.inflate(decompressedBuffer) - inflater.end() - - return if (actualSize == originalSize) { - decompressedBuffer - } else if (actualSize > 0) { - decompressedBuffer.copyOfRange(0, actualSize) - } else { + + if (rawResult != null) { + rawResult + } else { + // Fallback after either a format error or an incomplete/wrong-sized raw stream: + // accept the zlib-wrapped form used by some older/mixed clients, but only when it + // independently satisfies the same exact-size and complete-stream checks. + try { + inflateExact(compressedData, originalSize, nowrap = false) + } catch (fallbackException: Exception) { + Log.e( + "CompressionUtil", + "Both raw deflate and zlib decompression failed: ${fallbackException.message}" + ) null } - } catch (fallbackException: Exception) { - Log.e("CompressionUtil", "Both raw deflate and zlib decompression failed: ${fallbackException.message}") - return null } } } + + /** + * Inflate one complete stream into exactly [originalSize] bytes. + * + * A full output buffer alone is not success: an attacker can under-declare a larger stream so + * the first inflate call fills the buffer while [Inflater.finished] remains false. Conversely, + * a truncated or over-declared stream can produce a non-empty prefix. Both forms are rejected, + * as are trailing bytes after the compressed stream. + * + * [DataFormatException] is deliberately allowed to escape so the caller can try the legacy + * zlib-wrapped format. Size/completion mismatches return null; the fallback must then prove the + * same bytes are a complete, exact-sized zlib stream before they can be accepted. + */ + @Throws(DataFormatException::class) + private fun inflateExact( + compressedData: ByteArray, + originalSize: Int, + nowrap: Boolean + ): ByteArray? { + val inflater = Inflater(nowrap) + return try { + inflater.setInput(compressedData) + val output = ByteArray(originalSize) + var written = 0 + + while (written < originalSize) { + val count = inflater.inflate(output, written, originalSize - written) + if (count == 0) break + written += count + } + + if (written != originalSize) return null + + // Give Inflater one byte of room to consume the end marker. Any produced byte proves + // the declared size was smaller than the actual expansion. + val overflowProbe = ByteArray(1) + if (inflater.inflate(overflowProbe) != 0) return null + + if (!inflater.finished() || inflater.remaining != 0) return null + output + } finally { + inflater.end() + } + } /** * Test function to verify deflate compression works correctly diff --git a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt index c1b2327b..fb6e03bb 100644 --- a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt +++ b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt @@ -1,5 +1,6 @@ package com.bitchat.android.protocol +import com.bitchat.android.model.BitchatFilePacket import org.junit.Assert.assertEquals import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertFalse @@ -7,9 +8,11 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.Random +import java.util.zip.Deflater class BinaryProtocolTest { @@ -987,6 +990,309 @@ class BinaryProtocolTest { assertNull("v2 compression bomb (ratio > 50,000:1) must be rejected", result) } + @Test + fun `v2 expanded payload at exact maximum passes bound without allocating output`() { + val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + val raw = compressedPacket( + version = 2u, + originalSize = max, + compressedData = ByteArray(256) { it.toByte() } + ) + var calls = 0 + + val result = BinaryProtocol.decodeForTesting(raw) { _, requestedSize -> + calls += 1 + assertEquals(max, requestedSize) + null // Prove the boundary reached this seam without allocating a 10 MiB result. + } + + assertNull("The test decompressor deliberately returns no payload", result) + assertEquals(1, calls) + } + + @Test + fun `v2 expanded payload above maximum never reaches decompressor`() { + val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + val raw = compressedPacket( + version = 2u, + originalSize = max + 1, + compressedData = ByteArray(256) { it.toByte() } + ) + var calls = 0 + + val result = BinaryProtocol.decodeForTesting(raw) { _, _ -> + calls += 1 + byteArrayOf(0x42) + } + + assertNull("An oversized expansion must be rejected before inflation", result) + assertEquals("The decompressor must not be invoked", 0, calls) + } + + @Test + fun `v2 negative expanded payload never reaches decompressor`() { + val raw = compressedPacket( + version = 2u, + originalSize = -1, + compressedData = byteArrayOf(0x03) + ) + var calls = 0 + + val result = BinaryProtocol.decodeForTesting(raw) { _, _ -> + calls += 1 + byteArrayOf(0x42) + } + + assertNull("A negative expansion must be rejected before inflation", result) + assertEquals("The decompressor must not be invoked", 0, calls) + } + + @Test + fun `zero expanded payload never reaches decompressor`() { + val raw = compressedPacket( + version = 2u, + originalSize = 0, + compressedData = rawDeflate(ByteArray(0)) + ) + var calls = 0 + + val result = BinaryProtocol.decodeForTesting(raw) { _, _ -> + calls += 1 + ByteArray(0) + } + + assertNull("Compressed zero-length payloads are non-canonical and must be rejected", result) + assertEquals("The decompressor must not be invoked", 0, calls) + } + + @Test + fun `empty compressed body never reaches decompressor`() { + val raw = compressedPacket( + version = 2u, + originalSize = 128, + compressedData = ByteArray(0) + ) + var calls = 0 + + val result = BinaryProtocol.decodeForTesting(raw) { _, _ -> + calls += 1 + ByteArray(128) + } + + assertNull("A compressed payload must contain deflate bytes", result) + assertEquals("The decompressor must not be invoked", 0, calls) + } + + @Test + fun `v1 unsigned maximum expanded size reaches decompressor`() { + val originalSize = 0xFFFF + val raw = compressedPacket( + version = 1u, + originalSize = originalSize, + compressedData = byteArrayOf(0x01, 0x02) + ) + var calls = 0 + + val result = BinaryProtocol.decodeForTesting(raw) { _, requestedSize -> + calls += 1 + assertEquals(originalSize, requestedSize) + null + } + + assertNull("The test decompressor deliberately returns no payload", result) + assertEquals(1, calls) + } + + @Test + fun `compression utility rejects invalid expansion sizes directly`() { + val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + + assertNull(CompressionUtil.decompress(ByteArray(0), 1)) + assertNull(CompressionUtil.decompress(rawDeflate(ByteArray(0)), 0)) + assertNull(CompressionUtil.decompress(byteArrayOf(0x03), -1)) + assertNull(CompressionUtil.decompress(byteArrayOf(0x03), max + 1)) + } + + @Test + fun `raw deflate expands only when size and stream completion are exact`() { + val payload = ByteArray(4_096) { index -> (index % 17).toByte() } + val compressed = rawDeflate(payload) + + val decoded = BinaryProtocol.decode( + compressedPacket(version = 2u, originalSize = payload.size, compressedData = compressed) + ) + + assertNotNull(decoded) + assertArrayEquals(payload, decoded!!.payload) + } + + @Test + fun `zlib wrapped payload remains compatible when size and stream completion are exact`() { + val payload = ByteArray(4_096) { index -> (index % 23).toByte() } + val compressed = zlibDeflate(payload) + + val decoded = BinaryProtocol.decode( + compressedPacket(version = 2u, originalSize = payload.size, compressedData = compressed) + ) + + assertNotNull(decoded) + assertArrayEquals(payload, decoded!!.payload) + } + + @Test + fun `under-declared zlib expansion is rejected by fallback`() { + val payload = ByteArray(4_096) { 0x51 } + val compressed = zlibDeflate(payload) + + val decoded = BinaryProtocol.decode( + compressedPacket(version = 2u, originalSize = 128, compressedData = compressed) + ) + + assertNull("Zlib fallback must reject output beyond the declaration", decoded) + } + + @Test + fun `over-declared zlib expansion is rejected by fallback`() { + val payload = ByteArray(128) { 0x52 } + val compressed = zlibDeflate(payload) + + val decoded = BinaryProtocol.decode( + compressedPacket(version = 2u, originalSize = 256, compressedData = compressed) + ) + + assertNull("Zlib fallback must reject output shorter than the declaration", decoded) + } + + @Test + fun `truncated zlib stream is rejected by fallback`() { + val payload = ByteArray(4_096) { index -> (index % 29).toByte() } + val compressed = zlibDeflate(payload) + val truncated = compressed.copyOf(compressed.size - 1) + + val decoded = BinaryProtocol.decode( + compressedPacket(version = 2u, originalSize = payload.size, compressedData = truncated) + ) + + assertNull("Zlib fallback must require the stream end marker and checksum", decoded) + } + + @Test + fun `zlib stream with trailing bytes is rejected by fallback`() { + val payload = ByteArray(4_096) { index -> (index % 13).toByte() } + val compressedWithTrailingByte = zlibDeflate(payload) + byteArrayOf(0x00) + + val decoded = BinaryProtocol.decode( + compressedPacket( + version = 2u, + originalSize = payload.size, + compressedData = compressedWithTrailingByte + ) + ) + + assertNull("Zlib fallback must consume the complete input and nothing more", decoded) + } + + @Test + fun `under-declared raw expansion is rejected even when output buffer fills`() { + val payload = ByteArray(4_096) { 0x41 } + val compressed = rawDeflate(payload) + + val decoded = BinaryProtocol.decode( + compressedPacket(version = 2u, originalSize = 128, compressedData = compressed) + ) + + assertNull("Inflater must be finished, not merely fill the declared buffer", decoded) + } + + @Test + fun `over-declared raw expansion is rejected instead of returning a prefix`() { + val payload = ByteArray(128) { 0x42 } + val compressed = rawDeflate(payload) + + val decoded = BinaryProtocol.decode( + compressedPacket(version = 2u, originalSize = 256, compressedData = compressed) + ) + + assertNull("The expanded byte count must equal the declaration", decoded) + } + + @Test + fun `truncated raw stream is rejected even if all declared bytes were emitted`() { + val payload = ByteArray(4_096) { index -> (index % 31).toByte() } + val compressed = rawDeflate(payload) + val truncated = compressed.copyOf(compressed.size - 1) + + val decoded = BinaryProtocol.decode( + compressedPacket(version = 2u, originalSize = payload.size, compressedData = truncated) + ) + + assertNull("A stream without its end marker must not be accepted", decoded) + } + + @Test + fun `raw stream with trailing bytes is rejected`() { + val payload = ByteArray(4_096) { index -> (index % 19).toByte() } + val compressedWithTrailingByte = rawDeflate(payload) + byteArrayOf(0x00) + + val decoded = BinaryProtocol.decode( + compressedPacket( + version = 2u, + originalSize = payload.size, + compressedData = compressedWithTrailingByte + ) + ) + + assertNull("Trailing bytes after a complete stream must not be accepted", decoded) + } + + @Test + fun `decoder rejects a decompressor result shorter than its declaration`() { + val raw = compressedPacket( + version = 2u, + originalSize = 128, + compressedData = ByteArray(16) { it.toByte() } + ) + + val decoded = BinaryProtocol.decodeForTesting(raw) { _, _ -> byteArrayOf(0x01) } + + assertNull("BinaryProtocol must independently enforce the declared expanded size", decoded) + } + + @Test + fun `legacy 11 MiB public file transfer is explicitly rejected by bounded decoder`() { + val content = ByteArray(11 * 1024 * 1024) { 0x41 } + val filePayload = BitchatFilePacket( + fileName = "legacy-11m.bin", + fileSize = content.size.toLong(), + mimeType = "application/octet-stream", + content = content + ).encode() + assertNotNull(filePayload) + + val encoded = BinaryProtocol.encode( + BitchatPacket( + version = 2u, + type = MessageType.FILE_TRANSFER.value, + senderID = hexToBytes(senderHex), + recipientID = SpecialRecipients.BROADCAST, + timestamp = fixedTimestamp, + payload = filePayload!!, + ttl = 5u + ), + padding = false + ) + assertNotNull("A legacy sender can produce the highly-compressible wire packet", encoded) + assertTrue( + "The compressed wire body remains below the normal 10 MiB input cap", + encoded!!.size < com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + ) + + assertNull( + "The uniform bound intentionally rejects this legacy expansion until streaming admission exists", + BinaryProtocol.decode(encoded) + ) + } + /** * Compression bomb is rejected * @@ -1163,6 +1469,59 @@ class BinaryProtocolTest { return result } + private fun compressedPacket( + version: UByte, + originalSize: Int, + compressedData: ByteArray, + type: UByte = MessageType.MESSAGE.value + ): ByteArray { + val originalSizeFieldBytes = if (version >= 2u.toUByte()) 4 else 2 + val payloadLength = originalSizeFieldBytes + compressedData.size + val headerSize = if (version >= 2u.toUByte()) 16 else 14 + val buffer = ByteBuffer.allocate(headerSize + 8 + payloadLength).apply { + order(ByteOrder.BIG_ENDIAN) + put(version.toByte()) + put(type.toByte()) + put(5.toByte()) + putLong(fixedTimestamp.toLong()) + put(BinaryProtocol.Flags.IS_COMPRESSED.toByte()) + if (version >= 2u.toUByte()) { + putInt(payloadLength) + } else { + putShort(payloadLength.toShort()) + } + put(hexToBytes(senderHex)) + if (version >= 2u.toUByte()) { + putInt(originalSize) + } else { + putShort(originalSize.toShort()) + } + put(compressedData) + } + return buffer.array() + } + + private fun rawDeflate(data: ByteArray): ByteArray = deflate(data, nowrap = true) + + private fun zlibDeflate(data: ByteArray): ByteArray = deflate(data, nowrap = false) + + private fun deflate(data: ByteArray, nowrap: Boolean): ByteArray { + val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, nowrap) + return try { + deflater.setInput(data) + deflater.finish() + val output = ByteArrayOutputStream() + val buffer = ByteArray(1_024) + while (!deflater.finished()) { + val count = deflater.deflate(buffer) + output.write(buffer, 0, count) + } + output.toByteArray() + } finally { + deflater.end() + } + } + private fun makePacket( version: UByte = 1u, type: UByte = MessageType.MESSAGE.value, diff --git a/docs/file_transfer.md b/docs/file_transfer.md index 333c4d5a..3f44cb2c 100644 --- a/docs/file_transfer.md +++ b/docs/file_transfer.md @@ -107,6 +107,26 @@ source-route metadata. It does not imply multi-gigabyte mesh transfer support. transport threshold; the data portion is at most 469 bytes and becomes smaller when recipient or source-route overhead is present. +#### Compressed expansion rollout gate (draft/HOLD) + +Android's bounded decoder applies the same 10 MiB expanded-payload ceiling to every outer +message type. It also requires a non-empty compressed body, an exact declared output size, and a +complete deflate stream. The `FILE_TRANSFER (0x22)` byte cannot safely grant a larger ceiling: it is +attacker-controlled before packet signature verification, and the current receive pipeline must +inflate before it can perform that verification. + +This intentionally means a legacy Android sender can produce a highly compressible public file +between 10 MiB and the UI's 50 MiB send limit that the bounded decoder rejects. The hardening must +therefore remain a rollout HOLD rather than silently ship as backward compatible. Grandfathering +50 MiB also is not safe after only a type check: inflation allocates the declared payload and +`BitchatFilePacket.decode` currently copies the content again, creating a greater than 100 MiB peak +for a maximum-size transfer. + +Before enabling that legacy range, receive processing needs an authenticated admission decision +made before large allocation plus streaming inflation/TLV parsing into a bounded temporary file (or +another ownership-preserving design that avoids the second full-size copy). The sender limit and a +wire capability/version transition must then be coordinated so old and new clients fail predictably. + ### 1.3 File Transfer TLV payload (BitchatFilePacket) The file payload is a TLV structure with mixed length field sizes to support large contents efficiently. From b251812b9e57bd82ab8e25752bb4cb57f4f67fa6 Mon Sep 17 00:00:00 2001 From: a1denvalu3 <43107113+a1denvalu3@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:52:55 +0200 Subject: [PATCH 57/70] Align compressed payload send and receive bounds (#736) * Align compressed payload send and receive bounds * Preserve ambiguous raw deflate compatibility * Pool decompression by memory budget --- .../android/protocol/BinaryProtocol.kt | 25 ++-- .../android/protocol/CompressionUtil.kt | 83 +++++++++---- .../protocol/DecompressionResourcePool.kt | 73 +++++++++++ .../bitchat/android/ui/MediaSendingManager.kt | 2 +- .../com/bitchat/android/util/AppConstants.kt | 4 +- .../android/protocol/BinaryProtocolTest.kt | 24 ++-- .../protocol/DecompressionResourcePoolTest.kt | 117 ++++++++++++++++++ .../kotlin/com/bitchat/FileTransferTest.kt | 2 +- docs/file_transfer.md | 9 +- 9 files changed, 290 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/protocol/DecompressionResourcePool.kt create mode 100644 app/src/test/java/com/bitchat/android/protocol/DecompressionResourcePoolTest.kt diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 6b1b5325..5b1d7287 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -183,7 +183,6 @@ object BinaryProtocol { private const val SENDER_ID_SIZE = 8 private const val RECIPIENT_ID_SIZE = 8 private const val SIGNATURE_SIZE = 64 - object Flags { const val HAS_RECIPIENT: UByte = 0x01u const val HAS_SIGNATURE: UByte = 0x02u @@ -200,6 +199,15 @@ object BinaryProtocol { fun encode(packet: BitchatPacket, padding: Boolean = true): ByteArray? { try { + val maxPayloadLength = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + if (packet.payload.size > maxPayloadLength) { + Log.w( + "BinaryProtocol", + "Cannot encode payload ${packet.payload.size} above receiver limit $maxPayloadLength" + ) + return null + } + // Try to compress payload if beneficial var payload = packet.payload var originalPayloadSize: Int? = null @@ -325,7 +333,7 @@ object BinaryProtocol { } fun decode(data: ByteArray): BitchatPacket? = - decode(data, CompressionUtil::decompress) + decode(data, CompressionUtil::decompressWithResourcesReserved) /** Test seam used to prove rejected expansion sizes never reach inflation. */ internal fun decodeForTesting( @@ -466,9 +474,6 @@ object BinaryProtocol { Log.w("BinaryProtocol", "Compressed payload has no deflate bytes") return null } - val compressedPayload = ByteArray(compressedSize) - buffer.get(compressedPayload) - // Security check: Compression bomb protection val ratio = originalSize.toDouble() / compressedSize.toDouble() if (ratio > 50_000.0) { @@ -476,8 +481,14 @@ object BinaryProtocol { return null } - // Decompress - val expandedPayload = decompress(compressedPayload, originalSize) ?: return null + // Reserve the compressed copy plus expanded output before either allocation. + // Small packets share the memory pool; packets wait only while its budget is full. + val resourceBytes = compressedSize.toLong() + originalSize.toLong() + val expandedPayload = CompressionUtil.withDecompressionResources(resourceBytes) { + val compressedPayload = ByteArray(compressedSize) + buffer.get(compressedPayload) + decompress(compressedPayload, originalSize) + } ?: return null if (expandedPayload.size != originalSize) { Log.w( "BinaryProtocol", diff --git a/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt b/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt index 34e583fb..5f8e9d5a 100644 --- a/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt +++ b/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt @@ -13,9 +13,7 @@ import java.util.zip.Inflater object CompressionUtil { private const val COMPRESSION_THRESHOLD = com.bitchat.android.util.AppConstants.Protocol.COMPRESSION_THRESHOLD_BYTES // bytes - same as iOS - // Inflation allocates the full declared output buffer. Keep that allocation single-flight so - // concurrent packets cannot multiply the bounded per-packet memory cost. - private val decompressionLock = Any() + private val decompressionPool = DecompressionResourcePool.forRuntime() /** * Helper to check if compression is worth it - exact same logic as iOS @@ -78,50 +76,83 @@ object CompressionUtil { * iOS COMPRESSION_ZLIB produces raw deflate data (no headers) */ fun decompress(compressedData: ByteArray, originalSize: Int): ByteArray? { + if (!isValidRequest(compressedData, originalSize)) return null + return withDecompressionResources(originalSize.toLong()) { + decompressWithResourcesReserved(compressedData, originalSize) + } + } + + internal fun withDecompressionResources(bytes: Long, block: () -> T): T? = + decompressionPool.withReservation(bytes, block) + + /** + * Inflate after the caller has reserved all packet-specific allocations. + * This avoids nested acquisition when BinaryProtocol reserves both its input copy and output. + */ + internal fun decompressWithResourcesReserved( + compressedData: ByteArray, + originalSize: Int + ): ByteArray? { + if (!isValidRequest(compressedData, originalSize)) return null + return decompressExact(compressedData, originalSize) + } + + private fun isValidRequest(compressedData: ByteArray, originalSize: Int): Boolean { val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH if (compressedData.isEmpty()) { Log.w("CompressionUtil", "Refusing an empty compressed payload") - return null + return false } if (originalSize <= 0 || originalSize > maxExpandedSize) { Log.w( "CompressionUtil", "Refusing expanded payload size $originalSize outside 1..$maxExpandedSize" ) - return null + return false } + return true + } - return synchronized(decompressionLock) { - // iOS COMPRESSION_ZLIB produces raw deflate format (no headers). - val rawResult = try { - inflateExact(compressedData, originalSize, nowrap = true) - } catch (e: Exception) { - Log.d( - "CompressionUtil", - "Raw deflate decompression failed: ${e.message}" - ) + private fun decompressExact(compressedData: ByteArray, originalSize: Int): ByteArray? { + return if (looksLikeZlib(compressedData)) { + // A raw stream can coincidentally begin with a valid-looking zlib header. The + // header therefore only determines which format to try first; any non-exact zlib + // result must still fall back to raw under the same size/completion bounds. + val zlibResult = try { + inflateExact(compressedData, originalSize, nowrap = false) + } catch (zlibException: DataFormatException) { null } - - if (rawResult != null) { - rawResult + if (zlibResult != null) { + zlibResult } else { - // Fallback after either a format error or an incomplete/wrong-sized raw stream: - // accept the zlib-wrapped form used by some older/mixed clients, but only when it - // independently satisfies the same exact-size and complete-stream checks. try { - inflateExact(compressedData, originalSize, nowrap = false) - } catch (fallbackException: Exception) { - Log.e( - "CompressionUtil", - "Both raw deflate and zlib decompression failed: ${fallbackException.message}" - ) + inflateExact(compressedData, originalSize, nowrap = true) + } catch (rawException: DataFormatException) { + Log.d("CompressionUtil", "Invalid zlib/raw deflate stream") null } } + } else { + try { + inflateExact(compressedData, originalSize, nowrap = true) + } catch (rawException: DataFormatException) { + Log.d("CompressionUtil", "Invalid raw deflate stream") + null + } } } + /** RFC 1950 header check used to avoid speculative double inflation. */ + private fun looksLikeZlib(data: ByteArray): Boolean { + if (data.size < 2) return false + val cmf = data[0].toInt() and 0xFF + val flg = data[1].toInt() and 0xFF + return (cmf and 0x0F) == 8 && + (cmf ushr 4) <= 7 && + ((cmf shl 8) or flg) % 31 == 0 + } + /** * Inflate one complete stream into exactly [originalSize] bytes. * diff --git a/app/src/main/java/com/bitchat/android/protocol/DecompressionResourcePool.kt b/app/src/main/java/com/bitchat/android/protocol/DecompressionResourcePool.kt new file mode 100644 index 00000000..a72c8070 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/protocol/DecompressionResourcePool.kt @@ -0,0 +1,73 @@ +package com.bitchat.android.protocol + +import java.util.concurrent.Semaphore +import java.util.concurrent.TimeUnit +import kotlin.math.ceil + +/** + * Fair, weighted admission control for decompression allocations. + * + * Permits represent memory rather than workers: small packets can proceed concurrently while + * near-limit packets consume most of the budget. Callers must reserve before allocating any + * packet-specific compressed copy or expanded output. + */ +internal class DecompressionResourcePool( + budgetBytes: Long, + private val unitBytes: Int, + private val waitTimeoutMs: Long +) { + private val totalPermits = (budgetBytes / unitBytes).toInt().coerceAtLeast(1) + private val permits = Semaphore(totalPermits, true) + + fun withReservation(bytes: Long, block: () -> T): T? { + val requiredPermits = permitsFor(bytes) + val acquired = try { + permits.tryAcquire(requiredPermits, waitTimeoutMs, TimeUnit.MILLISECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + false + } + if (!acquired) return null + + return try { + block() + } finally { + permits.release(requiredPermits) + } + } + + internal fun permitsFor(bytes: Long): Int = + ceil(bytes.coerceAtLeast(1).toDouble() / unitBytes.toDouble()) + .toInt() + .coerceAtMost(totalPermits) + + internal val availablePermits: Int + get() = permits.availablePermits() + + companion object { + private const val DEFAULT_UNIT_BYTES = 256 * 1024 + private const val DEFAULT_WAIT_TIMEOUT_MS = 1_000L + private const val HEAP_BUDGET_DIVISOR = 8L + private const val MAX_BUDGET_BYTES = 64L * 1024 * 1024 + + fun forRuntime( + maxHeapBytes: Long = Runtime.getRuntime().maxMemory(), + maxPacketResourceBytes: Long = + 2L * com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + ): DecompressionResourcePool { + val budget = recommendedBudgetBytes(maxHeapBytes, maxPacketResourceBytes) + return DecompressionResourcePool( + budgetBytes = budget, + unitBytes = DEFAULT_UNIT_BYTES, + waitTimeoutMs = DEFAULT_WAIT_TIMEOUT_MS + ) + } + + internal fun recommendedBudgetBytes( + maxHeapBytes: Long, + maxPacketResourceBytes: Long + ): Long = (maxHeapBytes / HEAP_BUDGET_DIVISOR) + .coerceAtLeast(maxPacketResourceBytes) + .coerceAtMost(MAX_BUDGET_BYTES.coerceAtLeast(maxPacketResourceBytes)) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index ec8c064a..0b8f2526 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -43,7 +43,7 @@ class MediaSendingManager( get() = getMeshService() companion object { private const val TAG = "MediaSendingManager" - private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit + private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES private const val PENDING_PRIVATE_MEDIA_TIMEOUT_MS = 15_000L } diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 4dec775f..7d115b4a 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -131,7 +131,9 @@ object AppConstants { } object Media { - const val MAX_FILE_SIZE_BYTES: Long = 50L * 1024 * 1024 + // A file is currently encoded into one protocol payload before BLE fragmentation. + // Reserve room for maximum filename/MIME TLVs and encryption envelope overhead. + const val MAX_FILE_SIZE_BYTES: Long = (10L * 1024 * 1024) - (132L * 1024) } object Services { diff --git a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt index fb6e03bb..f6a1042d 100644 --- a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt +++ b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt @@ -1139,6 +1139,18 @@ class BinaryProtocolTest { assertArrayEquals(payload, decoded!!.payload) } + @Test + fun `raw deflate with zlib-looking prefix falls back after non-exact zlib parse`() { + val payload = ByteArray(29) { index -> (index + 1).toByte() } + val compressed = byteArrayOf( + 0x08, // non-final raw stored block; also zlib CMF + 0x1d, 0x00, // LEN = 29; 0x08 0x1d passes the RFC 1950 header check + 0xe2.toByte(), 0xff.toByte() // one's complement of LEN + ) + payload + byteArrayOf(0x03, 0x00) // final empty fixed-Huffman block + + assertArrayEquals(payload, CompressionUtil.decompress(compressed, payload.size)) + } + @Test fun `under-declared zlib expansion is rejected by fallback`() { val payload = ByteArray(4_096) { 0x51 } @@ -1259,7 +1271,7 @@ class BinaryProtocolTest { } @Test - fun `legacy 11 MiB public file transfer is explicitly rejected by bounded decoder`() { + fun `new sender refuses legacy 11 MiB public file transfer before transmission`() { val content = ByteArray(11 * 1024 * 1024) { 0x41 } val filePayload = BitchatFilePacket( fileName = "legacy-11m.bin", @@ -1281,15 +1293,9 @@ class BinaryProtocolTest { ), padding = false ) - assertNotNull("A legacy sender can produce the highly-compressible wire packet", encoded) - assertTrue( - "The compressed wire body remains below the normal 10 MiB input cap", - encoded!!.size < com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH - ) - assertNull( - "The uniform bound intentionally rejects this legacy expansion until streaming admission exists", - BinaryProtocol.decode(encoded) + "Sender and receiver must enforce the same expanded-payload ceiling", + encoded ) } diff --git a/app/src/test/java/com/bitchat/android/protocol/DecompressionResourcePoolTest.kt b/app/src/test/java/com/bitchat/android/protocol/DecompressionResourcePoolTest.kt new file mode 100644 index 00000000..843d88a2 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/protocol/DecompressionResourcePoolTest.kt @@ -0,0 +1,117 @@ +package com.bitchat.android.protocol + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class DecompressionResourcePoolTest { + @Test + fun `small reservations run concurrently and next waits only when budget is full`() { + val pool = DecompressionResourcePool( + budgetBytes = 2_000, + unitBytes = 1_000, + waitTimeoutMs = 2_000 + ) + val executor = Executors.newFixedThreadPool(3) + val entered = CountDownLatch(2) + val release = CountDownLatch(1) + val thirdEntered = CountDownLatch(1) + + try { + repeat(2) { + executor.submit { + pool.withReservation(1_000) { + entered.countDown() + release.await() + } + } + } + assertTrue(entered.await(1, TimeUnit.SECONDS)) + + executor.submit { + pool.withReservation(1_000) { + thirdEntered.countDown() + } + } + assertFalse("third reservation must wait while budget is full", thirdEntered.await(100, TimeUnit.MILLISECONDS)) + + release.countDown() + assertTrue("third reservation must proceed after release", thirdEntered.await(1, TimeUnit.SECONDS)) + } finally { + release.countDown() + executor.shutdownNow() + } + } + + @Test + fun `timed admission drops work instead of waiting indefinitely`() { + val pool = DecompressionResourcePool( + budgetBytes = 1_000, + unitBytes = 1_000, + waitTimeoutMs = 50 + ) + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val executor = Executors.newSingleThreadExecutor() + + try { + executor.submit { + pool.withReservation(1_000) { + entered.countDown() + release.await() + } + } + assertTrue(entered.await(1, TimeUnit.SECONDS)) + assertNull(pool.withReservation(1_000) { "unexpected" }) + } finally { + release.countDown() + executor.shutdownNow() + } + } + + @Test + fun `permits are released when decode throws`() { + val pool = DecompressionResourcePool(2_000, 1_000, 50) + + try { + pool.withReservation(2_000) { error("boom") } + } catch (_: IllegalStateException) { + // Expected. + } + + assertEquals(2, pool.availablePermits) + assertEquals("ok", pool.withReservation(2_000) { "ok" }) + } + + @Test + fun `runtime budget is based on heap memory and always admits one maximum packet`() { + val maxPacketResources = 20L * 1024 * 1024 + + assertEquals( + maxPacketResources, + DecompressionResourcePool.recommendedBudgetBytes( + maxHeapBytes = 64L * 1024 * 1024, + maxPacketResourceBytes = maxPacketResources + ) + ) + assertEquals( + 32L * 1024 * 1024, + DecompressionResourcePool.recommendedBudgetBytes( + maxHeapBytes = 256L * 1024 * 1024, + maxPacketResourceBytes = maxPacketResources + ) + ) + assertEquals( + 64L * 1024 * 1024, + DecompressionResourcePool.recommendedBudgetBytes( + maxHeapBytes = 2L * 1024 * 1024 * 1024, + maxPacketResourceBytes = maxPacketResources + ) + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/FileTransferTest.kt b/app/src/test/kotlin/com/bitchat/FileTransferTest.kt index 31131540..8a136e69 100644 --- a/app/src/test/kotlin/com/bitchat/FileTransferTest.kt +++ b/app/src/test/kotlin/com/bitchat/FileTransferTest.kt @@ -237,7 +237,7 @@ class FileTransferTest { // Given: Large file size (simulated) val largeFileSize = 100L * 1024 * 1024 // 100MB - val maxAllowedSize = 50L * 1024 * 1024 // 50MB + val maxAllowedSize = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // When: Checking if file can be transferred val isAllowed = largeFileSize <= maxAllowedSize diff --git a/docs/file_transfer.md b/docs/file_transfer.md index 3f44cb2c..0d0274d4 100644 --- a/docs/file_transfer.md +++ b/docs/file_transfer.md @@ -115,10 +115,11 @@ complete deflate stream. The `FILE_TRANSFER (0x22)` byte cannot safely grant a l attacker-controlled before packet signature verification, and the current receive pipeline must inflate before it can perform that verification. -This intentionally means a legacy Android sender can produce a highly compressible public file -between 10 MiB and the UI's 50 MiB send limit that the bounded decoder rejects. The hardening must -therefore remain a rollout HOLD rather than silently ship as backward compatible. Grandfathering -50 MiB also is not safe after only a type check: inflation allocates the declared payload and +New Android senders cap files just below 10 MiB (reserving envelope overhead) and refuse to encode +any payload above the receiver ceiling. +Legacy Android senders can still produce a highly compressible public file between 10 MiB and their +50 MiB UI limit that the bounded decoder rejects. Grandfathering 50 MiB is not safe after only a type +check: inflation allocates the declared payload and `BitchatFilePacket.decode` currently copies the content again, creating a greater than 100 MiB peak for a maximum-size transfer. From c3bc911bcddb0c40ecb2ccc8d03571656173260a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:35:51 +0200 Subject: [PATCH 58/70] Finish rollout: user-visible size-cap failure, OOM-safe decode catch - MediaSendingManager: surface a chat system message when a picked file exceeds the ~10 MiB send cap instead of silently dropping the send (voice/image/file paths), completing the 'user-visible failure' requirement of the rollout gate - BinaryProtocol: catch Exception instead of Throwable in decodeCore so OutOfMemoryError is never swallowed and masked as a parse failure - docs/file_transfer.md: mark the compressed-expansion rollout gate resolved; support for legacy >10 MiB compressed transfers is explicitly ended --- .../android/protocol/BinaryProtocol.kt | 2 +- .../bitchat/android/ui/MediaSendingManager.kt | 25 ++++++++++++++----- docs/file_transfer.md | 11 ++++---- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 5b1d7287..47ee8d75 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -522,7 +522,7 @@ object BinaryProtocol { route = route ) - } catch (e: Throwable) { + } catch (e: Exception) { Log.e("BinaryProtocol", "Error decoding packet: ${e.message}") return null } diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index 0b8f2526..41431cd1 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -81,6 +81,22 @@ class MediaSendingManager( private var automaticRetryRequestedFor: String? = null private var pendingAutomaticTimeoutRequestId: String? = null + /** + * Enforce the send-size cap with a user-visible failure. + * Returns true if the file is oversized and the send was aborted. + */ + private fun rejectIfOversized(file: java.io.File): Boolean { + val size = file.length() + if (size <= MAX_FILE_SIZE) return false + Log.e(TAG, "❌ File too large: $size bytes (max: $MAX_FILE_SIZE)") + val sizeMb = size / (1024 * 1024) + val maxMb = MAX_FILE_SIZE / (1024 * 1024) + messageManager.addSystemMessage( + "cannot send ${file.name}: file is too large (${sizeMb} MB, max $maxMb MB)" + ) + return true + } + /** * Send a voice note (audio file) */ @@ -103,8 +119,7 @@ class MediaSendingManager( return@withContext null } - if (file.length() > MAX_FILE_SIZE) { - Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)") + if (rejectIfOversized(file)) { return@withContext null } @@ -148,8 +163,7 @@ class MediaSendingManager( return@withContext null } - if (file.length() > MAX_FILE_SIZE) { - Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)") + if (rejectIfOversized(file)) { return@withContext null } @@ -193,8 +207,7 @@ class MediaSendingManager( return@withContext null } - if (file.length() > MAX_FILE_SIZE) { - Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)") + if (rejectIfOversized(file)) { return@withContext null } diff --git a/docs/file_transfer.md b/docs/file_transfer.md index 0d0274d4..8422b77a 100644 --- a/docs/file_transfer.md +++ b/docs/file_transfer.md @@ -107,7 +107,7 @@ source-route metadata. It does not imply multi-gigabyte mesh transfer support. transport threshold; the data portion is at most 469 bytes and becomes smaller when recipient or source-route overhead is present. -#### Compressed expansion rollout gate (draft/HOLD) +#### Compressed expansion rollout gate (resolved) Android's bounded decoder applies the same 10 MiB expanded-payload ceiling to every outer message type. It also requires a non-empty compressed body, an exact declared output size, and a @@ -116,10 +116,11 @@ attacker-controlled before packet signature verification, and the current receiv inflate before it can perform that verification. New Android senders cap files just below 10 MiB (reserving envelope overhead) and refuse to encode -any payload above the receiver ceiling. -Legacy Android senders can still produce a highly compressible public file between 10 MiB and their -50 MiB UI limit that the bounded decoder rejects. Grandfathering 50 MiB is not safe after only a type -check: inflation allocates the declared payload and +any payload above the receiver ceiling; exceeding the cap surfaces a user-visible error in chat. +Support for legacy >10 MiB compressed transfers is explicitly ended: legacy Android senders can +still produce a highly compressible public file between 10 MiB and their 50 MiB UI limit that the +bounded decoder rejects. Grandfathering 50 MiB is not safe after only a type check: inflation +allocates the declared payload and `BitchatFilePacket.decode` currently copies the content again, creating a greater than 100 MiB peak for a maximum-size transfer. From 0721c39f89415fb3e4935b01cc720906aaf7d018 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:24:02 +0200 Subject: [PATCH 59/70] Route oversize send failure to the active conversation Addresses review feedback: the size-cap error was posted to the main mesh timeline, so a user sending from a private chat or channel never saw it. rejectIfOversized now posts to the private conversation (addPrivateMessageNoUnread) or channel (addChannelMessage) the send originated from, falling back to the main timeline for public sends. Adds regression tests for both routings. --- .../bitchat/android/ui/MediaSendingManager.kt | 42 +++++++++++++++---- .../ui/MediaSendingManagerMigrationTest.kt | 37 ++++++++++++++++ 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index 41431cd1..68797e08 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -82,18 +82,42 @@ class MediaSendingManager( private var pendingAutomaticTimeoutRequestId: String? = null /** - * Enforce the send-size cap with a user-visible failure. - * Returns true if the file is oversized and the send was aborted. + * Enforce the send-size cap with a user-visible failure posted to the + * conversation the user is sending from. Returns true if the file is + * oversized and the send was aborted. */ - private fun rejectIfOversized(file: java.io.File): Boolean { + private fun rejectIfOversized( + file: java.io.File, + toPeerIDOrNull: String?, + channelOrNull: String? + ): Boolean { val size = file.length() if (size <= MAX_FILE_SIZE) return false Log.e(TAG, "❌ File too large: $size bytes (max: $MAX_FILE_SIZE)") val sizeMb = size / (1024 * 1024) val maxMb = MAX_FILE_SIZE / (1024 * 1024) - messageManager.addSystemMessage( - "cannot send ${file.name}: file is too large (${sizeMb} MB, max $maxMb MB)" - ) + val text = "cannot send ${file.name}: file is too large (${sizeMb} MB, max $maxMb MB)" + when { + toPeerIDOrNull != null -> { + val sys = BitchatMessage( + sender = "system", + content = text, + timestamp = Date(), + isRelay = false + ) + messageManager.addPrivateMessageNoUnread(toPeerIDOrNull, sys) + } + channelOrNull != null -> { + val sys = BitchatMessage( + sender = "system", + content = text, + timestamp = Date(), + isRelay = false + ) + messageManager.addChannelMessage(channelOrNull, sys) + } + else -> messageManager.addSystemMessage(text) + } return true } @@ -119,7 +143,7 @@ class MediaSendingManager( return@withContext null } - if (rejectIfOversized(file)) { + if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) { return@withContext null } @@ -163,7 +187,7 @@ class MediaSendingManager( return@withContext null } - if (rejectIfOversized(file)) { + if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) { return@withContext null } @@ -207,7 +231,7 @@ class MediaSendingManager( return@withContext null } - if (rejectIfOversized(file)) { + if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) { return@withContext null } diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt index ebe5f744..60c57198 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt @@ -287,6 +287,43 @@ class MediaSendingManagerMigrationTest { assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image }) } + @Test + fun `oversized file failure is posted to the private conversation and nothing is sent`() { + val bigFile = kotlin.io.path.createTempFile("oversized-private", ".jpg").toFile() + try { + bigFile.writeBytes(ByteArray(11 * 1024 * 1024) { 0x42 }) + + manager.sendImageNote(peerID, null, bigFile.absolutePath) + + val messages = state.privateChats.value[peerID].orEmpty() + assertEquals(1, messages.size) + assertTrue(messages.single().content.contains("too large")) + assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image }) + assertTrue(state.getMessagesValue().none { it.content.contains("too large") }) + verify(mesh, never()).prepareFilePrivate(any(), any(), any(), any()) + } finally { + bigFile.delete() + } + } + + @Test + fun `oversized file failure is posted to the channel and nothing is sent`() { + val bigFile = kotlin.io.path.createTempFile("oversized-channel", ".jpg").toFile() + try { + bigFile.writeBytes(ByteArray(11 * 1024 * 1024) { 0x42 }) + + manager.sendImageNote(null, "#test", bigFile.absolutePath) + + val channelMessages = state.getChannelMessagesValue()["#test"].orEmpty() + assertEquals(1, channelMessages.size) + assertTrue(channelMessages.single().content.contains("too large")) + assertTrue(state.getMessagesValue().none { it.content.contains("too large") }) + verify(mesh, never()).prepareFilePrivate(any(), any(), any(), any()) + } finally { + bigFile.delete() + } + } + @Test fun `cancelled consent cannot later send or echo`() { whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) From 7f2e684572d7a8f4be2b5b90d897cebb61ef6f91 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:33:28 +0200 Subject: [PATCH 60/70] feat: retry scheduler for queued private messages and session re-establishment --- .../android/mesh/BluetoothMeshService.kt | 11 + .../bitchat/android/services/MessageRouter.kt | 218 ++++++++++++++++-- .../com/bitchat/android/ui/ChatViewModel.kt | 9 + .../com/bitchat/android/util/AppConstants.kt | 7 + .../android/services/MessageRouterTest.kt | 200 ++++++++++++++++ 5 files changed, 424 insertions(+), 21 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index b17294f1..61924738 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -137,6 +137,17 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic messageHandler.packetProcessor = packetProcessor //startPeriodicDebugLogging() + // Flush queued private messages as soon as a BLE Noise session authenticates, + // instead of relying on the foreground-only UI poll. + encryptionService.onSessionEstablished = { peerID -> + Log.d(TAG, "BLE Noise session established with ${peerID.take(8)}") + try { + com.bitchat.android.services.MessageRouter + .tryGetInstance() + ?.onSessionEstablished(peerID) + } catch (_: Exception) { } + } + // Initialize sync manager (needs serviceScope) gossipSyncManager = GossipSyncManager( myPeerID = myPeerID, diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index 652e0c93..7905aee8 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -6,6 +6,15 @@ import com.bitchat.android.favorites.FavoriteControlMessage import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.ReadReceipt import com.bitchat.android.nostr.NostrTransport +import com.bitchat.android.util.AppConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** * Routes messages between local mesh transports and Nostr, matching iOS behavior. @@ -22,9 +31,27 @@ class MessageRouter private constructor( DROPPED } + private data class QueuedMessage( + val content: String, + val nickname: String, + val messageID: String, + val enqueuedAtMs: Long + ) + + private data class ConversationRetry( + val handshakeAttempts: Int, + val nextHandshakeAttemptAtMs: Long + ) + companion object { private const val TAG = "MessageRouter" + private const val OUTBOX_TICK_MS = AppConstants.Router.OUTBOX_TICK_MS + private const val OUTBOX_MESSAGE_TTL_MS = AppConstants.Router.OUTBOX_MESSAGE_TTL_MS + private const val OUTBOX_MAX_PER_PEER = AppConstants.Router.OUTBOX_MAX_PER_PEER + private val HANDSHAKE_RETRY_BACKOFF_MS = AppConstants.Router.HANDSHAKE_RETRY_BACKOFF_MS + @Volatile private var INSTANCE: MessageRouter? = null + internal var disableSchedulerForTesting = false fun tryGetInstance(): MessageRouter? = INSTANCE fun getInstance(context: Context, mesh: MeshService): MessageRouter { val instance = INSTANCE ?: synchronized(this) { @@ -44,10 +71,30 @@ class MessageRouter private constructor( instance.nostr.senderPeerID = mesh.myPeerID return instance } + + internal fun resetForTesting() { + INSTANCE?.schedulerScope?.cancel() + INSTANCE = null + } } - // Outbox: peerID -> queued (content, nickname, messageID) - private val outbox = mutableMapOf>>() + // Outbox: conversationID -> queued messages, oldest first + private val outbox = ConcurrentHashMap>() + + // Per-conversation handshake retry state for queued messages + private val retryState = ConcurrentHashMap() + + private val schedulerScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + // Injectable clock for tests + internal var clock: () -> Long = { System.currentTimeMillis() } + + // Called with the messageID of queued messages that expired or were evicted + var onMessageExpired: ((String) -> Unit)? = null + + init { + if (!disableSchedulerForTesting) startOutboxScheduler() + } // Listener for favorites changes to flush outbox when npub mapping appears/changes private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener { @@ -88,10 +135,9 @@ class MessageRouter private constructor( return RouteResult.NOSTR } else { Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…") - val q = outbox.getOrPut(conversationID) { mutableListOf() } - q.add(Triple(content, recipientNickname, messageID)) + enqueue(conversationID, QueuedMessage(content, recipientNickname, messageID, clock())) Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}…") - if (hasMesh) meshTarget?.let { mesh.initiateNoiseHandshake(it) } + if (hasMesh) meshTarget?.let { kickHandshake(conversationID, it, immediate = true) } return RouteResult.QUEUED } } @@ -145,23 +191,27 @@ class MessageRouter private constructor( val queued = outbox[conversationID] ?: outbox[peerID] ?: return if (queued.isEmpty()) return Log.d(TAG, "Flushing outbox for ${conversationID.take(16)}… count=${queued.size}") - val iterator = queued.iterator() - while (iterator.hasNext()) { - val (content, nickname, messageID) = iterator.next() - val resolution = ContactDirectory.resolve(conversationID) - val meshTarget = resolution.meshPeerID - val nostrTarget = resolution.noiseKeyHex ?: conversationID - if (meshTarget != null && isReady(mesh, meshTarget)) { - mesh.sendPrivateMessage(content, meshTarget, nickname, messageID) - iterator.remove() - } else if (canSendViaNostr(nostrTarget)) { - nostr.sendPrivateMessage(content, nostrTarget, nickname, messageID) - iterator.remove() + synchronized(queued) { + val iterator = queued.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + val resolution = ContactDirectory.resolve(conversationID) + val meshTarget = resolution.meshPeerID + val nostrTarget = resolution.noiseKeyHex ?: conversationID + if (meshTarget != null && isReady(mesh, meshTarget)) { + mesh.sendPrivateMessage(entry.content, meshTarget, entry.nickname, entry.messageID) + iterator.remove() + } else if (canSendViaNostr(nostrTarget)) { + nostr.sendPrivateMessage(entry.content, nostrTarget, entry.nickname, entry.messageID) + iterator.remove() + } } } if (queued.isEmpty()) { - outbox.remove(conversationID) - outbox.remove(peerID) + outbox.remove(conversationID, queued) + outbox.remove(peerID, queued) + retryState.remove(conversationID) + retryState.remove(peerID) } } @@ -170,6 +220,102 @@ class MessageRouter private constructor( outbox.keys.toList().forEach { flushOutboxFor(it) } } + @Synchronized + private fun enqueue(conversationID: String, entry: QueuedMessage) { + val queue = outbox.getOrPut(conversationID) { mutableListOf() } + queue.add(entry) + while (queue.size > OUTBOX_MAX_PER_PEER) { + val evicted = queue.removeAt(0) + Log.w(TAG, "Outbox full for ${conversationID.take(16)}…; evicting oldest msg_id=${evicted.messageID.take(8)}…") + notifyExpired(evicted.messageID) + } + } + + private fun notifyExpired(messageID: String) { + try { onMessageExpired?.invoke(messageID) } catch (_: Exception) { } + } + + /** + * Initiate a Noise handshake for a conversation with queued messages, applying + * exponential backoff between attempts. [immediate] resets the backoff (peer just + * appeared or a new message was queued). Kicks are suppressed while a previous + * attempt is still inside its backoff window, so alias duplicates and frequent + * peer-list updates cannot spam handshakes. + */ + @Synchronized + private fun kickHandshake(conversationID: String, meshTarget: String, immediate: Boolean) { + val now = clock() + val current = retryState[conversationID] + if (current != null && now < current.nextHandshakeAttemptAtMs) return + val attempts = if (immediate) 0 else (current?.handshakeAttempts ?: 0) + try { mesh.initiateNoiseHandshake(meshTarget) } catch (_: Exception) { } + val backoff = HANDSHAKE_RETRY_BACKOFF_MS[attempts.coerceAtMost(HANDSHAKE_RETRY_BACKOFF_MS.size - 1)] + retryState[conversationID] = ConversationRetry( + handshakeAttempts = attempts + 1, + nextHandshakeAttemptAtMs = now + backoff + ) + Log.d(TAG, "Handshake attempt ${attempts + 1} for ${conversationID.take(16)}…, next retry in ${backoff}ms") + } + + private fun startOutboxScheduler() { + schedulerScope.launch { + while (isActive) { + delay(OUTBOX_TICK_MS) + try { tickOutbox() } catch (e: Exception) { + Log.w(TAG, "Outbox scheduler tick failed: ${e.message}") + } + } + } + } + + /** + * One scheduler pass over the outbox: expire old entries, flush what can be sent, + * and re-initiate handshakes (with backoff) for peers that are connected but have + * no established session yet. + */ + internal fun tickOutbox(nowMs: Long = clock()) { + outbox.keys.toList().forEach { conversationID -> + expireOldEntries(conversationID, nowMs) + val queued = outbox[conversationID] ?: return@forEach + if (queued.isEmpty()) return@forEach + + val resolution = ContactDirectory.resolve(conversationID) + val meshTarget = resolution.meshPeerID + + if (meshTarget != null && isReady(mesh, meshTarget)) { + flushOutboxFor(conversationID) + return@forEach + } + if (canSendViaNostr(resolution.noiseKeyHex ?: conversationID)) { + flushOutboxFor(conversationID) + return@forEach + } + // Peer visible but no session: retry the handshake with backoff. + if (meshTarget != null && isConnected(mesh, meshTarget)) { + kickHandshake(conversationID, meshTarget, immediate = false) + } + } + } + + private fun expireOldEntries(conversationID: String, nowMs: Long) { + val queued = outbox[conversationID] ?: return + synchronized(queued) { + val iterator = queued.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (nowMs - entry.enqueuedAtMs > OUTBOX_MESSAGE_TTL_MS) { + Log.w(TAG, "Expiring queued PM for ${conversationID.take(16)}… msg_id=${entry.messageID.take(8)}…") + iterator.remove() + notifyExpired(entry.messageID) + } + } + } + if (queued.isEmpty()) { + outbox.remove(conversationID, queued) + retryState.remove(conversationID) + } + } + private fun canSendViaNostr(peerID: String): Boolean { return try { val resolution = ContactDirectory.resolve(peerID) @@ -208,20 +354,50 @@ class MessageRouter private constructor( // Called when mesh peer list changes; attempt to flush any matching outbox entries fun onPeersUpdated(peers: List) { peers.forEach { pid -> + kickHandshakeIfPending(pid) flushOutboxFor(pid) val noiseHex = try { mesh.getPeerInfo(pid)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) } } catch (_: Exception) { null } - noiseHex?.let { flushOutboxFor(it) } + noiseHex?.let { + kickHandshakeIfPending(it) + flushOutboxFor(it) + } } } // Called when a Noise session becomes established; flush both the mesh peerID and its noiseHex alias fun onSessionEstablished(peerID: String) { + resetRetry(peerID) flushOutboxFor(peerID) val noiseHex = try { mesh.getPeerInfo(peerID)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) } } catch (_: Exception) { null } - noiseHex?.let { flushOutboxFor(it) } + noiseHex?.let { + resetRetry(it) + flushOutboxFor(it) + } + } + + /** Reset handshake backoff for a conversation whose session just came up. */ + private fun resetRetry(peerID: String) { + retryState.remove(ContactDirectory.canonicalConversationId(peerID)) + retryState.remove(peerID) + } + + /** + * A peer (re)appeared: if we still owe them queued messages and there is no working + * session yet, restart the handshake immediately instead of waiting for the backoff. + */ + private fun kickHandshakeIfPending(peerID: String) { + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val queued = outbox[conversationID] ?: outbox[peerID] ?: return + if (queued.isEmpty()) return + val resolution = ContactDirectory.resolve(conversationID) + val meshTarget = resolution.meshPeerID ?: return + if (isReady(mesh, meshTarget)) return + if (!isConnected(mesh, meshTarget)) return + Log.d(TAG, "Peer ${meshTarget.take(8)}… reappeared with ${queued.size} queued PM(s); re-initiating handshake") + kickHandshake(conversationID, meshTarget, immediate = true) } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 1f2d7980..6b9fb236 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -232,6 +232,15 @@ class ChatViewModel( loadAndInitialize() ContactDirectory.initialize(getApplication()) { mesh } com.bitchat.android.services.AppStateStore.canonicalizePrivateChats() + // Mark queued private messages as failed when the router gives up on them + try { + com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh).onMessageExpired = { messageID -> + messageManager.updateMessageDeliveryStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Failed("Message expired before delivery") + ) + } + } catch (_: Exception) { } // Hydrate UI state from process-wide AppStateStore to survive Activity recreation viewModelScope.launch { try { com.bitchat.android.services.AppStateStore.peers.collect { peers -> diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 4dec775f..7cc4c923 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -134,6 +134,13 @@ object AppConstants { const val MAX_FILE_SIZE_BYTES: Long = 50L * 1024 * 1024 } + object Router { + const val OUTBOX_TICK_MS: Long = 2_000L + const val OUTBOX_MESSAGE_TTL_MS: Long = 86_400_000L // 24 hours + const val OUTBOX_MAX_PER_PEER: Int = 100 + val HANDSHAKE_RETRY_BACKOFF_MS: LongArray = longArrayOf(5_000L, 15_000L, 30_000L, 60_000L) + } + object Services { const val SEEN_MESSAGE_MAX_IDS: Int = 10_000 } diff --git a/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt new file mode 100644 index 00000000..4ec89fc4 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt @@ -0,0 +1,200 @@ +package com.bitchat.android.services + +import android.content.Context +import android.os.Build +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.PeerInfo +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class MessageRouterTest { + + private val myPeerID = "1111222233334444" + private val peerID = "aaaabbbbccccdddd" + private val noiseKey = ByteArray(32) { 0x0B } + + private lateinit var mesh: MeshService + private lateinit var router: MessageRouter + private var fakeTime = 1_000_000L + private val expired = mutableListOf() + + @Before + fun setup() { + val context = RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences( + "message-router-test-${UUID.randomUUID()}", + Context.MODE_PRIVATE + ) + val identityManager = SecureIdentityStateManager(prefs, testOnly = true) + ContactDirectory.identityManagerProvider = { identityManager } + + mesh = mock() + whenever(mesh.myPeerID).thenReturn(myPeerID) + whenever(mesh.getPeerNicknames()).thenReturn(mapOf(peerID to "peer")) + + ContactDirectory.initialize(context) { mesh } + + MessageRouter.disableSchedulerForTesting = true + MessageRouter.resetForTesting() + fakeTime = 1_000_000L + expired.clear() + + router = MessageRouter.getInstance(context, mesh) + router.clock = { fakeTime } + router.onMessageExpired = { expired.add(it) } + } + + @After + fun tearDown() { + MessageRouter.resetForTesting() + MessageRouter.disableSchedulerForTesting = false + ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) } + } + + @Test + fun `queued message flushes after peer returns and session establishes`() { + peerOffline() + val result = router.sendPrivate("hello", peerID, "peer", "msg-1") + + assertEquals(MessageRouter.RouteResult.QUEUED, result) + verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull()) + verify(mesh, never()).initiateNoiseHandshake(any()) + + // Peer reappears without a session: handshake kicked immediately + peerConnectedNoSession() + router.onPeersUpdated(listOf(peerID)) + verify(mesh, times(1)).initiateNoiseHandshake(peerID) + verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull()) + + // Session established: queued message is sent + peerReady() + router.onSessionEstablished(peerID) + verify(mesh, times(1)).sendPrivateMessage("hello", peerID, "peer", "msg-1") + } + + @Test + fun `scheduler retries handshake with capped backoff`() { + peerConnectedNoSession() + val result = router.sendPrivate("hello", peerID, "peer", "msg-1") + assertEquals(MessageRouter.RouteResult.QUEUED, result) + verify(mesh, times(1)).initiateNoiseHandshake(peerID) // immediate kick at enqueue + clearInvocations(mesh) + + router.tickOutbox() // backoff (5s) not yet elapsed + verify(mesh, never()).initiateNoiseHandshake(any()) + + fakeTime += 6_000 + router.tickOutbox() // attempt 2, next in 15s + verify(mesh, times(1)).initiateNoiseHandshake(peerID) + + fakeTime += 7_000 + router.tickOutbox() // too early + verify(mesh, times(1)).initiateNoiseHandshake(peerID) + + fakeTime += 9_000 + router.tickOutbox() // attempt 3, next in 30s + verify(mesh, times(2)).initiateNoiseHandshake(peerID) + + fakeTime += 31_000 + router.tickOutbox() // attempt 4, next in 60s + verify(mesh, times(3)).initiateNoiseHandshake(peerID) + + fakeTime += 61_000 + router.tickOutbox() // attempt 5, capped at 60s + verify(mesh, times(4)).initiateNoiseHandshake(peerID) + } + + @Test + fun `expired entries are dropped and reported`() { + peerOffline() + router.sendPrivate("old message", peerID, "peer", "msg-old") + + fakeTime += 86_400_001L + router.tickOutbox() + + assertEquals(listOf("msg-old"), expired) + + // Nothing left to flush even when the peer becomes reachable + peerReady() + router.tickOutbox() + verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull()) + } + + @Test + fun `outbox cap evicts oldest and preserves order`() { + peerOffline() + repeat(101) { i -> + router.sendPrivate("content-$i", peerID, "peer", "msg-$i") + } + + assertEquals(listOf("msg-0"), expired) + + peerReady() + router.onSessionEstablished(peerID) + verify(mesh, times(100)).sendPrivateMessage(any(), eq(peerID), any(), any()) + verify(mesh, times(1)).sendPrivateMessage("content-1", peerID, "peer", "msg-1") + verify(mesh, times(1)).sendPrivateMessage("content-100", peerID, "peer", "msg-100") + verify(mesh, never()).sendPrivateMessage(eq("content-0"), any(), any(), anyOrNull()) + } + + @Test + fun `peer reappearance without pending messages does not kick handshake`() { + peerConnectedNoSession() + router.onPeersUpdated(listOf(peerID)) + verify(mesh, never()).initiateNoiseHandshake(any()) + } + + @Test + fun `established session flushes directly without handshake retry state`() { + peerReady() + val result = router.sendPrivate("direct", peerID, "peer", "msg-direct") + assertEquals(MessageRouter.RouteResult.MESH, result) + verify(mesh, times(1)).sendPrivateMessage("direct", peerID, "peer", "msg-direct") + verify(mesh, never()).initiateNoiseHandshake(any()) + } + + private fun peerOffline() { + whenever(mesh.getPeerInfo(peerID)).thenReturn(peerInfo(isConnected = false)) + whenever(mesh.hasEstablishedSession(peerID)).thenReturn(false) + } + + private fun peerConnectedNoSession() { + whenever(mesh.getPeerInfo(peerID)).thenReturn(peerInfo(isConnected = true)) + whenever(mesh.hasEstablishedSession(peerID)).thenReturn(false) + } + + private fun peerReady() { + whenever(mesh.getPeerInfo(peerID)).thenReturn(peerInfo(isConnected = true)) + whenever(mesh.hasEstablishedSession(peerID)).thenReturn(true) + } + + private fun peerInfo(isConnected: Boolean) = PeerInfo( + id = peerID, + nickname = "peer", + isConnected = isConnected, + isDirectConnection = true, + noisePublicKey = noiseKey, + signingPublicKey = ByteArray(32) { 0x0A }, + isVerifiedNickname = false, + lastSeen = System.currentTimeMillis() + ) +} From 673f65d052cfcaf6380911f286d5db7a0cc0bb25 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:42:15 +0200 Subject: [PATCH 61/70] Fix onboarding skip-button jump between permission screens. Align the background location footer with the battery optimization layout so primary, check-again, and skip stay at the same height when navigating between them. Co-authored-by: Cursor --- .../BackgroundLocationPermissionScreen.kt | 241 +++++++++--------- 1 file changed, 118 insertions(+), 123 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt index 50a58beb..7cd53924 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt @@ -37,6 +37,9 @@ import com.bitchat.android.R /** * Explanation screen shown before requesting background location permission. + * + * Bottom action layout matches [BatteryOptimizationEnabledContent] so the + * primary / check-again / skip buttons stay pixel-aligned across the two screens. */ @Composable fun BackgroundLocationPermissionScreen( @@ -46,132 +49,125 @@ fun BackgroundLocationPermissionScreen( onSkip: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - val scrollState = rememberScrollState() - Box(modifier = modifier) { + Box( + modifier = modifier.padding(24.dp), + contentAlignment = Alignment.Center + ) { Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp) - .padding(bottom = 88.dp) - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Spacer(modifier = Modifier.height(24.dp)) - - HeaderSection(colorScheme) - - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surfaceVariant.copy(alpha = 0.25f), - shape = RoundedCornerShape(12.dp) - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Filled.LocationOn, - contentDescription = stringResource(R.string.cd_location_services), - tint = colorScheme.primary, - modifier = Modifier - .padding(top = 2.dp) - .size(20.dp) - ) - Column { - Text( - text = stringResource(R.string.background_location_required_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = colorScheme.onBackground - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.background_location_explanation), - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onBackground.copy(alpha = 0.8f) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.background_location_settings_tip), - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = BitchatFontFamily - ), - color = colorScheme.onBackground.copy(alpha = 0.8f) - ) - } - } - } - } - - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surfaceVariant.copy(alpha = 0.25f), - shape = RoundedCornerShape(12.dp) - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Filled.Security, - contentDescription = stringResource(R.string.cd_privacy_protected), - tint = colorScheme.primary, - modifier = Modifier - .padding(top = 2.dp) - .size(20.dp) - ) - Column { - Text( - text = stringResource(R.string.background_location_needs_for), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = colorScheme.onBackground - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.background_location_needs_bullets), - style = MaterialTheme.typography.bodySmall, - 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 = BitchatFontFamily, - fontWeight = FontWeight.Medium - ), - color = colorScheme.onBackground - ) - } - } - } - } - - Spacer(modifier = Modifier.height(24.dp)) - } - - Surface( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth(), - color = colorScheme.surface, - shadowElevation = 8.dp + modifier = Modifier.fillMaxSize() ) { + // Scrollable content area Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + HeaderSection(colorScheme) + + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.LocationOn, + contentDescription = stringResource(R.string.cd_location_services), + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(20.dp) + ) + Column { + Text( + text = stringResource(R.string.background_location_required_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.background_location_explanation), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.background_location_settings_tip), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = BitchatFontFamily + ), + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + } + } + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Security, + contentDescription = stringResource(R.string.cd_privacy_protected), + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(20.dp) + ) + Column { + Text( + text = stringResource(R.string.background_location_needs_for), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.background_location_needs_bullets), + style = MaterialTheme.typography.bodySmall, + 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 = BitchatFontFamily, + fontWeight = FontWeight.Medium + ), + color = colorScheme.onBackground + ) + } + } + } + } + } + + // Fixed buttons at the bottom — same structure/spacing as battery optimization + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { Button( onClick = onContinue, @@ -185,8 +181,7 @@ fun BackgroundLocationPermissionScreen( style = MaterialTheme.typography.bodyMedium.copy( fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold - ), - modifier = Modifier.padding(vertical = 4.dp) + ) ) } From b79276f23645f18d7cd2a04fda2f104609083ecb Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:42:22 +0200 Subject: [PATCH 62/70] Tweak KDoc for onboarding footer alignment note. Co-authored-by: Cursor --- .../android/onboarding/BackgroundLocationPermissionScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt index 7cd53924..14fbb06d 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt @@ -38,8 +38,8 @@ import com.bitchat.android.R /** * Explanation screen shown before requesting background location permission. * - * Bottom action layout matches [BatteryOptimizationEnabledContent] so the - * primary / check-again / skip buttons stay pixel-aligned across the two screens. + * Bottom action layout matches the battery optimization skip screen so the + * primary / check-again / skip buttons stay pixel-aligned across both screens. */ @Composable fun BackgroundLocationPermissionScreen( From 3c150d91e0a455cc416b2c1f52924ce85400c656 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:48:10 +0200 Subject: [PATCH 63/70] fix: unify outbox locking and tie retry scheduler to service lifecycle --- .../android/service/MeshForegroundService.kt | 1 + .../bitchat/android/services/MessageRouter.kt | 73 ++++++++++++------- .../android/services/MessageRouterTest.kt | 17 +++++ 3 files changed, 64 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index d9ad2d7f..2d84b16f 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -144,6 +144,7 @@ class MeshForegroundService : Service() { when (intent?.action) { ACTION_STOP -> { // Stop FGS and mesh cleanly + try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.stopOutboxScheduler() } catch (_: Exception) { } try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { } try { MeshServiceHolder.clear() } catch (_: Exception) { } try { stopForeground(true) } catch (_: Exception) { } diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index 7905aee8..a09fa31e 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -66,9 +66,11 @@ class MessageRouter private constructor( } } } - // Always update mesh reference and sync peer ID + // Always update mesh reference and sync peer ID, and make sure the retry + // scheduler is running (it is stopped together with MeshForegroundService). instance.mesh = mesh instance.nostr.senderPeerID = mesh.myPeerID + instance.startOutboxScheduler() return instance } @@ -85,6 +87,7 @@ class MessageRouter private constructor( private val retryState = ConcurrentHashMap() private val schedulerScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private var schedulerJob: kotlinx.coroutines.Job? = null // Injectable clock for tests internal var clock: () -> Long = { System.currentTimeMillis() } @@ -93,7 +96,7 @@ class MessageRouter private constructor( var onMessageExpired: ((String) -> Unit)? = null init { - if (!disableSchedulerForTesting) startOutboxScheduler() + startOutboxScheduler() } // Listener for favorites changes to flush outbox when npub mapping appears/changes @@ -185,26 +188,27 @@ class MessageRouter private constructor( } } - // Flush any queued messages for a specific peerID + // Flush any queued messages for a specific peerID. + // All outbox mutations happen under the router monitor so a concurrent enqueue cannot + // be lost between the empty check and the map removal. + @Synchronized fun flushOutboxFor(peerID: String) { val conversationID = ContactDirectory.canonicalConversationId(peerID) val queued = outbox[conversationID] ?: outbox[peerID] ?: return if (queued.isEmpty()) return Log.d(TAG, "Flushing outbox for ${conversationID.take(16)}… count=${queued.size}") - synchronized(queued) { - val iterator = queued.iterator() - while (iterator.hasNext()) { - val entry = iterator.next() - val resolution = ContactDirectory.resolve(conversationID) - val meshTarget = resolution.meshPeerID - val nostrTarget = resolution.noiseKeyHex ?: conversationID - if (meshTarget != null && isReady(mesh, meshTarget)) { - mesh.sendPrivateMessage(entry.content, meshTarget, entry.nickname, entry.messageID) - iterator.remove() - } else if (canSendViaNostr(nostrTarget)) { - nostr.sendPrivateMessage(entry.content, nostrTarget, entry.nickname, entry.messageID) - iterator.remove() - } + val iterator = queued.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + val resolution = ContactDirectory.resolve(conversationID) + val meshTarget = resolution.meshPeerID + val nostrTarget = resolution.noiseKeyHex ?: conversationID + if (meshTarget != null && isReady(mesh, meshTarget)) { + mesh.sendPrivateMessage(entry.content, meshTarget, entry.nickname, entry.messageID) + iterator.remove() + } else if (canSendViaNostr(nostrTarget)) { + nostr.sendPrivateMessage(entry.content, nostrTarget, entry.nickname, entry.messageID) + iterator.remove() } } if (queued.isEmpty()) { @@ -257,8 +261,11 @@ class MessageRouter private constructor( Log.d(TAG, "Handshake attempt ${attempts + 1} for ${conversationID.take(16)}…, next retry in ${backoff}ms") } + @Synchronized private fun startOutboxScheduler() { - schedulerScope.launch { + if (disableSchedulerForTesting) return + if (schedulerJob?.isActive == true) return + schedulerJob = schedulerScope.launch { while (isActive) { delay(OUTBOX_TICK_MS) try { tickOutbox() } catch (e: Exception) { @@ -268,11 +275,24 @@ class MessageRouter private constructor( } } + /** + * Stop retrying while the mesh transports are down. Persistent network work must + * follow the MeshForegroundService lifecycle; getInstance restarts the scheduler + * and rebinds the mesh reference when the service comes back. + */ + fun stopOutboxScheduler() { + schedulerJob?.cancel() + schedulerJob = null + } + + internal val isSchedulerRunning: Boolean get() = schedulerJob?.isActive == true + /** * One scheduler pass over the outbox: expire old entries, flush what can be sent, * and re-initiate handshakes (with backoff) for peers that are connected but have * no established session yet. */ + @Synchronized internal fun tickOutbox(nowMs: Long = clock()) { outbox.keys.toList().forEach { conversationID -> expireOldEntries(conversationID, nowMs) @@ -299,15 +319,13 @@ class MessageRouter private constructor( private fun expireOldEntries(conversationID: String, nowMs: Long) { val queued = outbox[conversationID] ?: return - synchronized(queued) { - val iterator = queued.iterator() - while (iterator.hasNext()) { - val entry = iterator.next() - if (nowMs - entry.enqueuedAtMs > OUTBOX_MESSAGE_TTL_MS) { - Log.w(TAG, "Expiring queued PM for ${conversationID.take(16)}… msg_id=${entry.messageID.take(8)}…") - iterator.remove() - notifyExpired(entry.messageID) - } + val iterator = queued.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (nowMs - entry.enqueuedAtMs > OUTBOX_MESSAGE_TTL_MS) { + Log.w(TAG, "Expiring queued PM for ${conversationID.take(16)}… msg_id=${entry.messageID.take(8)}…") + iterator.remove() + notifyExpired(entry.messageID) } } if (queued.isEmpty()) { @@ -389,6 +407,7 @@ class MessageRouter private constructor( * A peer (re)appeared: if we still owe them queued messages and there is no working * session yet, restart the handshake immediately instead of waiting for the backoff. */ + @Synchronized private fun kickHandshakeIfPending(peerID: String) { val conversationID = ContactDirectory.canonicalConversationId(peerID) val queued = outbox[conversationID] ?: outbox[peerID] ?: return diff --git a/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt index 4ec89fc4..48cbe2da 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt @@ -7,6 +7,8 @@ import com.bitchat.android.mesh.MeshService import com.bitchat.android.mesh.PeerInfo import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -172,6 +174,21 @@ class MessageRouterTest { verify(mesh, never()).initiateNoiseHandshake(any()) } + @Test + fun `scheduler stops with the mesh service and restarts on rebind`() { + MessageRouter.disableSchedulerForTesting = false + MessageRouter.resetForTesting() + val context = RuntimeEnvironment.getApplication() + val running = MessageRouter.getInstance(context, mesh) + assertTrue(running.isSchedulerRunning) + + running.stopOutboxScheduler() + assertFalse(running.isSchedulerRunning) + + val rebound = MessageRouter.getInstance(context, mesh) + assertTrue(rebound.isSchedulerRunning) + } + private fun peerOffline() { whenever(mesh.getPeerInfo(peerID)).thenReturn(peerInfo(isConnected = false)) whenever(mesh.hasEstablishedSession(peerID)).thenReturn(false) From dfe4fba2128778580c6657bdcc22ab789784474e Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:50:27 +0200 Subject: [PATCH 64/70] fix: preserve private message arrival order --- .../android/services/ContactDirectory.kt | 7 +- .../services/ConversationAliasResolver.kt | 1 - .../android/services/AppStateStoreTest.kt | 25 +++++-- .../services/ConversationAliasResolverTest.kt | 69 +++++++++++++++++++ 4 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt diff --git a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt index b3ffc429..eeec28e7 100644 --- a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt +++ b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt @@ -102,9 +102,10 @@ object ContactDirectory { } return merged.mapValues { (_, messages) -> - messages - .distinctBy { it.id } - .sortedWith(compareBy { it.timestamp.time }.thenBy { it.id }) + // A private message's timestamp comes from the sender and is not a reliable ordering + // signal when peers' clocks differ. The lists are populated in receive order, so keep + // their first occurrence in that same order while canonicalizing aliases. + messages.distinctBy { it.id } } } diff --git a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt index e3c2c61d..96fa15b8 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt @@ -65,7 +65,6 @@ object ConversationAliasResolver { if (didMerge) { currentChats[targetConversationID] = targetList .distinctBy { it.id } - .sortedBy { it.timestamp.time } state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentChats)) // Move unread flags diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index a70d4fa9..afce826f 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -128,16 +128,29 @@ class AppStateStoreTest { } @Test - fun `canonicalized private chat history is chronological after alias merge`() { + fun `canonicalized private chat history keeps arrival order when peer clocks differ`() { val noiseKeyHex = "01".repeat(32) val contactID = ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 1 }) - val later = BitchatMessage(id = "later", sender = "alice", content = "later", timestamp = Date(3)) - val earlier = BitchatMessage(id = "earlier", sender = "alice", content = "earlier", timestamp = Date(1)) + val firstArrival = BitchatMessage( + id = "first-arrival", + sender = "alice", + content = "sent from a clock that is ahead", + timestamp = Date(3) + ) + val secondArrival = BitchatMessage( + id = "second-arrival", + sender = "alice", + content = "sent from a clock that is behind", + timestamp = Date(1) + ) - AppStateStore.addPrivateMessage(contactID, later) - AppStateStore.addPrivateMessage(noiseKeyHex, earlier) + AppStateStore.addPrivateMessage(contactID, firstArrival) + AppStateStore.addPrivateMessage(noiseKeyHex, secondArrival) - assertEquals(listOf(earlier, later), AppStateStore.privateMessages.value[contactID]) + assertEquals( + listOf(firstArrival, secondArrival), + AppStateStore.privateMessages.value[contactID] + ) } @Test diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt new file mode 100644 index 00000000..6286419f --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt @@ -0,0 +1,69 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.ChatState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.Date + +class ConversationAliasResolverTest { + private lateinit var scope: CoroutineScope + private lateinit var state: ChatState + + @Before + fun setUp() { + AppStateStore.clear() + scope = CoroutineScope(SupervisorJob()) + state = ChatState(scope) + } + + @After + fun tearDown() { + scope.cancel() + AppStateStore.clear() + } + + @Test + fun `explicit alias merge keeps arrival order when peer clocks differ`() { + val firstArrival = BitchatMessage( + id = "first-arrival", + sender = "alice", + content = "sent from a clock that is ahead", + timestamp = Date(3) + ) + val secondArrival = BitchatMessage( + id = "second-arrival", + sender = "alice", + content = "sent from a clock that is behind", + timestamp = Date(1) + ) + state.setPrivateChats( + linkedMapOf( + "target-peer" to listOf(firstArrival), + "source-alias" to listOf(secondArrival) + ) + ) + AppStateStore.addPrivateMessage("target-peer", firstArrival) + AppStateStore.addPrivateMessage("source-alias", secondArrival) + + ConversationAliasResolver.unifyChatsIntoPeer( + state = state, + targetPeerID = "target-peer", + keysToMerge = listOf("source-alias") + ) + + assertEquals( + listOf(firstArrival, secondArrival), + state.getPrivateChatsValue()["target-peer"] + ) + assertEquals( + listOf(firstArrival, secondArrival), + AppStateStore.privateMessages.value["target-peer"] + ) + } +} From 3484ff5ccfcd4033e2d7127ea5e7ee51ca8240d6 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:18:15 +0200 Subject: [PATCH 65/70] readme --- README.md | 321 +++++++++--------------------------------------------- 1 file changed, 52 insertions(+), 269 deletions(-) diff --git a/README.md b/README.md index c9b0091d..97be62da 100644 --- a/README.md +++ b/README.md @@ -5,304 +5,87 @@ > [!WARNING] > This software has not received external security review and may contain vulnerabilities and may not necessarily meet its stated security goals. Do not use it for sensitive use cases, and do not rely on its security until it has been reviewed. Work in progress. -# bitchat for Android +## bitchat for Android -A secure, decentralized, peer-to-peer messaging app that works over Bluetooth mesh networks. No internet required for mesh chats, no servers, no phone numbers - just pure encrypted communication. Bitchat also supports geohash channels, which use an internet connection to connect you with others in your geographic area. +A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers. -This is the **Android port** of the original [bitchat iOS app](https://github.com/jackjackbits/bitchat), maintaining 100% protocol compatibility for cross-platform communication. +This is the Android implementation of bitchat, fully protocol-compatible with the [iOS version](https://github.com/permissionlesstech/bitchat) for cross-platform mesh communication. -## Install bitchat +[bitchat.free](http://bitchat.free) -You can download the latest version of bitchat for Android from the [GitHub Releases page](https://github.com/permissionlesstech/bitchat-android/releases). - -Or you can: +[GitHub Releases](https://github.com/permissionlesstech/bitchat-android/releases) [Get it on Google Play](https://play.google.com/store/apps/details?id=com.bitchat.droid) -**Instructions:** - -1. **Download the APK:** On your Android device, navigate to the link above and download the latest `.apk` file. Open it. -2. **Allow Unknown Sources:** On some devices, before you can install the APK, you may need to enable "Install from unknown sources" in your device's settings. This is typically found under **Settings > Security** or **Settings > Apps & notifications > Special app access**. -3. **Install:** Open the downloaded `.apk` file to begin the installation. - ## License This project is released into the public domain. See the [LICENSE](LICENSE.md) file for details. ## Features -- **✅ Cross-Platform Compatible**: Full protocol compatibility with iOS bitchat -- **✅ Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE -- **✅ End-to-End Encryption**: X25519 key exchange + AES-256-GCM for private messages -- **✅ Channel-Based Chats**: Topic-based group messaging with optional password protection -- **✅ Store & Forward**: Messages cached for offline peers and delivered when they reconnect -- **✅ Privacy First**: No accounts, no phone numbers, no persistent identifiers -- **✅ IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface -- **✅ Message Retention**: Optional channel-wide message saving controlled by channel owners -- **✅ Emergency Wipe**: Triple-tap logo to instantly clear all data -- **✅ Modern Android UI**: Jetpack Compose with Material Design 3 -- **✅ Dark/Light Themes**: Terminal-inspired aesthetic matching iOS version -- **✅ Battery Optimization**: Adaptive scanning and power management - -## Android Setup - -### Prerequisites - -- **Android Studio**: Arctic Fox (2020.3.1) or newer -- **Android SDK**: API level 26 (Android 8.0) or higher -- **Kotlin**: 1.8.0 or newer -- **Gradle**: 7.0 or newer - -### Build Instructions - -1. **Clone the repository:** - ```bash - git clone https://github.com/permissionlesstech/bitchat-android.git - cd bitchat-android - ``` - -2. **Open in Android Studio:** - ```bash - # Open Android Studio and select "Open an Existing Project" - # Navigate to the bitchat-android directory - ``` - -3. **Build the project:** - ```bash - ./gradlew build - ``` - -4. **Install on device:** - ```bash - ./gradlew installDebug - ``` - -### Development Build - -For development builds with debugging enabled: - -```bash -./gradlew assembleDebug -adb install -r app/build/outputs/apk/debug/app-debug.apk -``` - -### Release Build - -For production releases: - -```bash -./gradlew assembleRelease -``` - -## Android-Specific Requirements - -### Permissions - -The app requires the following permissions (automatically requested): - -- **Bluetooth**: Core BLE functionality -- **Location**: Required for BLE scanning on Android -- **Network**: Expand your mesh through public internet relays -- **Notifications**: Message alerts and background updates - -### Hardware Requirements - -- **Bluetooth LE (BLE)**: Required for mesh networking -- **Android 8.0+**: API level 26 minimum -- **RAM**: 2GB recommended for optimal performance - -## Usage - -### Basic Commands - -- `/j #channel` - Join or create a channel -- `/m @name message` - Send a private message -- `/w` - List online users -- `/channels` - Show all discovered channels -- `/block @name` - Block a peer from messaging you -- `/block` - List all blocked peers -- `/unblock @name` - Unblock a peer -- `/clear` - Clear chat messages -- `/pass [password]` - Set/change channel password (owner only) -- `/transfer @name` - Transfer channel ownership -- `/save` - Toggle message retention for channel (owner only) - -### Getting Started - -1. **Install the app** on your Android device (requires Android 8.0+) -2. **Grant permissions** for Bluetooth and location when prompted -3. **Launch bitchat** - it will auto-start mesh networking -4. **Set your nickname** or use the auto-generated one -5. **Connect automatically** to nearby iOS and Android bitchat users -6. **Join a channel** with `/j #general` or start chatting in public -7. **Messages relay** through the mesh network to reach distant peers - -### Android UI Features - -- **Jetpack Compose UI**: Modern Material Design 3 interface -- **Dark/Light Themes**: Terminal-inspired aesthetic matching iOS -- **Haptic Feedback**: Vibrations for interactions and notifications -- **Adaptive Layout**: Optimized for various Android screen sizes -- **Message Status**: Real-time delivery and read receipts -- **RSSI Indicators**: Signal strength colors for each peer - -### Channel Features - -- **Password Protection**: Channel owners can set passwords with `/pass` -- **Message Retention**: Owners can enable mandatory message saving with `/save` -- **@ Mentions**: Use `@nickname` to mention users (with autocomplete) -- **Ownership Transfer**: Pass control to trusted users with `/transfer` - -## Security & Privacy - -### Encryption -- **Private Messages**: X25519 key exchange + AES-256-GCM encryption -- **Channel Messages**: Argon2id password derivation + AES-256-GCM -- **Digital Signatures**: Ed25519 for message authenticity -- **Forward Secrecy**: New key pairs generated each session - -### Privacy Features -- **No Registration**: No accounts, emails, or phone numbers required -- **Ephemeral by Default**: Messages exist only in device memory -- **Cover Traffic**: Random delays and dummy messages prevent traffic analysis -- **Emergency Wipe**: Triple-tap logo to instantly clear all data -- **Bundled Tor Support**: Built-in Tor network integration for enhanced privacy when internet connectivity is available - -## Performance & Efficiency - -### Message Compression -- **LZ4 Compression**: Automatic compression for messages >100 bytes -- **30-70% bandwidth savings** on typical text messages -- **Smart compression**: Skips already-compressed data - -### Battery Optimization -- **Adaptive Power Modes**: Automatically adjusts based on battery level - - Performance mode: Full features when charging or >60% battery - - Balanced mode: Default operation (30-60% battery) - - Power saver: Reduced scanning when <30% battery - - Ultra-low power: Emergency mode when <10% battery -- **Background efficiency**: Automatic power saving when app backgrounded -- **Configurable scanning**: Duty cycle adapts to battery state - -### Network Efficiency -- **Optimized Bloom filters**: Faster duplicate detection with less memory -- **Message aggregation**: Batches small messages to reduce transmissions -- **Adaptive connection limits**: Adjusts peer connections based on power mode +- **Dual Transport Architecture**: Bluetooth LE mesh for offline messaging, Nostr relays for internet-based messaging +- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over Nostr relays +- **Intelligent Message Routing**: Automatically chooses the best transport, with queuing and retry when a peer is unreachable +- **End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) (XX pattern, X25519 + ChaCha20-Poly1305) for private messages over the mesh +- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop relay over Bluetooth LE (max 7 hops) +- **Wi-Fi Aware Transport**: Higher-bandwidth local mesh on supported devices +- **Channel Chats**: Topic-based group messaging with optional password protection (Argon2id + AES-256-GCM) +- **IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface +- **Tor Support**: Built-in Tor (Arti) for private internet connectivity +- **Emergency Wipe**: Triple-tap to instantly clear all data +- **Cross-Platform**: Binary protocol compatible with bitchat on iOS and macOS ## Technical Architecture -### Binary Protocol -bitchat uses an efficient binary protocol optimized for Bluetooth LE: -- Compact packet format with 1-byte type field -- TTL-based message routing (max 7 hops) -- Automatic fragmentation for large messages -- Message deduplication via unique IDs +### Bluetooth Mesh Network (Offline) -### Mesh Networking -- Each device acts as both client and peripheral -- Automatic peer discovery and connection management -- Store-and-forward for offline message delivery -- Adaptive duty cycling for battery optimization +- Direct peer-to-peer within Bluetooth range, multi-hop relay through nearby devices +- Noise Protocol sessions with forward secrecy; peer identities derived from static keys +- Compact binary packet format with fragmentation, TTL routing, and deduplication +- Adaptive duty cycling and connection limits for battery efficiency +- Foreground service keeps the mesh alive within Android background execution limits -### Android-Specific Optimizations -- **Coroutine Architecture**: Asynchronous operations for mesh networking -- **Kotlin Coroutines**: Thread-safe concurrent mesh operations -- **EncryptedSharedPreferences**: Secure storage for user settings -- **Lifecycle-Aware**: Proper handling of Android app lifecycle -- **Battery Optimization**: Foreground service and adaptive scanning +### Nostr Protocol (Internet) -## Android Technical Architecture +- Global reach via public relays, geohash-based location channels +- Private messages fall back to Nostr for mutual favorites when the mesh is unavailable +- Ephemeral keys per geohash area -### Core Components +### Android Stack -1. **BitchatApplication.kt**: Application-level initialization and dependency injection -2. **MainActivity.kt**: Main activity handling permissions and UI hosting -3. **ChatViewModel.kt**: MVVM pattern managing app state and business logic -4. **BluetoothMeshService.kt**: Core BLE mesh networking (central + peripheral roles) -5. **EncryptionService.kt**: Cryptographic operations using BouncyCastle -6. **BinaryProtocol.kt**: Binary packet encoding/decoding matching iOS format -7. **ChatScreen.kt**: Jetpack Compose UI with Material Design 3 +- Kotlin, Jetpack Compose (Material 3), MVVM +- Coroutines and Flow for all networking and state +- Core components: `MeshForegroundService` (persistent connectivity), `BluetoothMeshService` / `WifiAwareMeshService` (transports), `UnifiedMeshService` (transport selection), `NoiseSessionManager` (encryption sessions), `MessageRouter` (mesh/Nostr routing with outbox retry) -### Dependencies +## Building -- **Jetpack Compose**: Modern declarative UI -- **BouncyCastle**: Cryptographic operations (X25519, Ed25519, AES-GCM) -- **Nordic BLE Library**: Reliable Bluetooth LE operations -- **Kotlin Coroutines**: Asynchronous programming -- **LZ4**: Message compression (when enabled) -- **EncryptedSharedPreferences**: Secure local storage +Requires Android Studio and the Android SDK (API 26+). -### Binary Protocol Compatibility +```bash +git clone https://github.com/permissionlesstech/bitchat-android.git +cd bitchat-android +./gradlew assembleDebug +``` -The Android implementation maintains 100% binary protocol compatibility with iOS: -- **Header Format**: Identical 13-byte header structure -- **Packet Types**: Same message types and routing logic -- **Encryption**: Identical cryptographic algorithms and key exchange -- **UUIDs**: Same Bluetooth service and characteristic identifiers -- **Fragmentation**: Compatible message fragmentation for large content +Install on a connected device: -## Publishing to Google Play +```bash +adb install -r app/build/outputs/apk/debug/app-debug.apk +``` -### Preparation +The app requests Bluetooth, location (required for BLE scanning), and notification permissions at runtime. -1. **Update version information:** - ```kotlin - // In app/build.gradle.kts - defaultConfig { - versionCode = 2 // Increment for each release - versionName = "1.1.0" // User-visible version - } - ``` +## Testing -2. **Create a signed release build:** - ```bash - ./gradlew assembleRelease - ``` +```bash +# Unit tests +./gradlew test -3. **Generate app bundle (recommended for Play Store):** - ```bash - ./gradlew bundleRelease - ``` +# Lint +./gradlew lint -### Play Store Requirements +# Instrumented tests (requires a device or emulator) +./gradlew connectedAndroidTest +``` -- **Target API**: Latest Android API (currently 34) -- **Privacy Policy**: Required for apps requesting sensitive permissions -- **App Permissions**: Justify Bluetooth and location usage -- **Content Rating**: Complete questionnaire for age-appropriate content - -### Distribution - -- **Google Play Store**: Main distribution channel -- **F-Droid**: For open-source distribution -- **Direct APK**: For testing and development - -## Cross-Platform Communication - -This Android port enables seamless communication with the original iOS bitchat app: - -- **iPhone ↔ Android**: Full bidirectional messaging -- **Mixed Groups**: iOS and Android users in same channels -- **Feature Parity**: All commands and encryption work across platforms -- **Protocol Sync**: Identical message format and routing behavior - -**iOS Version**: For iPhone/iPad users, get the original bitchat at [github.com/jackjackbits/bitchat](https://github.com/jackjackbits/bitchat) - -## Contributing - -Contributions are welcome! Key areas for enhancement: - -1. **Performance**: Battery optimization and connection reliability -2. **UI/UX**: Additional Material Design 3 features -3. **Security**: Enhanced cryptographic features -4. **Testing**: Unit and integration test coverage -5. **Documentation**: API documentation and development guides - -## Support & Issues - -- **Bug Reports**: [Create an issue](../../issues) with device info and logs -- **Feature Requests**: [Start a discussion](https://github.com/orgs/permissionlesstech/discussions) -- **Security Issues**: Email security concerns privately -- **iOS Compatibility**: Cross-reference with [original iOS repo](https://github.com/jackjackbits/bitchat) - -For iOS-specific issues, please refer to the [original iOS bitchat repository](https://github.com/jackjackbits/bitchat). +Note that BLE mesh behavior is difficult to emulate; protocol and session logic is covered by unit tests, while radio-level behavior needs real devices. From 5fd4067ba4e3cb5895d2abe330f3a48e233e4b04 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:19:51 +0200 Subject: [PATCH 66/70] readme --- README.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/README.md b/README.md index 97be62da..93d740c4 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,4 @@ -

- Bitchat Android Logo -

- -> [!WARNING] -> This software has not received external security review and may contain vulnerabilities and may not necessarily meet its stated security goals. Do not use it for sensitive use cases, and do not rely on its security until it has been reviewed. Work in progress. +icon_128x128@2x ## bitchat for Android From 79a44ba5468c709df7eac3b2828f7c3b9f5e0c36 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:20:32 +0200 Subject: [PATCH 67/70] update --- GOOGLE_PLAY.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 GOOGLE_PLAY.md diff --git a/GOOGLE_PLAY.md b/GOOGLE_PLAY.md deleted file mode 100644 index bdea715e..00000000 --- a/GOOGLE_PLAY.md +++ /dev/null @@ -1 +0,0 @@ -I, callebtc, creator of bitchat for android, owner of the copyright claims, and owner of the official bitchat android repository (https://github.com/permissionlesstech/bitchat-android), hereby authorize Verse Communication PBC to publish bitchat (com.bitchat.doid) on the Google Play Store. From 80f774dd0ba01d4ee383836b055dd47c2c9c79d9 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:20:57 +0200 Subject: [PATCH 68/70] update --- CHANGELOG.md | 178 ---------------------------------------------- PRIVACY_POLICY.md | 156 ---------------------------------------- 2 files changed, 334 deletions(-) delete mode 100644 CHANGELOG.md delete mode 100644 PRIVACY_POLICY.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 89adc1eb..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,178 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -## [1.4.0] - 2025-10-15 -### Fixed -- fix: Resolve debug settings bottom sheet crash on some devices (Issue #472) - - Fixed IllegalFormatConversionException in DebugSettingsSheet.kt when scrolling through debug settings - - Corrected string formatting for debug_target_fpr_fmt and debug_derived_p_fmt string resources - - Improved string resource parameter handling for numeric values - -## [0.7.2] - 2025-07-20 -### Fixed -- fix: battery optimization screen content scrollable with fixed buttons - -## [0.7.1] - 2025-07-19 - -### Added -- feat(battery): add battery optimization management for background reliability - -### Fixed -- fix: center align toolbar item in ChatHeader - passed modifier.fillmaxHeight so the content inside the row can actually be centered -- fix: update sidebar text to use string resources -- fix(chat): cursor location and enhance message input with slash command styling - -### Changed -- refactor: remove context attribute at ChatViewModel.kt -- Refactor: Migrate MainViewModel to use StateFlow - -### Improved -- Use HorizontalDivider instead of deprecated Divider -- Use contentPadding instead of padding so items remain fully visible - - -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [0.7] - -### Added -- Location services check during app startup with educational UI -- Message text selection functionality in chat interface -- Enhanced RSSI tracking and unread message indicators -- Major Bluetooth connection architecture refactoring with dedicated managers - -### Fixed -- **Critical**: Android-iOS message fragmentation compatibility issues - - Fixed fragment size (500→150 bytes) and ID generation for cross-platform messaging - - Ensures Android can properly communicate with iOS devices -- DirectMessage notifications and text copying functionality -- Smart routing optimizations (no relay loops, targeted delivery) -- Build system compilation issues and null pointer exceptions - -### Changed -- Comprehensive dependency updates (AGP 8.10.1, Kotlin 2.2.0, Compose 2025.06.01) -- Optimized BLE scan intervals for better battery performance -- Reduced excessive logging output - -### Improved -- Cross-platform compatibility with iOS and Rust implementations -- Connection stability through architectural improvements -- Battery performance via scan duty cycling -- User onboarding with location services education - -## [0.6] - -### Added -- Channel password management with `/pass` command for channel owners -- Monochrome/themed launcher icon for Android 12+ dynamic theming support -- Unit tests package with initial testing infrastructure -- Production build optimization with code minification and shrinking -- Native back gesture/button handling for all app views - -### Fixed -- Favorite peer functionality completely restored and improved - - Enhanced favorite system with fallback mechanism for peers without key exchange - - Fixed UI state updates for favorite stars in both header and sidebar - - Improved favorite persistence across app sessions -- `/w` command now displays user nicknames instead of peer IDs -- Button styling and layout improvements across the app - - Enhanced back button positioning and styling - - Improved private chat and channel header button layouts - - Fixed button padding and alignment issues -- Color scheme consistency updates - - Updated orange color throughout the app to match iOS version - - Consistent color usage for private messages and UI elements -- App startup reliability improvements - - Better initialization sequence handling - - Fixed null pointer exceptions during startup - - Enhanced error handling and logging -- Input field styling and behavior improvements -- Sidebar user interaction enhancements -- Permission explanation screen layout fixes with proper vertical padding - -### Changed -- Updated GitHub organization references in project files -- Improved README documentation with updated clone URLs -- Enhanced logging throughout the application for better debugging - -## [0.5.1] - 2025-07-10 - -### Added -- Bluetooth startup check with user prompt to enable Bluetooth if disabled - -### Fixed -- Improved Bluetooth initialization reliability on first app launch - -## [0.5] - 2025-07-10 - -### Added -- New user onboarding screen with permission explanations -- Educational content explaining why each permission is required -- Privacy assurance messaging (no tracking, no servers, local-only data) - -### Fixed -- Comprehensive permission validation - ensures all required permissions are granted -- Proper Bluetooth stack initialization on first app load -- Eliminated need for manual app restart after installation -- Enhanced permission request coordination and error handling - -### Changed -- Improved first-time user experience with guided setup flow - -## [0.4] - 2025-07-10 - -### Added -- Push notifications for direct messages -- Enhanced notification system with proper click handling and grouping - -### Improved -- Direct message (DM) view with better user interface -- Enhanced private messaging experience - -### Known Issues -- Favorite peer functionality currently broken - -## [0.3] - 2025-07-09 - -### Added -- Battery-aware scanning policies for improved power management -- Dynamic scan behavior based on device battery state - -### Fixed -- Android-to-Android Bluetooth Low Energy connections -- Peer discovery reliability between Android devices -- Connection stability improvements - -## [0.2] - 2025-07-09 - -### Added -- Initial Android implementation of bitchat protocol -- Bluetooth Low Energy mesh networking -- End-to-end encryption for private messages -- Channel-based messaging with password protection -- Store-and-forward message delivery -- IRC-style commands (/msg, /join, /clear, etc.) -- RSSI-based signal quality indicators - -### Fixed -- Various Bluetooth handling improvements -- User interface refinements -- Connection reliability enhancements - -## [0.1] - 2025-07-08 - -### Added -- Initial release of bitchat Android client -- Basic mesh networking functionality -- Core messaging features -- Protocol compatibility with iOS bitchat client - -[Unreleased]: https://github.com/permissionlesstech/bitchat-android/compare/0.5.1...HEAD -[0.5.1]: https://github.com/permissionlesstech/bitchat-android/compare/0.5...0.5.1 -[0.5]: https://github.com/permissionlesstech/bitchat-android/compare/0.4...0.5 -[0.4]: https://github.com/permissionlesstech/bitchat-android/compare/0.3...0.4 -[0.3]: https://github.com/permissionlesstech/bitchat-android/compare/0.2...0.3 -[0.2]: https://github.com/permissionlesstech/bitchat-android/compare/0.1...0.2 -[0.1]: https://github.com/permissionlesstech/bitchat-android/releases/tag/0.1 diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md deleted file mode 100644 index a90dd78d..00000000 --- a/PRIVACY_POLICY.md +++ /dev/null @@ -1,156 +0,0 @@ -# bitchat Privacy Policy - -*Last updated: January 2025* - -## Our Commitment - -bitchat is designed with privacy as its foundation. We believe private communication is a fundamental human right. This policy explains how bitchat protects your privacy. - -## Summary - -**WE DO NOT COLLECT ANY INFORMATION.** - -- **No personal data collection** - We don't collect names, emails, or phone numbers -- **No location data collection** - Location is accessed only for local processing (BLE/Geohash) and is never collected or sent to us -- **Hybrid Functionality** - bitchat offers two modes of communication: - - **Bluetooth Mesh Chat**: This mode is completely offline, using peer-to-peer Bluetooth connections. It does not use any servers or internet connection. - - **Geohash Chat**: This mode uses an internet connection to communicate with others in a specific geographic area. It relies on Nostr relays for message transport. -- **No tracking** - We have no analytics, telemetry, or user tracking -- **Open source** - You can verify these claims by reading our code - -## What Information bitchat Stores - -### On Your Device Only - -1. **Identity Key** - - A cryptographic key generated on first launch - - Stored locally in your device's secure storage - - Allows you to maintain "favorite" relationships across app restarts - - Never leaves your device - -2. **Nickname** - - The display name you choose (or auto-generated) - - Stored only on your device - - Shared with peers you communicate with - -3. **Message History** (if enabled) - - When room owners enable retention, messages are saved locally - - Stored encrypted on your device - - You can delete this at any time - -4. **Favorite Peers** - - Public keys of peers you mark as favorites - - Stored only on your device - - Allows you to recognize these peers in future sessions - -### Temporary Session Data - -During each session, bitchat temporarily maintains: -- Active peer connections (forgotten when app closes) -- Routing information for message delivery -- Cached messages for offline peers (12 hours max) - -## What Information is Shared - -### With Other bitchat Users - -When you use bitchat, nearby peers can see: -- Your chosen nickname -- Your ephemeral public key (changes each session) -- Messages you send to public rooms or directly to them -- Your approximate Bluetooth signal strength (for connection quality) - -### With Room Members - -When you join a password-protected room: -- Your messages are visible to others with the password -- Your nickname appears in the member list -- Room owners can see you've joined - -## What We DON'T Do - -bitchat **never**: -- Collects personal information -- Collects location history -- Transmits any data to us (the developers) -- Stores data on servers -- Shares data with third parties -- Uses analytics or telemetry -- Creates user profiles -- Requires registration - -## Encryption - -All private messages use end-to-end encryption: -- **X25519** for key exchange -- **AES-256-GCM** for message encryption -- **Ed25519** for digital signatures -- **Argon2id** for password-protected rooms - -## Your Rights - -You have complete control: -- **Delete Everything**: Triple-tap the logo to instantly wipe all data -- **Leave Anytime**: Close the app and your presence disappears -- **No Account**: Nothing to delete from servers because there are none -- **Portability**: Your data never leaves your device unless you export it - -## Location Data & Permissions - -To provide the core functionality of bitchat, we access your device's location data. This access is necessary for the following specific purposes: - -### 1. Bluetooth Low Energy (BLE) Scanning -- **Why we need it:** The Android operating system requires Location permission to scan for nearby Bluetooth LE devices (especially on Android 11 and lower). This is a system-level requirement because Bluetooth scans can theoretically be used to derive location. -- **How we use it:** We use this permission strictly to discover other bitchat peers nearby for the "Bluetooth Mesh Chat" mode. -- **Privacy protection:** We do not record or store your location during this process. The data is processed instantaneously by the Android system to facilitate the connection. - -### 2. Geohash Chat Functionality -- **Why we need it:** The "Geohash Chat" mode allows you to communicate with others in your approximate geographic area. -- **How we use it:** If you enable this mode, we access your location to calculate a "geohash" (a short alphanumeric string representing a geographic region). This geohash is used to find and subscribe to relevant channels on decentralized Nostr relays. -- **Privacy protection:** - - Your precise GPS coordinates are **never** sent to any server or peer. - - Only the coarse geohash (representing an area, not a pinpoint) is shared with the Nostr network. - - You can use the "Bluetooth Mesh Chat" mode without this feature if you prefer. - -**We do not collect, store, or share your location history.** Location data is processed locally on your device to enable these specific features. - -## Children's Privacy - -bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone. - -## Data Retention - -- **Messages**: Deleted from memory when app closes (unless room retention is enabled) -- **Identity Key**: Persists until you delete the app -- **Favorites**: Persist until you remove them or delete the app -- **Everything Else**: Exists only during active sessions - -## Security Measures - -- All communication is encrypted -- No data transmitted to servers (there are none) -- Open source code for public audit -- Regular security updates -- Cryptographic signatures prevent tampering - -## Changes to This Policy - -If we update this policy: -- The "Last updated" date will change -- The updated policy will be included in the app -- No retroactive changes can affect data (since we don't collect any) - -## Contact - -bitchat is an open source project. For privacy questions: -- Review our code: https://github.com/yourusername/bitchat -- Open an issue on GitHub -- Join the discussion in public rooms - -## Philosophy - -Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no servers, no surveillance. Just people talking freely. - ---- - -*This policy is released into the public domain under The Unlicense, just like bitchat itself.* \ No newline at end of file From 62926f88d5414307bde160e7f8265b628f587758 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:29:01 +0200 Subject: [PATCH 69/70] fix: preserve arrival order across aliases --- .../bitchat/android/services/AppStateStore.kt | 2 + .../android/services/ContactDirectory.kt | 6 +-- .../services/PrivateMessageArrivalOrder.kt | 39 +++++++++++++++++++ .../com/bitchat/android/ui/MessageManager.kt | 5 +++ .../services/ConversationAliasResolverTest.kt | 35 ++++++++++------- 5 files changed, 70 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index c7971eb8..503f26a6 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -99,6 +99,7 @@ object AppStateStore { synchronized(this) { if (seenMessageIds.contains(msg.id)) return seenMessageIds.add(msg.id) + PrivateMessageArrivalOrder.record(msg.id) val conversationID = ContactDirectory.canonicalConversationId(peerID) val map = _privateMessages.value.toMutableMap() val list = (map[conversationID] ?: emptyList()) + msg @@ -207,6 +208,7 @@ object AppStateStore { synchronized(this) { seenMessageIds.clear() seenPublicMessageKeys.clear() + PrivateMessageArrivalOrder.clear() peerIdsByTransport.clear() directPeerIdsByTransport.clear() _peers.value = emptyList() diff --git a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt index eeec28e7..28cb695c 100644 --- a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt +++ b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt @@ -103,9 +103,9 @@ object ContactDirectory { return merged.mapValues { (_, messages) -> // A private message's timestamp comes from the sender and is not a reliable ordering - // signal when peers' clocks differ. The lists are populated in receive order, so keep - // their first occurrence in that same order while canonicalizing aliases. - messages.distinctBy { it.id } + // signal when peers' clocks differ. Use the local receipt sequence so interleaved + // alias lists can be merged back into their global arrival order. + PrivateMessageArrivalOrder.order(messages.distinctBy { it.id }) } } diff --git a/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt b/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt new file mode 100644 index 00000000..a5c92453 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt @@ -0,0 +1,39 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage + +/** + * Process-local receipt sequence for private messages. + * + * Peer-provided timestamps cannot safely order a conversation because clocks can differ. This + * registry follows the lifetime of [AppStateStore] and lets alias merges reconstruct the order in + * which messages entered the app, even when they were initially stored under different keys. + */ +internal object PrivateMessageArrivalOrder { + private val sequenceByMessageID = mutableMapOf() + private var nextSequence = 0L + + fun record(messageID: String) { + synchronized(this) { + if (messageID !in sequenceByMessageID) { + sequenceByMessageID[messageID] = nextSequence++ + } + } + } + + fun order(messages: List): List { + synchronized(this) { + if (messages.size < 2 || messages.any { it.id !in sequenceByMessageID }) { + return messages + } + return messages.sortedBy { sequenceByMessageID.getValue(it.id) } + } + } + + fun clear() { + synchronized(this) { + sequenceByMessageID.clear() + nextSequence = 0L + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index 0c7b64b2..41a610f4 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.PrivateMessageArrivalOrder import java.util.* import java.util.Collections @@ -110,6 +111,8 @@ class MessageManager(private val state: ChatState) { val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf() chatMessages.add(message) currentPrivateChats[conversationID] = chatMessages + // Record the local arrival sequence before canonicalizing UI aliases. + PrivateMessageArrivalOrder.record(message.id) state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats)) // Reflect into process-wide store try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { } @@ -132,6 +135,8 @@ class MessageManager(private val state: ChatState) { val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf() chatMessages.add(message) currentPrivateChats[conversationID] = chatMessages + // Record the local arrival sequence before canonicalizing UI aliases. + PrivateMessageArrivalOrder.record(message.id) state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats)) // Reflect into process-wide store try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { } diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt index 6286419f..ce3eedbd 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt @@ -29,27 +29,34 @@ class ConversationAliasResolverTest { } @Test - fun `explicit alias merge keeps arrival order when peer clocks differ`() { + fun `explicit alias merge restores global order for interleaved arrivals`() { val firstArrival = BitchatMessage( - id = "first-arrival", + id = "alias-a-first", sender = "alice", - content = "sent from a clock that is ahead", - timestamp = Date(3) + content = "first arrival through alias A", + timestamp = Date(300) ) val secondArrival = BitchatMessage( - id = "second-arrival", + id = "alias-b-first", sender = "alice", - content = "sent from a clock that is behind", - timestamp = Date(1) + content = "second arrival through alias B", + timestamp = Date(100) ) - state.setPrivateChats( - linkedMapOf( - "target-peer" to listOf(firstArrival), - "source-alias" to listOf(secondArrival) - ) + val thirdArrival = BitchatMessage( + id = "alias-a-second", + sender = "alice", + content = "third arrival through alias A", + timestamp = Date(200) ) AppStateStore.addPrivateMessage("target-peer", firstArrival) AppStateStore.addPrivateMessage("source-alias", secondArrival) + AppStateStore.addPrivateMessage("target-peer", thirdArrival) + state.setPrivateChats( + linkedMapOf( + "target-peer" to listOf(firstArrival, thirdArrival), + "source-alias" to listOf(secondArrival) + ) + ) ConversationAliasResolver.unifyChatsIntoPeer( state = state, @@ -58,11 +65,11 @@ class ConversationAliasResolverTest { ) assertEquals( - listOf(firstArrival, secondArrival), + listOf(firstArrival, secondArrival, thirdArrival), state.getPrivateChatsValue()["target-peer"] ) assertEquals( - listOf(firstArrival, secondArrival), + listOf(firstArrival, secondArrival, thirdArrival), AppStateStore.privateMessages.value["target-peer"] ) } From 96340a35aa2e03de6c9a2184ae2b4929058d9c89 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:57:30 +0200 Subject: [PATCH 70/70] fix string --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 02393f74..a12482ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -73,7 +73,7 @@ Nostr reachable - Unread private messages + Messages Location notes Teleported Tor status