mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-10-25 07:40:36 +00:00
Merge main into expanded store-and-forward branch
This commit is contained in:
commit
dc72dd6124
@ -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())
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
|
||||
75
wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt
Normal file
75
wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
30
wear/src/main/java/com/bitchat/watch/ui/WearFormScreen.kt
Normal file
30
wear/src/main/java/com/bitchat/watch/ui/WearFormScreen.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user