Merge main into expanded store-and-forward branch

This commit is contained in:
wollow 2026-09-26 14:04:08 +03:00
commit dc72dd6124
13 changed files with 668 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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