diff --git a/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt b/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt index d3ca9208..42e2af77 100644 --- a/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt @@ -136,6 +136,7 @@ class BridgeCourierService internal constructor( } val filter = NostrFilter.Builder() .kinds(KIND) + // Builder.since accepts milliseconds and serializes seconds for Nostr relays. .since(now - CourierEnvelope.MAX_LIFETIME_MS) .limit(100) .tag("x", *tags.toTypedArray()) diff --git a/app/src/main/java/com/bitchat/android/nostr/RelayReconnectPolicy.kt b/app/src/main/java/com/bitchat/android/nostr/RelayReconnectPolicy.kt new file mode 100644 index 00000000..199e0dcf --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/RelayReconnectPolicy.kt @@ -0,0 +1,45 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.util.AppConstants +import kotlin.math.min +import kotlin.math.pow + +/** + * Reconnect schedule for a Nostr relay socket. + * + * A phone loses its data connection constantly — airplane mode, a tunnel, a + * Wi-Fi to cellular handover, a dead zone — and every relay fails at once when + * it does. The schedule therefore has to survive an outage of arbitrary length + * and heal on its own, because nothing else will: the relay layer registers no + * connectivity callback, the periodic subscription validator only repairs + * subscriptions on sockets that are already open, and `connect()` runs once at + * startup. + * + * So the backoff grows exponentially and then *saturates* rather than + * terminating. Retrying forever at the ceiling costs one connection attempt per + * relay per [AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS]; giving up costs the + * user every internet DM, delivery receipt and geohash channel until they + * notice and restart the app. + */ +internal object RelayReconnectPolicy { + + /** + * Attempt count past which the interval stops growing. Beyond this the + * exponential term is already above the ceiling, so pinning it keeps the + * schedule at a steady [AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS] and + * keeps the exponent from running away over a long outage. + */ + const val SATURATION_ATTEMPTS: Int = AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS + + /** Attempt number to record after a failure at [previousAttempts]. */ + fun nextAttempt(previousAttempts: Int): Int = + (previousAttempts.coerceAtLeast(0) + 1).coerceAtMost(SATURATION_ATTEMPTS) + + /** Delay before the reconnect for a given attempt number (1-based). */ + fun backoffMs(attempt: Int): Long { + val step = attempt.coerceAtLeast(1) + val exponential = AppConstants.Nostr.INITIAL_BACKOFF_INTERVAL_MS * + AppConstants.Nostr.BACKOFF_MULTIPLIER.pow(step - 1.0) + return min(exponential, AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS.toDouble()).toLong() + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/BridgeCourierServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/BridgeCourierServiceTest.kt index 3d887ab1..fe7339fa 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/BridgeCourierServiceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/BridgeCourierServiceTest.kt @@ -17,6 +17,29 @@ class BridgeCourierServiceTest { private val relays = listOf("wss://bridge-test.invalid") private val recipientKey = ByteArray(32) { 0x32 } + @Test + fun `subscription queries the last 24 hours in relay seconds`() { + val relay = FakeRelay() + val receiver = BridgeCourierService( + cipher = FakeCipher(recipientKey), + onEnvelope = {}, + relayManager = relay, + relayUrls = relays, + clock = { now } + ) + + try { + receiver.start() + val filter = requireNotNull(relay.subscription).filter + val expectedSince = ((now - CourierEnvelope.MAX_LIFETIME_MS) / 1000).toInt() + assertEquals(expectedSince, filter.since) + assertEquals(listOf(1401), filter.kinds) + assertEquals(100, filter.limit) + } finally { + receiver.stop() + } + } + @Test fun `deposit creates a signed iOS-compatible event and receiver admits it once`() { val senderRelay = FakeRelay() diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/GeohashMessageHandlerSignatureTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/GeohashMessageHandlerSignatureTest.kt new file mode 100644 index 00000000..44044f2d --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/GeohashMessageHandlerSignatureTest.kt @@ -0,0 +1,136 @@ +package com.bitchat.android.nostr + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.MessageManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +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 + +/** + * Regression coverage for GH-733: geohash events (kind 20000/20001) were accepted from the + * relay without Schnorr signature verification, allowing full pubkey impersonation. + */ +@RunWith(RobolectricTestRunner::class) +class GeohashMessageHandlerSignatureTest { + + private val application: Application = ApplicationProvider.getApplicationContext() + + @OptIn(ExperimentalCoroutinesApi::class) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var chatState: ChatState + private lateinit var dataManager: DataManager + private lateinit var messageManager: MessageManager + private lateinit var repo: GeohashRepository + private lateinit var handler: GeohashMessageHandler + + private val geohash = "u4pruy" + + @Before + fun setup() { + chatState = ChatState(scope = testScope) + dataManager = DataManager(context = application) + messageManager = MessageManager(state = chatState) + repo = GeohashRepository(application, chatState, dataManager) + handler = GeohashMessageHandler( + application = application, + repo = repo, + scope = testScope, + dataManager = dataManager, + addChannelMessage = messageManager::addChannelMessage, + signatureVerificationDispatcher = testDispatcher + ) + } + + private fun buildSignedEvent(identity: NostrIdentity, content: String): NostrEvent { + val unsigned = NostrEvent( + pubkey = identity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.EPHEMERAL_EVENT, + tags = listOf(listOf("g", geohash)), + content = content + ) + return unsigned.sign(identity.privateKeyHex) + } + + @Test + fun onEvent_acceptsGenuinelySignedEvent() { + val victim = NostrIdentity.generate() + val genuine = buildSignedEvent(victim, "hello from the real victim") + + handler.onEvent(genuine, geohash) + + val stored = chatState.getChannelMessagesValue()["geo:$geohash"] + assertEquals(1, stored?.size) + assertEquals("hello from the real victim", stored?.first()?.content) + } + + @Test + fun onEvent_rejectsEventWithForgedSignature() { + val victim = NostrIdentity.generate() + val attacker = NostrIdentity.generate() + + // Attacker claims the victim's pubkey but signs with their own key - a forged event. + val forged = buildSignedEvent(victim, "impersonated message").copy( + sig = NostrEvent( + pubkey = attacker.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.EPHEMERAL_EVENT, + tags = listOf(listOf("g", geohash)), + content = "impersonated message" + ).sign(attacker.privateKeyHex).sig + ) + + // Sanity check: this event is indeed invalid before we even touch the handler. + assertEquals(false, forged.isValidSignature()) + + handler.onEvent(forged, geohash) + + val stored = chatState.getChannelMessagesValue()["geo:$geohash"] + assertNull("Forged event must not be rendered as a legitimate message", stored) + } + + @Test + fun onEvent_stillAcceptsGenuineEventAfterForgedCopyWithSameIdWasRejected() { + val victim = NostrIdentity.generate() + val attacker = NostrIdentity.generate() + val genuine = buildSignedEvent(victim, "hello from the real victim") + + // A forged event carrying the SAME id as the genuine one (ids are a content hash, + // independent of the signature), but signed by an attacker - relays can deliver this + // before the genuine copy arrives from another relay. + val forgedWithSameId = genuine.copy( + sig = NostrEvent( + pubkey = attacker.publicKeyHex, + createdAt = genuine.createdAt, + kind = NostrKind.EPHEMERAL_EVENT, + tags = listOf(listOf("g", geohash)), + content = "hello from the real victim" + ).sign(attacker.privateKeyHex).sig + ) + assertEquals(genuine.id, forgedWithSameId.id) + assertEquals(false, forgedWithSameId.isValidSignature()) + + // Forged copy arrives first and must be rejected without poisoning the dedup cache. + handler.onEvent(forgedWithSameId, geohash) + assertNull(chatState.getChannelMessagesValue()["geo:$geohash"]) + + // The genuine copy (same id, valid signature) arrives afterwards from another relay - + // it must still be rendered, not silently dropped as a "duplicate". + handler.onEvent(genuine, geohash) + + val stored = chatState.getChannelMessagesValue()["geo:$geohash"] + assertEquals(1, stored?.size) + assertEquals("hello from the real victim", stored?.first()?.content) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrEventDeduplicatorTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrEventDeduplicatorTest.kt new file mode 100644 index 00000000..557ead2e --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrEventDeduplicatorTest.kt @@ -0,0 +1,66 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +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 NostrEventDeduplicatorTest { + @Test + fun `isDuplicate flags a repeated event id`() { + val deduplicator = NostrEventDeduplicator(maxCapacity = 10) + + assertEquals(false, deduplicator.isDuplicate("event-1")) + assertEquals(true, deduplicator.isDuplicate("event-1")) + } + + @Test + fun `capacity evicts the least recently used event id`() { + val deduplicator = NostrEventDeduplicator(maxCapacity = 2) + + deduplicator.isDuplicate("a") + deduplicator.isDuplicate("b") + deduplicator.isDuplicate("c") // evicts "a" + + assertTrue(deduplicator.contains("b")) + assertTrue(deduplicator.contains("c")) + assertEquals(false, deduplicator.contains("a")) + } + + /** + * totalChecks used to be incremented outside the lruLock that guards every other + * mutation in this class, so concurrent callers could race on the read-modify-write + * and lose increments. Every other counter (duplicateCount, evictionCount) was + * already incremented under the lock, which is why only this one drifted. + */ + @Test + fun `totalChecks counts every call exactly once under concurrent access`() { + val deduplicator = NostrEventDeduplicator(maxCapacity = 10_000) + val threadCount = 8 + val checksPerThread = 2_000 + val executor = Executors.newFixedThreadPool(threadCount) + val start = CountDownLatch(1) + val done = CountDownLatch(threadCount) + + repeat(threadCount) { threadIndex -> + executor.submit { + start.await() + repeat(checksPerThread) { callIndex -> + deduplicator.isDuplicate("thread-$threadIndex-event-$callIndex") + } + done.countDown() + } + } + + start.countDown() + assertTrue(done.await(10, TimeUnit.SECONDS)) + executor.shutdown() + + assertEquals( + (threadCount * checksPerThread).toLong(), + deduplicator.getStats().totalChecks + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelayReconnectPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelayReconnectPolicyTest.kt new file mode 100644 index 00000000..f5e2581a --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayReconnectPolicyTest.kt @@ -0,0 +1,96 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.util.AppConstants +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The relay layer has no connectivity callback, its periodic validator only + * repairs subscriptions on sockets that are already open, and `connect()` runs + * once at startup. The backoff schedule is therefore the only thing that can + * bring relays back after the phone loses its data connection, so it has to + * saturate rather than terminate. + */ +class RelayReconnectPolicyTest { + + private val initial = AppConstants.Nostr.INITIAL_BACKOFF_INTERVAL_MS + private val ceiling = AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS + + @Test + fun `the first retry waits the initial interval`() { + assertEquals(initial, RelayReconnectPolicy.backoffMs(RelayReconnectPolicy.nextAttempt(0))) + } + + @Test + fun `the interval doubles per attempt until it reaches the ceiling`() { + var attempt = 0 + var previous = 0L + var sawCeiling = false + + repeat(RelayReconnectPolicy.SATURATION_ATTEMPTS) { + attempt = RelayReconnectPolicy.nextAttempt(attempt) + val delay = RelayReconnectPolicy.backoffMs(attempt) + + assertTrue("delay must never exceed the ceiling", delay <= ceiling) + if (delay == ceiling) { + sawCeiling = true + } else { + assertEquals("expected doubling below the ceiling", maxOf(initial, previous * 2), delay) + } + previous = delay + } + + assertTrue("the schedule must actually reach the ceiling", sawCeiling) + } + + @Test + fun `an outage longer than the schedule keeps retrying at the ceiling`() { + var attempt = 0 + // Far past the old give-up point; a real outage can last hours. + repeat(500) { attempt = RelayReconnectPolicy.nextAttempt(attempt) } + + assertEquals(RelayReconnectPolicy.SATURATION_ATTEMPTS, attempt) + assertEquals(ceiling, RelayReconnectPolicy.backoffMs(attempt)) + } + + @Test + fun `a long outage cannot run the attempt counter or the exponent away`() { + var attempt = 0 + repeat(10_000) { attempt = RelayReconnectPolicy.nextAttempt(attempt) } + + val delay = RelayReconnectPolicy.backoffMs(attempt) + assertTrue("delay must stay finite and bounded", delay in 1..ceiling) + } + + @Test + fun `a successful connection resets the schedule to the initial interval`() { + var attempt = 0 + repeat(6) { attempt = RelayReconnectPolicy.nextAttempt(attempt) } + assertTrue(RelayReconnectPolicy.backoffMs(attempt) > initial) + + // updateRelayStatus zeroes reconnectAttempts on a successful open. + attempt = 0 + + assertEquals(initial, RelayReconnectPolicy.backoffMs(RelayReconnectPolicy.nextAttempt(attempt))) + } + + @Test + fun `a nonsensical stored attempt count still yields a usable delay`() { + assertEquals(initial, RelayReconnectPolicy.backoffMs(RelayReconnectPolicy.nextAttempt(-5))) + assertTrue(RelayReconnectPolicy.backoffMs(0) in 1..ceiling) + assertTrue(RelayReconnectPolicy.backoffMs(Int.MAX_VALUE) in 1..ceiling) + } + + @Test + fun `the whole schedule stays under an hour of total wait before the ceiling`() { + var attempt = 0 + var total = 0L + repeat(RelayReconnectPolicy.SATURATION_ATTEMPTS) { + attempt = RelayReconnectPolicy.nextAttempt(attempt) + total += RelayReconnectPolicy.backoffMs(attempt) + } + + assertTrue("reaching the steady state must not take an hour", total < 60 * 60 * 1000L) + } +} diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md index 7a8a5bee..4927c30e 100644 --- a/docs/client-rewrite-contracts.md +++ b/docs/client-rewrite-contracts.md @@ -18,7 +18,7 @@ The remaining implementation work and milestone progress are tracked in | Store and forward | Courier type `0x04`, rotating HMAC recipient tags, Noise X seals, copy-budget and prekey-ID TLVs, 24-hour expiry, bounded tiered custody | `CourierEnvelopeTest`, `NoiseCourierTest`, `MessageRouterTest` | | 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, type-scoped filters, bounded-history cursors, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewriteWireContractTest`, `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `GossipSyncManagerTest`, `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` | +| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals, 22h outbound envelope randomization | `ClientRewriteNostrContractTest`, `NostrProtocolTest` | | Application state | Peer unions, canonical private conversations, chronological history, delivery/read behavior, media migration policy | `AppStateStoreTest`, `PrivateChatManagerTest`, `MediaSendingManagerMigrationTest` | ## Golden-vector policy @@ -32,6 +32,11 @@ 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. +NIP-17 receivers should reserve safety slack beyond the maximum timestamp +randomization used by senders. Android caps outbound seal and gift-wrap +randomization at 22h, leaving 2 hours of slack inside iOS's 24-hour +subscription window, while retaining its 48-hour receive lookback. + ## Rewrite acceptance gate From a configured Android development environment, run: diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt b/wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt new file mode 100644 index 00000000..aeb19766 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt @@ -0,0 +1,75 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material3.MaterialTheme + +/** The title is outside the scrolling list, so it needs its own physical display bounds. */ +@Composable +internal fun WearChatHeader( + fontSize: Float, + onClickLabel: String, + onClick: () -> Unit, + content: @Composable RowScope.() -> Unit, +) { + val configuration = LocalConfiguration.current + val lineHeight = with(LocalDensity.current) { (fontSize * 1.3f).sp.toDp() } + val rowHeight = maxOf(48.dp, lineHeight) + val top = (rowHeight - lineHeight) / 2 + val background = MaterialTheme.colorScheme.background + // Follow the existing title animation without animating layout or the hit target. + val expansion = ((fontSize - 12f) / 2f).coerceIn(0f, 1f) + val fadeEnd = maxOf((36f + 8f * expansion).dp, top + lineHeight + 4.dp) + BoxWithConstraints( + Modifier.fillMaxWidth().drawWithCache { + val brush = + Brush.verticalGradient( + 0f to background, + 0.65f to background.copy(alpha = 0.95f), + 1f to Color.Transparent, + endY = fadeEnd.toPx(), + ) + onDrawBehind { drawRect(brush) } + }, + contentAlignment = Alignment.TopCenter, + ) { + val safeWidth = + if (configuration.isScreenRound) { + roundBandWidth( + maxWidth.value, + configuration.screenHeightDp.toFloat(), + top.value, + (top + lineHeight).value, + ) + .dp - 8.dp + } else { + maxWidth - 16.dp + } + Row( + modifier = + Modifier.width(safeWidth.coerceAtLeast(48.dp)) + .height(rowHeight) + .clickable(role = Role.Button, onClickLabel = onClickLabel, onClick = onClick), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + content = content, + ) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt new file mode 100644 index 00000000..738e56cf --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt @@ -0,0 +1,53 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp + +/** + * Preserve the responsive, shape-aware padding supplied by Wear Material while allowing a + * screen to reserve additional room for its own content or floating controls. + */ +internal fun PaddingValues.withAdditionalPadding( + layoutDirection: LayoutDirection, + horizontal: Dp = 0.dp, + vertical: Dp = 0.dp +): PaddingValues { + return PaddingValues( + start = calculateStartPadding(layoutDirection) + horizontal, + top = calculateTopPadding() + vertical, + end = calculateEndPadding(layoutDirection) + horizontal, + bottom = calculateBottomPadding() + vertical + ) +} + +/** + * Keep the responsive horizontal inset supplied by Wear Material while replacing its vertical + * padding with screen-owned clearances. This keeps the lazy list full-screen for correct Wear + * transformation and scroll calculations without stacking duplicate vertical insets. + */ +internal fun PaddingValues.withVerticalClearance( + layoutDirection: LayoutDirection, + top: Dp, + bottom: Dp +): PaddingValues { + return PaddingValues( + start = calculateStartPadding(layoutDirection), + top = top, + end = calculateEndPadding(layoutDirection), + bottom = bottom + ) +} + +private fun PaddingValues.calculateStartPadding(layoutDirection: LayoutDirection): Dp = + when (layoutDirection) { + LayoutDirection.Ltr -> calculateLeftPadding(layoutDirection) + LayoutDirection.Rtl -> calculateRightPadding(layoutDirection) + } + +private fun PaddingValues.calculateEndPadding(layoutDirection: LayoutDirection): Dp = + when (layoutDirection) { + LayoutDirection.Ltr -> calculateRightPadding(layoutDirection) + LayoutDirection.Rtl -> calculateLeftPadding(layoutDirection) + } diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearDisplayGeometry.kt b/wear/src/main/java/com/bitchat/watch/ui/WearDisplayGeometry.kt new file mode 100644 index 00000000..894e43a6 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearDisplayGeometry.kt @@ -0,0 +1,16 @@ +package com.bitchat.watch.ui + +import kotlin.math.abs +import kotlin.math.min +import kotlin.math.sqrt + +/** Width of the narrowest chord across a centered horizontal band in a round display. */ +internal fun roundBandWidth(width: Float, height: Float, top: Float, bottom: Float): Float { + val radius = min(width, height) / 2f + val distance = maxOf(abs(top - height / 2f), abs(bottom - height / 2f)) + return 2f * sqrt((radius * radius - distance * distance).coerceAtLeast(0f)) +} + +/** A centered square whose four corners fit inside the physical circle, with a small inset. */ +internal fun roundContentSide(width: Float, height: Float): Float = + (min(width, height) / sqrt(2f) - 4f).coerceAtLeast(0f) diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearFormScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/WearFormScreen.kt new file mode 100644 index 00000000..75cf3703 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearFormScreen.kt @@ -0,0 +1,30 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.ScalingLazyListScope +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.ScreenScaffold + +/** Independently scrollable form items remain reachable on small watches and at large fonts. */ +@Composable +internal fun WearFormScreen(content: ScalingLazyListScope.() -> Unit) { + val state = rememberScalingLazyListState(initialCenterItemIndex = 0) + val direction = LocalLayoutDirection.current + ScreenScaffold(scrollState = state) { padding -> + ScalingLazyColumn( + state = state, + modifier = Modifier.fillMaxSize(), + autoCentering = null, + contentPadding = + padding + .withAdditionalPadding(direction, horizontal = 14.dp) + .withVerticalClearance(direction, top = 28.dp, bottom = 28.dp), + content = content, + ) + } +} diff --git a/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt new file mode 100644 index 00000000..60191d67 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt @@ -0,0 +1,70 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Test + +class WearContentPaddingTest { + + @Test + fun `additional padding preserves scaffold insets`() { + val resolved = PaddingValues(start = 10.dp, top = 18.dp, end = 12.dp, bottom = 20.dp) + .withAdditionalPadding( + layoutDirection = LayoutDirection.Ltr, + horizontal = 10.dp, + vertical = 8.dp + ) + + assertEquals(20.dp, resolved.calculateLeftPadding(LayoutDirection.Ltr)) + assertEquals(22.dp, resolved.calculateRightPadding(LayoutDirection.Ltr)) + assertEquals(26.dp, resolved.calculateTopPadding()) + assertEquals(28.dp, resolved.calculateBottomPadding()) + } + + @Test + fun `vertical clearance preserves responsive width and replaces scaffold height inset`() { + val resolved = PaddingValues(start = 10.dp, top = 20.dp, end = 12.dp, bottom = 20.dp) + .withVerticalClearance( + layoutDirection = LayoutDirection.Ltr, + top = 30.dp, + bottom = 64.dp + ) + + assertEquals(10.dp, resolved.calculateLeftPadding(LayoutDirection.Ltr)) + assertEquals(12.dp, resolved.calculateRightPadding(LayoutDirection.Ltr)) + assertEquals(30.dp, resolved.calculateTopPadding()) + assertEquals(64.dp, resolved.calculateBottomPadding()) + } + + @Test + fun `additional padding preserves logical start and end in rtl`() { + val resolved = PaddingValues(start = 10.dp, top = 18.dp, end = 12.dp, bottom = 20.dp) + .withAdditionalPadding( + layoutDirection = LayoutDirection.Rtl, + horizontal = 10.dp, + vertical = 8.dp + ) + + assertEquals(22.dp, resolved.calculateLeftPadding(LayoutDirection.Rtl)) + assertEquals(20.dp, resolved.calculateRightPadding(LayoutDirection.Rtl)) + assertEquals(26.dp, resolved.calculateTopPadding()) + assertEquals(28.dp, resolved.calculateBottomPadding()) + } + + @Test + fun `vertical clearance preserves logical start and end in rtl`() { + val resolved = PaddingValues(start = 10.dp, top = 20.dp, end = 12.dp, bottom = 20.dp) + .withVerticalClearance( + layoutDirection = LayoutDirection.Rtl, + top = 30.dp, + bottom = 64.dp + ) + + assertEquals(12.dp, resolved.calculateLeftPadding(LayoutDirection.Rtl)) + assertEquals(10.dp, resolved.calculateRightPadding(LayoutDirection.Rtl)) + assertEquals(30.dp, resolved.calculateTopPadding()) + assertEquals(64.dp, resolved.calculateBottomPadding()) + } +} diff --git a/wear/src/test/java/com/bitchat/watch/ui/WearDisplayGeometryTest.kt b/wear/src/test/java/com/bitchat/watch/ui/WearDisplayGeometryTest.kt new file mode 100644 index 00000000..c274657c --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/ui/WearDisplayGeometryTest.kt @@ -0,0 +1,51 @@ +package com.bitchat.watch.ui + +import com.bitchat.watch.ui.theme.ChatVisualTokens +import kotlin.math.pow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WearDisplayGeometryTest { + @Test + fun `header band corners stay inside the circle at supported text sizes`() { + for (diameter in listOf(192f, 228f, 240f)) { + for (scale in listOf(0.94f, 1f, 1.24f, 1.3f)) { + for (titleSize in listOf(12f, 13f, 14f)) { + val lineHeight = titleSize * 1.3f * scale + val top = (maxOf(48f, lineHeight) - lineHeight) / 2f + val width = roundBandWidth(diameter, diameter, top, top + lineHeight) + val radius = diameter / 2f + for (y in listOf(top, top + lineHeight)) { + assertTrue( + (width / 2).pow(2) + (y - radius).pow(2) <= radius.pow(2) + 0.01f + ) + } + } + } + } + } + + @Test + fun `out of display bands have no usable width`() { + assertEquals(0f, roundBandWidth(192f, 192f, -1f, 20f)) + assertEquals(0f, roundBandWidth(192f, 192f, 180f, 193f)) + } + + @Test + fun `image and recording safe square fits all four corners`() { + for (diameter in listOf(192f, 228f, 240f)) { + val side = roundContentSide(diameter, diameter) + assertTrue(side > 0) + assertTrue(2 * (side / 2).pow(2) < (diameter / 2).pow(2)) + } + } + + @Test + fun `essential shared text is at least twelve sp`() { + assertTrue(ChatVisualTokens.SystemActionStyle.fontSize.value >= 12f) + assertTrue(ChatVisualTokens.MessageBodyStyle.fontSize.value >= 12f) + assertTrue(ChatVisualTokens.SenderStyle.fontSize.value >= 12f) + assertTrue(ChatVisualTokens.TimestampStyle.fontSize.value >= 10f) + } +}