Compare commits

..

24 Commits
v2.0.2 ... main

Author SHA1 Message Date
GitHub Action
c127eb83ab Automated update of relay data - Sun Sep 13 06:20:34 UTC 2026 2026-09-13 06:20:34 +00:00
callebtc
945c9e025b
Merge pull request #866 from Chessing234/fix-dedup-total-checks-race
fix: synchronize totalChecks increment in NostrEventDeduplicator
2026-09-07 23:44:31 +03:00
callebtc
010789adde
Merge pull request #882: strengthen packet deduplication
Use full-payload packet IDs for duplicate detection and simplify the explanatory comment.
2026-09-07 23:33:41 +03:00
callebtc
9444acd33e
Merge pull request #869 from qutad/fix/nostr-ios-dm-lookback
fix(nostr): cap DM envelope backdating at 24 hours
2026-09-07 23:32:40 +03:00
callebtc
9e5a07b782
Merge pull request #907 from Chessing234/fix/relay-reconnect-recovery
fix(nostr): keep retrying relays instead of giving up on them forever
2026-09-07 23:31:15 +03:00
callebtc
fa1727de2f docs: simplify packet deduplication comment 2026-09-07 23:29:23 +03:00
callebtc
936a4cdf6d
Merge pull request #932 from permissionlesstech/codex/release-wear-0.1.3
Bump Wear OS version to 0.1.3
2026-09-07 15:08:23 +03:00
callebtc
0fce9faa1f Bump Wear OS version to 0.1.3 2026-09-07 14:55:50 +03:00
callebtc
f352a3e4bc
Merge pull request #931 from permissionlesstech/codex/wear-ui-shape-accessibility
Improve Wear round-screen layouts and chat scrolling
2026-09-07 14:43:41 +03:00
callebtc
eb9ce6555f Merge remote-tracking branch 'origin/main' into codex/wear-ui-shape-accessibility 2026-09-07 14:20:35 +03:00
callebtc
08974107dc Compact Wear chat headers and stabilize scroll controls 2026-09-07 14:20:35 +03:00
GitHub Action
9a399cc333 Automated update of relay data - Sun Sep 6 06:16:30 UTC 2026 2026-09-06 06:16:30 +00:00
callebtc
afad3c5513 Fix Wear layouts on small round displays 2026-09-05 19:42:43 +03:00
Taksh
e50cf9c8cc chore: retrigger ci 2026-08-30 09:49:50 +05:30
Taksh
5800583cd2 chore: retrigger ci 2026-08-30 09:39:09 +05:30
Taksh
e65039fe76 chore: retrigger ci 2026-08-30 09:38:30 +05:30
Taksh
6bdcd46b33 fix(nostr): keep retrying relays instead of giving up on them forever
A relay that fails is retried on an exponential backoff, then abandoned
for the lifetime of the process. Nothing brings it back: the relay layer
registers no connectivity callback, the periodic subscription validator
only repairs subscriptions on sockets that are already open (and returns
immediately when connectedRelayCount is 0, which is exactly the state
after an outage), and connect() runs once from NostrClient.initialize().
The remaining paths that reset reconnectAttempts are a manual retry, a
Tor state change, and a successful open.

Two ways a relay died permanently:

- Any error whose message mentioned DNS returned before scheduling
  anything at all. "Unable to resolve host" is what this device reports
  when it simply has no network, so a moment in a tunnel killed every
  relay at once, with no retry ever.
- Otherwise the schedule stopped at MAX_RECONNECT_ATTEMPTS. With
  INITIAL=1s and MULTIPLIER=2 that is nine waits totalling about eight
  and a half minutes, after which the relay was dead. MAX_BACKOFF_INTERVAL
  was unreachable: attempt 9 asks for 256s and attempt 10 gave up, so the
  five-minute ceiling the constant defines never applied to anything.

Let the backoff saturate at MAX_BACKOFF_INTERVAL and keep retrying there.
A name-resolution failure now backs off like any other error. Steady state
costs one connection attempt per relay per five minutes; the previous
behaviour cost the user every internet DM, delivery receipt and geohash
channel until they noticed and restarted the app.

The schedule moves into RelayReconnectPolicy so it is unit-testable
without OkHttp or a Context.
2026-08-24 21:32:36 +05:30
Taksh
4ef1b9c765 Pin the collision, the replay, and the peer scoping
The collision case fails on main: two packets sharing a 64-byte prefix and
a timestamp, where the second was silently dropped.

The other two pass before and after on purpose. Replay of an identical
packet must still be caught, and the same packet arriving from two
different peers must still be tracked separately — strengthening the
identity must not quietly weaken either.
2026-08-15 13:58:41 +05:30
Taksh
87ccfe3f6c Use the shared packet identity for duplicate detection
Replay and duplicate detection keyed on a 32-bit contentHashCode over at
most the first 64 bytes of the payload. Two packets from the same peer in
the same millisecond that agreed on that prefix were the same packet as
far as this cache was concerned, and a collision here is a dropped
message: the second is discarded and nothing reports it.

PacketIdUtil is the identity the rest of the stack already uses for this
question — gossip sync membership, and the message IDs MessageHandler
assigns — and iOS derives it identically: first 16 bytes of SHA-256 over
type, senderID, timestamp and the whole payload. The security path now
agrees with the sync path instead of carrying a weaker private notion of
"same packet", and the FRAGMENT special case disappears because the full
payload is covered either way.

Peer scoping is deliberately kept. PacketIdUtil covers the packet's own
senderID, which is not the peer it arrived from once relayed.
2026-08-15 13:58:41 +05:30
wollow
acfbacf78f fix(nostr): reserve two-hour DM timestamp margin 2026-08-11 22:37:44 +03:00
wollow
bc03d78972 changed to 23-hour-45-minute maximum backdating window 2026-08-11 18:06:25 +03:00
farwhile
bad78f8b99
Merge branch 'permissionlesstech:main' into fix/nostr-ios-dm-lookback 2026-08-11 15:00:35 +00:00
wollow
76bf226f98 fix(nostr): cap DM envelope backdating at 24 hours 2026-08-11 14:52:16 +03:00
Taksh
ac47af9101 fix: synchronize totalChecks increment in NostrEventDeduplicator
isDuplicate() incremented totalChecks outside the lruLock that guards
every other mutation on this class, so concurrent callers raced on the
read-modify-write and lost increments. duplicateCount and evictionCount
were already incremented under the lock; totalChecks was the one
counter left outside it.

getStats().totalChecks feeds hitRate and is the only denominator for
duplicateCount, so a systematic undercount skews both. Moved the
increment inside the existing synchronized block.

Added a concurrency test that reproduces the race: run against the
prior code it failed reliably (3/3 runs); against the fix it passes.
2026-08-10 20:09:00 +05:30
30 changed files with 1496 additions and 946 deletions

View File

@ -1,389 +1,391 @@
Relay URL,Latitude,Longitude
dm-test-strfry-generic.samt.st,43.6532,-79.3832
nostrelay.circum.space:443,52.2245,8.826
relay.staging.plebeian.market,51.5072,-0.127586
nostr.chaima.info:443,50.1109,8.68213
relay.vrtmrz.net:443,43.6532,-79.3832
relay.gulugulu.moe:443,43.6532,-79.3832
relay.dreamith.to,43.6532,-79.3832
nostrride.io,37.3986,-121.964
nostr.n7ekb.net,47.4941,-122.294
relay.npubhaus.com,43.6532,-79.3832
relay1.privkey.io,25.7617,-80.1918
nostr-relay.xbytez.io:443,50.6924,3.20113
relay.bullishbounty.com,43.6532,-79.3832
nostr.islandarea.net,35.4669,-97.6473
nostrrelay.taylorperron.com,45.5029,-73.5723
relay.sharegap.net,43.6532,-79.3832
relayone.geektank.ai,39.0997,-94.5786
nexus.libernet.app,43.6532,-79.3832
fanfares.nostr1.com:443,40.7057,-74.0136
relay.satlantis.io,39.0438,-77.4874
relay.hivetalk.org,40.8302,-74.1299
nos.lol,50.4754,12.3683
staging.yabu.me,35.6092,139.73
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
relay.internationalright-wing.org,-22.4692,-48.9875
relay.nostrcheck.me,43.6532,-79.3832
denimroad.feeds.relay.tools,38.6327,-90.1961
relay.nostr-check.me,43.6532,-79.3832
ribo.us.nostria.app:443,43.6532,-79.3832
relay.lightning.pub,39.0438,-77.4874
myvoiceourstory.org,37.3598,-121.981
relay.jmoose.rocks:443,37.4419,-122.143
nostr-pr02.redscrypt.org,52.3676,4.90414
nostr.purpura.cloud,43.6532,-79.3832
relay.sigit.io:443,50.4754,12.3683
relay.nostrops.com,32.71,-96.6745
relay.jmoose.rocks,37.4419,-122.143
nostr.spaceshell.xyz,43.6532,-79.3832
nostr-pub.wellorder.net,45.5201,-122.99
relay.opmaat.org,60.1699,24.9384
nostr.myshosholoza.co.za,52.3676,4.90414
offchain.bostr.online,43.6532,-79.3832
nostr-relay.nilpote.com,43.6532,-79.3832
relay.nostu.be,40.4167,-3.70329
nostr.stakey.net,52.3676,4.90414
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
conduitl2.fly.dev,38.7946,-106.535
vault.iris.to:443,43.6532,-79.3832
relay.minibolt.info,43.6532,-79.3832
articles.layer3.news:443,37.3387,-121.885
nostr.overmind.lol,43.6532,-79.3832
nostr.21crypto.ch,47.5356,8.73209
nostr.tabordalab.com,60.1699,24.9384
nostr.spicyz.io,43.6532,-79.3832
nostr.sathoarder.com,48.5734,7.75211
relay.chorus.community,48.5333,10.7
dev.relay.stream,43.6532,-79.3832
nostr-relay.corb.net:443,39.6478,-104.988
relay.lightning.pub:443,39.0438,-77.4874
nostr.tbxnetworx.de,47.8226,10.0064
nostr.azzamo.net,52.2633,21.0283
relay.loveisbitcoin.com,43.6532,-79.3832
strfry.ymir.cloud,43.6532,-79.3832
chorus.mikedilger.com:444,-36.8906,174.794
relay.agilesolutionlabs.com,43.6532,-79.3832
relay.gulugulu.moe,43.6532,-79.3832
nostr.tac.lol,47.4748,-122.273
relay.mypathtofire.de,42.8864,-78.8784
nostrelay.circum.space,52.2245,8.826
bcast.girino.org,43.6532,-79.3832
relay.ordoplay.com,50.1109,8.68213
nostr.chaima.info,50.1109,8.68213
offchain.pub:443,39.1585,-94.5728
relay.wisp.talk,49.4543,11.0746
strfry.bonsai.com,39.0438,-77.4874
nostr.2b9t.xyz:443,34.0549,-118.243
relay.nostx.io,43.6532,-79.3832
bucket.coracle.social,37.7775,-122.397
relay.wavlake.com:443,41.2619,-95.8608
nostr.carroarmato0.be:443,50.914,3.21378
treuzkas.branruz.com,48.8575,2.35138
nostr.computingcache.com,45.5341,-122.956
cs-relay.nostrdev.com,50.4754,12.3683
relay.lanacoin-eternity.com,40.8302,-74.1299
nostr.rtvslawenia.com,49.4543,11.0746
nip85.nosfabrica.com,39.0997,-94.5786
relay.wisp.talk:443,49.4543,11.0746
nostr.stakey.net:443,52.3676,4.90414
relay.piazza.today,48.122,11.589
relay.conduit.market,38.7946,-106.535
relay.nostrmap.net,60.1699,24.9384
maxq.descendant.io,43.6532,-79.3832
nostr.hekster.org,37.3986,-121.964
nostr.christiansass.de,49.7423,8.76687
tribune-panel-growing-noon.trycloudflare.com,43.6532,-79.3832
relay.lanacoin-eternity.com:443,40.8302,-74.1299
relay.scuba323.com,40.8218,-74.45
schnorr.me,43.6532,-79.3832
relay.cypherflow.ai,48.8575,2.35138
nostr.snowbla.de:443,50.4754,12.3683
nostr.whitenode45.ddns.net,40.55,-74.4758
adre.su,59.9311,30.3609
relay.getsafebox.app,43.6532,-79.3832
auth.nostr1.com,40.7057,-74.0136
relay2.veganostr.com,60.1699,24.9384
relay.i9.eti.br,43.6532,-79.3832
relay.satsmarkt.club,52.6907,4.8181
nostr.bitcoiner.social:443,47.6743,-117.112
cache.trustr.ing,43.6548,-79.3885
relay.mitchelltribe.com,39.0438,-77.4874
21milionidinostr.duckdns.org,41.8967,12.4822
public.crostr.com:443,43.6532,-79.3832
relay.otrta.me,50.1109,8.68213
relay.bornheimer.app,50.1109,8.68213
relay.sigit.io,50.4754,12.3683
nostr-relay.xbytez.io,50.6924,3.20113
relayone.soundhsa.com,39.0997,-94.5786
vm-1734.lnvps.cloud,53.3498,-6.26031
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
nostr.twinkle.lol,51.902,7.6657
nostr.data.haus,50.4754,12.3683
nostr.mas-family.eu,60.3478,15.7505
relay.nostrhub.fr,48.1045,11.6004
nostr.openhoofd.nl,51.5717,3.70417
relay.nostreon.com,60.1699,24.9384
relay.tdw.lol,41.8781,-87.6298
top.testrelay.top,43.6532,-79.3832
syb.lol,34.0549,-118.243
relay.primal.net,43.6532,-79.3832
n.musicroadmap.com,35.694,139.754
ribo.eu.nostria.app,43.6532,-79.3832
nostr.red5d.dev,43.6532,-79.3832
nostr.ltd,19.076,72.8777
relay.olas.app,60.1699,24.9384
chat-relay.zap-work.com,43.6532,-79.3832
relay.islandbitcoin.com,12.8498,77.6545
relay.chatbett.de,40.7128,-74.006
relay.novospes.com,43.6532,-79.3832
nostr.bond,50.1109,8.68213
inbox.scuba323.com,40.8218,-74.45
0x-nostr-relay.fly.dev,38.7946,-106.535
relay.sincensura.org,43.6532,-79.3832
nostr-02.uid.ovh,50.9871,2.12554
relay.solomonstr.com,43.6532,-79.3832
purplerelay.com,43.6532,-79.3832
nostr.emanuelemiani.it,45.778,8.79414
relay.damustr.com,43.6532,-79.3832
fanfares.nostr1.com,40.7057,-74.0136
relay.plebeian.market:443,50.1109,8.68213
buzz.ac2n-share.kozow.com,45.764,4.83566
nostr-01.yakihonne.com:443,1.32123,103.695
relay.snotr.nl:49999,51.9758,4.31389
relay.ltgnet.work:8443,51.0511,-114.075
nostras.app,38.7946,-106.535
purplerelay.com:443,43.6532,-79.3832
relay.bnos.space,43.6532,-79.3832
porchlight.social,43.6532,-79.3832
relay.mostr.pub:443,43.6532,-79.3832
relay.layer.systems,49.0291,8.35695
nostrcity-club.fly.dev,38.7946,-106.535
relay.guggero.org,46.5971,9.59652
relay.layer.systems:443,49.0291,8.35695
relay.edufeed.org,49.4521,11.0767
nostr.mom:443,50.4754,12.3683
relay.mccormick.cx,52.3563,4.95714
public.crostr.com,43.6532,-79.3832
mostro-p2p.tech,50.1109,8.68213
strfry.shock.network:443,39.0438,-77.4874
relay.agentry.com,42.8864,-78.8784
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
relay.nostrian-conquest.com,41.223,-111.974
relay.fountain.fm,43.6532,-79.3832
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
rele.speyhard.fi,50.1109,8.68213
reraw.pbla2fish.cc,43.6532,-79.3832
relay.pyramid.li,47.4093,8.46503
relay.trotters.cc,43.6532,-79.3832
relay.ru.ac.th,13.7607,100.627
relay.orly.dev,32.7767,-96.797
nostr.yutakobayashi.com,43.6532,-79.3832
nostr-01.yakihonne.com,1.32123,103.695
relay.lanavault.space,60.1699,24.9384
chat-relay.zap-work.com:443,43.6532,-79.3832
strfry.shock.network,39.0438,-77.4874
wheat.happytavern.co,43.6532,-79.3832
relay.klabo.world,47.674,-122.122
relay.littlebitstudios.com,43.6532,-79.3832
nostr-relay.corb.net,39.6478,-104.988
relay.flashapp.me,43.6548,-79.3885
espelho.girino.org,43.6532,-79.3832
budabit.nostr1.com,40.7057,-74.0136
nostr.vulpem.com,49.4543,11.0746
relay.nostrfeed.com,60.1699,24.9384
basspistol.org,49.0291,8.35696
relay-rpi.edufeed.org,49.4521,11.0767
relay.mitchelltribe.com:443,39.0438,-77.4874
spamspamspamspam.rest,43.6532,-79.3832
relayrs.notoshi.win:443,43.6532,-79.3832
relay.endfiat.money,59.3327,18.0656
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
nostr.tagomago.me,42.3601,-71.0589
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
nostr.rtvslawenia.com:443,49.4543,11.0746
no.str.cr:443,8.96171,-83.5246
relay.ditto.pub,43.6532,-79.3832
relay.shadowbip.com,54.352,18.6466
nostr.liberty.fans,36.8767,-89.5879
prl.plus,55.7628,37.5983
relay.agorist.space,52.3734,4.89406
articles.layer3.news,37.3387,-121.885
nostr.thalheim.io:443,60.1699,24.9384
relay.mccormick.cx:443,52.3563,4.95714
support.flotilla.social,32.9483,-96.7299
nostr.mikoshi.de,50.1109,8.68213
nostrmxn.lulus.com.mx,59.4016,17.9455
bridge.tagomago.me,42.3601,-71.0589
relayrs.notoshi.win,43.6532,-79.3832
nostr.88mph.life,52.1941,-2.21905
nostr.fullstackcash.net,45.5201,-122.99
relay5.bitransfer.org,43.6532,-79.3832
relay.0xchat.com,43.6532,-79.3832
relay.nostriot.com,41.5695,-83.9786
relay.wellorder.net,45.5201,-122.99
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
nostr.relay.hedwig.sh,60.1699,24.9384
relay.openspecs.uid.ovh,50.9871,2.12554
relay.keykeeper.world,40.7824,-74.0711
nostr.wecsats.io:443,43.6532,-79.3832
relay.wavlake.com,41.2619,-95.8608
relay.lacrypta.ar,43.6532,-79.3832
nos.lol:443,50.4754,12.3683
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
relay.nmail.li,50.9871,2.12554
relay.mostro.network,40.8302,-74.1299
bitcoinostr.duckdns.org,41.1976,1.11167
relay.s-w.art,43.6532,-79.3832
relay.littlebitstudios.com,43.6532,-79.3832
public.crostr.com,43.6532,-79.3832
nostr-verified.wellorder.net,45.5201,-122.99
nostr-relay.xbytez.io,50.6924,3.20113
nostr.azzamo.net:443,52.2633,21.0283
yabu.me,35.6092,139.73
relay.arx-ccn.com,50.4754,12.3683
relay.ohstr.com:443,43.6532,-79.3832
nostr-dev.wellorder.net,45.5201,-122.99
nostr.notribe.net,40.8302,-74.1299
relay.mleku.dev,32.7767,-96.797
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
nostr2.girino.org:443,43.6532,-79.3832
relay.cosmicbolt.net:443,37.3986,-121.964
ribo.us.nostria.app,43.6532,-79.3832
nostr.janx.com,43.6532,-79.3832
familiamartins.net.br,-22.8833,-43.1036
relay.yoinekodo.jp,43.6532,-79.3832
nostr.4rs.nl,49.0291,8.35696
relay.routstr.com,59.4016,17.9455
relay.bitmacro.cloud,43.6532,-79.3832
relay.mappingbitcoin.com,43.6532,-79.3832
nostr.dlcdevkit.com:443,40.0992,-83.1141
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.overmind.lol:443,43.6532,-79.3832
relayone.geektank.ai:443,39.0997,-94.5786
relay.staging.plebeian.market:443,51.5072,-0.127586
relay.kilombino.com,43.6532,-79.3832
relay.endfiat.money:443,59.3327,18.0656
relay.nearhood.co.uk,51.5134,-0.0890675
purplerelay.com,43.6532,-79.3832
cs-relay.nostrdev.com,50.4754,12.3683
nostr.red5d.dev,43.6532,-79.3832
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
relay.lightning.pub:443,39.0438,-77.4874
relay.mccormick.cx:443,52.3563,4.95714
fanfares.nostr1.com:443,40.7057,-74.0136
relay.shadowbip.com,54.352,18.6466
nip85.nosfabrica.com,39.0997,-94.5786
social.amanah.eblessing.co,48.1046,11.6002
nostr.2b9t.xyz,34.0549,-118.243
relay.sector01.com,41.4513,-81.7021
relay-dev.gulugulu.moe:443,43.6532,-79.3832
relay.nostr.dev.br,48.1046,11.6002
x.kojira.io,43.6532,-79.3832
node.kommonzenze.de,49.4521,11.0767
relay.earthly.city,34.1749,-118.54
buzz.cashu.space,50.1109,8.68213
relay.kizuna-miki.com,35.694,139.754
bitchat.nostr1.com,40.7057,-74.0136
bruh.samt.st,43.6532,-79.3832
relay.openspecs.uid.ovh,50.9871,2.12554
relay.pyramid.li,47.4093,8.46503
bcast.girino.org,43.6532,-79.3832
relay.jmoose.rocks:443,32.8009,-96.8195
relay.solife.me,43.6532,-79.3832
relay.flashapp.me,43.6548,-79.3885
relay.wavlake.com:443,41.2619,-95.8608
nostr.plantroon.com:443,50.1013,8.62643
relay.lanacoin-eternity.com,40.8302,-74.1299
nostr.infero.net,35.6764,139.65
nostrelay.circum.space:443,52.2245,8.826
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay2.fiatdenier.com,50.1013,8.62643
nostr.oxtr.dev,50.4754,12.3683
nostr.l484.com,30.2672,-97.7431
relay.nostr.net,43.6532,-79.3832
relay.nuts.cash,52.3676,4.90414
nostr.bond,50.1109,8.68213
nostr.debate.report,50.1109,8.68213
prl.plus,55.7558,37.6173
temp.iris.to,43.6532,-79.3832
relay.noeudlibre.fr,50.6924,3.20113
relay.routstr.com,59.4016,17.9455
relay.islandbitcoin.com,20.3898,78.0903
nostr.linky.fit,50.1109,8.68213
bitchat.nostr1.com,40.7057,-74.0136
relay.lanavault.space,60.1699,24.9384
relayone.geektank.ai:443,39.0997,-94.5786
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relay.kizuna-miki.com,35.694,139.754
relay.edufeed.org:443,49.4521,11.0767
nr.yay.so,46.2126,6.1154
nostr.oxtr.dev:443,50.4754,12.3683
relay.primal.net,43.6532,-79.3832
relay.staging.plebeian.market,51.5072,-0.127586
nostr.planix.org,43.6532,-79.3832
relay.artio.inf.unibe.ch,46.9501,7.43678
chorus.mikedilger.com:444,-36.8906,174.794
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
relay.pocketstr.com,40.8302,-74.1299
nostr.thalheim.io,60.1699,24.9384
nostr.middling.mydns.jp,35.8099,140.12
offchain.pub:443,39.1585,-94.5728
cache.trustr.ing,43.6548,-79.3885
nostrride.io,37.3986,-121.964
relay.nostrfy.org,35.6916,139.768
wheat.happytavern.co,43.6532,-79.3832
relayone.soundhsa.com:443,39.0997,-94.5786
relay.satsmarkt.club,52.6907,4.8181
nexus.libernet.app,43.6532,-79.3832
relay.directsponsor.net,42.8864,-78.8784
nostr.spicyz.io,43.6532,-79.3832
nostr.rtvslawenia.com,49.4543,11.0746
relay.trotters.cc,43.6532,-79.3832
relayone.soundhsa.com,39.0997,-94.5786
nostr.na.social,43.6532,-79.3832
ribo.us.nostria.app:443,43.6532,-79.3832
nostr.hekster.org,37.3986,-121.964
relay.nostr.dev.br,48.1046,11.6002
relay.keykeeper.world,40.7824,-74.0711
nostr.pbfs.io,50.4754,12.3683
nostr.girino.org,43.6532,-79.3832
relay1.privkey.io,25.7617,-80.1918
relay.sharegap.net,43.6532,-79.3832
relay.dreamith.to,43.6532,-79.3832
nostr2.girino.org,43.6532,-79.3832
nostr.bitcoiner.social,47.6743,-117.112
relay.nostu.be,40.4167,-3.70329
relay.mwaters.net,50.9871,2.12554
nostr.islandarea.net,35.4669,-97.6473
chorus.bonsai.com,39.0438,-77.4874
nostr.yutakobayashi.com,43.6532,-79.3832
nostras.app,38.7946,-106.535
relay.yoinekodo.jp,43.6532,-79.3832
top.testrelay.top,43.6532,-79.3832
nostr-01.uid.ovh,50.9871,2.12554
relay.orly.dev,32.7767,-96.797
offchain.bostr.online,43.6532,-79.3832
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
conduitl2.fly.dev,38.7946,-106.535
nostr.n7ekb.net,47.4941,-122.294
relay.44billion.net,43.6532,-79.3832
nostrcity-club.fly.dev,38.7946,-106.535
syb.lol,34.0549,-118.243
relay.mypathtofire.de,42.8864,-78.8784
nostr.relay.hedwig.sh,60.1699,24.9384
nostr.myshosholoza.co.za:443,52.3676,4.90414
relay.minibolt.info,43.6532,-79.3832
nostr.fullstackcash.net,45.5201,-122.99
bendernostur.duckdns.org:8443,50.1109,8.68213
nostr.21crypto.ch,47.5356,8.73209
relay.qstr.app,50.1109,8.68213
ynostr.yael.at,60.1699,24.9384
relay.zone667.com,60.1699,24.9384
relay.underorion.se,50.1109,8.68213
relay.beamhop.com,43.6532,-79.3832
relay.paulstephenborile.com,49.4543,11.0746
nostr.easycryptosend.it,43.6532,-79.3832
nostr-verified.wellorder.net,45.5201,-122.99
nostr.hekster.org:443,37.3986,-121.964
relay.samt.st,40.8302,-74.1299
nostr.islandarea.net:443,35.4669,-97.6473
nostr-01.uid.ovh,50.9871,2.12554
relay-dev.gulugulu.moe,43.6532,-79.3832
relay.44billion.net,43.6532,-79.3832
dev-relay.nostreon.com,60.1699,24.9384
nostr.pbfs.io,50.4754,12.3683
nrs-01.darkcloudarcade.com,39.0997,-94.5786
relay01.lnfi.network,35.6764,139.65
relay.mostr.pub,43.6532,-79.3832
strfry.apps3.slidestr.net,40.4167,-3.70329
nostr.linky.fit,50.1109,8.68213
nostr.davenov.com,50.1109,8.68213
relay.noeudlibre.fr,50.6924,3.20113
nostr.stakey.net,52.3676,4.90414
espelho.girino.org,43.6532,-79.3832
nostr.snowbla.de:443,50.4754,12.3683
hasky.chat,40.7862,-74.0743
relay.artio.inf.unibe.ch,46.9501,7.43678
relay.manneken.brussels,49.4543,11.0746
relay.veganostr.com,60.1699,24.9384
relay.nexterz.com,43.6532,-79.3832
nostr.snowbla.de,50.4754,12.3683
nostr.unkn0wn.world,46.8499,9.53287
nostr.wild-vibes.ts.net,48.8566,2.35222
relay.plebeian.market,50.1109,8.68213
relay.zone667.com,60.1699,24.9384
cs-relay.nostrdev.com:443,50.4754,12.3683
relay.staging.plebeian.market:443,51.5072,-0.127586
relay.openresist.com,43.6532,-79.3832
nostr.spaceshell.xyz,43.6532,-79.3832
vault.iris.to:443,43.6532,-79.3832
nostr.easycryptosend.it,43.6532,-79.3832
nostr.bitcoiner.social:443,47.6743,-117.112
relay.fiatdenier.com,43.7787,-79.5393
relay.gulugulu.moe:443,43.6532,-79.3832
relay.mitosystem.net,-23.5558,-46.6396
relay.mostro.network,40.8302,-74.1299
relay1.nostrchat.io,60.1699,24.9384
nostr.tabordalab.com,60.1699,24.9384
relay.gulugulu.moe,43.6532,-79.3832
pocketrelay.live,40.8302,-74.1299
relay.nostrfeed.com,60.1699,24.9384
testr.nymble.world,40.8054,-74.0241
x.kojira.io,43.6532,-79.3832
relay.sigit.io,50.4754,12.3683
nostrcity-club.fly.dev:443,38.7946,-106.535
relay.wisp.talk:443,49.4543,11.0746
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr-dev.wellorder.net,45.5201,-122.99
nostr.pbfs.io:443,50.4754,12.3683
nostr.takasaki.dev,43.6532,-79.3832
relay.snort.social,53.3498,-6.26031
articles.layer3.news,37.3387,-121.885
staging.yabu.me,35.6092,139.73
nostr.rtvslawenia.com:443,49.4543,11.0746
nostr-relay.xbytez.io:443,50.6924,3.20113
public.crostr.com:443,43.6532,-79.3832
nostr.ltd,19.076,72.8777
nostr-02.uid.ovh,50.9871,2.12554
nostr.islandarea.net:443,35.4669,-97.6473
relay.kilombino.com,43.6532,-79.3832
relay.satlantis.io,39.0438,-77.4874
nostr.computingcache.com,45.5341,-122.956
nostr.wecsats.io,43.6532,-79.3832
nostr-relay.zimage.com,34.282,-118.439
nostr.tbxnetworx.de,47.8226,10.0064
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
relay.klabo.world,47.674,-122.122
auth.nostr1.com,40.7057,-74.0136
relay.degmods.com,50.4754,12.3683
relay.olas.app,60.1699,24.9384
fanfares.nostr1.com,40.7057,-74.0136
node.kommonzenze.de,49.4521,11.0767
relay.bitos.space,43.6532,-79.3832
relay-na1.metanomalist.com,43.6532,-79.3832
relay.favillakey.io,-27.4705,153.026
relay.nearhood.co.uk,51.5134,-0.0890675
relay.illuminodes.com,43.6532,-79.3832
nostr.dlcdevkit.com,40.0992,-83.1141
relay.nostr.net,43.6532,-79.3832
relay.trotters.cc:443,43.6532,-79.3832
cyberspace.nostr1.com,40.7057,-74.0136
nostr-relay.nextblockvending.com,47.2343,-119.853
relay01.lnfi.network,35.6764,139.65
relay.froth.zone,60.1699,24.9384
denimroad.feeds.relay.tools,38.6327,-90.1961
nostr.myshosholoza.co.za,52.3676,4.90414
21milionidinostr.duckdns.org,41.8945,12.6493
relay.getvia.xyz,60.1699,24.9384
ribo.us.nostria.app,43.6532,-79.3832
relay.nostrhub.fr,48.1045,11.6004
talon.quest,43.6532,-79.3832
portal-relay.pareto.space,49.4521,11.0767
relayone.geektank.ai,39.0997,-94.5786
relay.orangesync.tech,40.7128,-74.006
strfry.apps3.slidestr.net,40.4167,-3.70329
relay.mleku.dev,32.7767,-96.797
relay.nostrian-conquest.com,41.223,-111.974
relay.underorion.se,50.1109,8.68213
vault.iris.to,43.6532,-79.3832
schnorr.me,43.6532,-79.3832
relay.bullishbounty.com:443,43.6532,-79.3832
relay.i9.eti.br,43.6532,-79.3832
nostr-pub.wellorder.net,45.5201,-122.99
relay.cyberguy.fyi,52.6907,4.8181
relay.mappingbitcoin.com,43.6532,-79.3832
nostr-relay.acloud.kdns.fr,43.6532,-79.3832
nostr.2b9t.xyz,34.0549,-118.243
nostr.2b9t.xyz:443,34.0549,-118.243
dev-relay.nostreon.com,60.1699,24.9384
nostr.purpura.cloud,43.6532,-79.3832
nos.lol:443,50.4754,12.3683
nostr-relay.corb.net,39.6478,-104.988
relay.scuba323.com,40.8218,-74.45
relay.agora.social,50.7383,15.0648
nostr.data.haus,50.4754,12.3683
relay.satsapp.me,50.4754,12.3683
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
nostr.hekster.org:443,37.3986,-121.964
relay.btcforplebs.com,43.6532,-79.3832
nostr.azzamo.net:443,52.2633,21.0283
relay.tdw.gg,34.0549,-118.243
relay.veganostr.com:443,60.1699,24.9384
wisp.djorivaltech.com,43.6532,-79.3832
relay.cosmicbolt.net,37.3986,-121.964
nostr.debate.report,50.1109,8.68213
nostr.na.social,43.6532,-79.3832
relay-arg.zombi.cloudrodion.com,1.35208,103.82
nostr.wild-vibes.ts.net,48.8566,2.35222
relay.bornheimer.app,50.1109,8.68213
relay02.lnfi.network,35.6764,139.65
relay.kaleidoswap.com,50.8476,4.35717
strfry.shock.network,39.0438,-77.4874
nostr.txlyre.website,60.1699,24.9384
nostr.twinkle.lol,51.902,7.6657
relay.satmaxt.xyz,43.6532,-79.3832
relay.vrtmrz.net:443,43.6532,-79.3832
aurum.saturnali.net,46.2044,6.14316
nostrrelay.taylorperron.com,45.5029,-73.5723
nostrelay.circum.space,52.2245,8.826
relay.mostr.pub:443,43.6532,-79.3832
nostr.data.haus:443,50.4754,12.3683
relay.nostreon.com,60.1699,24.9384
relay.plebeian.market:443,50.1109,8.68213
relay.damus.io,43.6532,-79.3832
budabit.nostr1.com,40.7057,-74.0136
bucket.coracle.social,37.7775,-122.397
relay.chorus.community:443,50.7529,6.84585
relay.hivetalk.org,40.8302,-74.1299
relay.favillakey.io,-27.4705,153.026
nostr.mom,50.4754,12.3683
relay2.orangesync.tech,40.7128,-74.006
relay.artiostr.ch,43.6532,-79.3832
relay.lacrypta.ar,43.6532,-79.3832
treuzkas.branruz.com,48.8575,2.35138
relay2.veganostr.com,60.1699,24.9384
borgar.lol,52.3676,4.90414
relay.ltgnet.work:8443,51.0511,-114.075
relay.nostrmap.net,60.1699,24.9384
nostr.mom:443,50.4754,12.3683
relay.decentralia.fr,48.122,11.589
relay.atsocy.com,43.6532,-79.3832
strfry.shock.network:443,39.0438,-77.4874
relay.vrtmrz.net,43.6532,-79.3832
0x-nostr-relay.fly.dev,38.7946,-106.535
relay.veganostr.com,60.1699,24.9384
relay.nostr.bond,40.1885,29.061
relay.angor.io,48.1046,11.6002
nostr.sathoarder.com:443,48.5734,7.75211
nostr.oxtr.dev,50.4754,12.3683
dev.relay.stream,43.6532,-79.3832
relay.mitchelltribe.com,39.0438,-77.4874
nostr-relay.cbrx.io,43.6532,-79.3832
nostr2.girino.org:443,43.6532,-79.3832
relay.snotr.nl:49999,51.9758,4.31389
n.musicroadmap.com,35.694,139.754
relay.loveisbitcoin.com,43.6532,-79.3832
relay.conduit.market,38.7946,-106.535
schnorr.me:443,43.6532,-79.3832
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
nostr.dlcdevkit.com,40.0992,-83.1141
relay2.angor.io,48.1046,11.6002
nostr.hifish.org,47.4009,8.57814
rele.speyhard.fi,51.5072,-0.127586
nostr.chaima.info:443,51.5072,-0.127586
relay.trotters.cc:443,43.6532,-79.3832
nostrmxn.lulus.com.mx,59.4016,17.9455
chat-relay.zap-work.com:443,43.6532,-79.3832
relay.tdw.gg,34.0549,-118.243
relay.ohstr.com,43.6532,-79.3832
relay.guggero.org,46.5971,9.59652
nostr.stakey.net:443,52.3676,4.90414
dev-relay.nostr.space,43.6532,-79.3832
nostr.snowbla.de,50.4754,12.3683
relay.piazza.today,48.122,11.589
relay.fountain.fm:443,43.6532,-79.3832
relay-dev.gulugulu.moe,43.6532,-79.3832
relay.ditto.pub:443,43.6532,-79.3832
vm-1734.lnvps.cloud,53.3498,-6.26031
relay.beginningend.com,35.2227,-97.4786
nos.lol,50.4754,12.3683
nostr-01.yakihonne.com:443,1.32123,103.695
relay.sigit.io:443,50.4754,12.3683
nostr.janx.com,43.6532,-79.3832
relay.wavlake.com,41.2619,-95.8608
relayrs.notoshi.win:443,43.6532,-79.3832
openrelay.ziomc.com,45.4642,9.18998
nostr.88mph.life,52.1941,-2.21905
maxq.descendant.io,43.6532,-79.3832
nostr.wecsats.io:443,43.6532,-79.3832
relay.bitmacro.cloud,43.6532,-79.3832
relay.lightning.pub,39.0438,-77.4874
strfry.bonsai.com,39.0438,-77.4874
chat-relay.zap-work.com,43.6532,-79.3832
relay.illuminodes.com,43.6532,-79.3832
nostr.thebiglake.org,32.71,-96.6745
ribo.eu.nostria.app,43.6532,-79.3832
relay.novospes.com,43.6532,-79.3832
nostr.notribe.net,40.8302,-74.1299
nostr-relay.corb.net:443,39.6478,-104.988
relay.pprgb.app,52.52,13.405
relay.wellorder.net,45.5201,-122.99
relay.mitchelltribe.com:443,39.0438,-77.4874
cyberspace.nostr1.com,40.7057,-74.0136
bruh.samt.st,43.6532,-79.3832
nostr.davenov.com,50.1109,8.68213
nostr.sovereignservices.xyz,43.6532,-79.3832
relay-dev.gulugulu.moe:443,43.6532,-79.3832
nostr.unkn0wn.world,46.8499,9.53287
relay.nostr.space,43.6532,-79.3832
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
relay.ezernode.net,52.6921,6.19372
no.str.cr,8.96171,-83.5246
spamspamspamspam.rest,43.6532,-79.3832
relay.ru.ac.th,13.7607,100.627
relay.tdw.lol,41.8781,-87.6298
buzz.cashu.space,50.1109,8.68213
relay.duck1123.com,43.6532,-79.3832
relay.tapestry.ninja,40.8054,-74.0241
relay.layer.systems,49.0291,8.35695
relay.dyne.org,49.0291,8.35705
relay.aidatanorge.no,43.6532,-79.3832
relay.opmaat.org,60.1699,24.9384
nostr.azzamo.net,52.2633,21.0283
relay.mccormick.cx,52.3563,4.95714
relay.nostr.com,50.1109,8.68213
nostr.plantroon.com,50.1013,8.62643
support.flotilla.social,32.9483,-96.7299
relay.veganostr.com:443,60.1699,24.9384
relay.notoshi.win,12.9333,100.883
relay.edufeed.org,49.4521,11.0767
memlay.v0l.io,53.3498,-6.26031
bitcoiner.social,47.6743,-117.112
relay.manneken.brussels,49.4543,11.0746
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
no.str.cr:443,8.96171,-83.5246
relay.cosmicbolt.net,37.3986,-121.964
relay.getsafebox.app:443,43.6532,-79.3832
relay.aarpia.com,37.3986,-121.964
relay.satsapp.me,50.4754,12.3683
relay.decentralia.fr,48.122,11.589
relay.beginningend.com,35.2227,-97.4786
nostr.sathoarder.com,48.5734,7.75211
relay.getsafebox.app,43.6532,-79.3832
nostr.chaima.info,51.5072,-0.127586
relay.ditto.pub,43.6532,-79.3832
nostr.4rs.nl,49.0291,8.35696
rilo.nostria.app,43.6532,-79.3832
nostr.christiansass.de,51.7883,6.13865
relay.paulstephenborile.com:443,49.4543,11.0746
relay.satmaxt.xyz,43.6532,-79.3832
nostr.oxtr.dev:443,50.4754,12.3683
vault.iris.to,43.6532,-79.3832
relay.ditto.pub:443,43.6532,-79.3832
relay.atsocy.com,43.6532,-79.3832
relay.vrtmrz.net,43.6532,-79.3832
soloco.nl,43.6532,-79.3832
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
relayone.soundhsa.com:443,39.0997,-94.5786
nostr.wawasoft.net,43.6532,-79.3832
relay.solife.me,43.6532,-79.3832
nostr.mom,50.4754,12.3683
relay.getvia.xyz,60.1699,24.9384
freelay.sovbit.host,60.1699,24.9384
relay-rpi.edufeed.org:443,49.4521,11.0767
relay.bullishbounty.com:443,43.6532,-79.3832
schnorr.me:443,43.6532,-79.3832
temp.iris.to,43.6532,-79.3832
nostr-relay.cbrx.io,43.6532,-79.3832
strfry.bonsai.com:443,39.0438,-77.4874
nostr.hoppe-relay.it.com,42.8864,-78.8784
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.nostriches.club,43.6532,-79.3832
relay.dyne.org,49.0291,8.35705
yabu.me,35.6092,139.73
relay.chorus.community:443,48.5333,10.7
cs-relay.nostrdev.com:443,50.4754,12.3683
relay.mwaters.net,50.9871,2.12554
no.str.cr,8.96171,-83.5246
nostr.hifish.org,47.4009,8.57814
relay.directsponsor.net,42.8864,-78.8784
public.obelisk.ar,43.6532,-79.3832
nostr.carroarmato0.be,50.914,3.21378
nostr.sovereignservices.xyz,43.6532,-79.3832
nostr.pbfs.io:443,50.4754,12.3683
relay.degmods.com,50.4754,12.3683
relay.plebeian.market,50.1109,8.68213
relay.earthly.city,34.1749,-118.54
inbox.scuba323.com,40.8218,-74.45
nostr-01.yakihonne.com,1.32123,103.695
articles.layer3.news:443,37.3387,-121.885
offchain.pub,39.1585,-94.5728
nostr.thebiglake.org,32.71,-96.6745
bendernostur.duckdns.org:8443,50.1109,8.68213
nostr-relay.zimage.com,34.282,-118.439
nostr.ac,38.958,-77.3592
relay.nostr.com,50.1109,8.68213
bitcoiner.social,47.6743,-117.112
nostr.wecsats.io,43.6532,-79.3832
relay.pprgb.app,52.52,13.405
nostr.girino.org,43.6532,-79.3832
nostr.bitcoiner.social,47.6743,-117.112
nostr.planix.org,43.6532,-79.3832
nostr.data.haus:443,50.4754,12.3683
strfry.bonsai.com:443,39.0438,-77.4874
relay.nostriot.com,41.5695,-83.9786
relay.bullishbounty.com,43.6532,-79.3832
dm-test-strfry-generic.samt.st,43.6532,-79.3832
relay.lanacoin-eternity.com:443,40.8302,-74.1299
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
nostr.emanuelemiani.it,45.778,8.79414
reraw.pbla2fish.cc,43.6532,-79.3832
relay.s-w.art,43.6532,-79.3832
relay.nostrcheck.me,43.6532,-79.3832
relay.npubhaus.com,43.6532,-79.3832
nostr.vulpem.com,49.4543,11.0746
relay.wasabiwallet.io,43.6532,-79.3832
nostr.liberty.fans,36.8767,-89.5879
relay.paulstephenborile.com,49.4543,11.0746
relay.nostrmap.net:443,60.1699,24.9384
relay.arx-ccn.com,50.4754,12.3683
relay.agorist.space:443,52.3734,4.89406
tribune-panel-growing-noon.trycloudflare.com,43.6532,-79.3832
relay-na1.metanomalist.com,43.6532,-79.3832
relay.agentry.com,42.8864,-78.8784
relay.samt.st,40.8302,-74.1299
relay.nostx.io,43.6532,-79.3832
relay.cosmicbolt.net:443,37.3986,-121.964
relayrs.notoshi.win,43.6532,-79.3832
adre.su,59.9311,30.3609
nostrcheck.me,43.6532,-79.3832
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
nrs-01.darkcloudarcade.com,39.0997,-94.5786
relay.nmail.li,50.9871,2.12554
relay.wisp.talk,49.4543,11.0746
relay.mostr.pub,43.6532,-79.3832
relay.jmoose.rocks,32.8009,-96.8195
relay.laantungir.net,-19.4692,-42.5315
relay.edufeed.org:443,49.4521,11.0767
nostrcity-club.fly.dev:443,38.7946,-106.535
nostr.nodesmap.com,59.3327,18.0656
freelay.sovbit.host,60.1699,24.9384
soloco.nl,43.6532,-79.3832
ribo.nostria.app,43.6532,-79.3832
relay.chatbett.de,40.7128,-74.006
groups.beginningend.com,35.2227,-97.4786
relay.chorus.community,50.7529,6.84585
wisp.djorivaltech.com,43.6532,-79.3832
nostr-02.yakihonne.com,1.32123,103.695

1 Relay URL Latitude Longitude
dm-test-strfry-generic.samt.st 43.6532 -79.3832
nostrelay.circum.space:443 52.2245 8.826
relay.staging.plebeian.market 51.5072 -0.127586
nostr.chaima.info:443 50.1109 8.68213
relay.vrtmrz.net:443 43.6532 -79.3832
relay.gulugulu.moe:443 43.6532 -79.3832
relay.dreamith.to 43.6532 -79.3832
nostrride.io 37.3986 -121.964
nostr.n7ekb.net 47.4941 -122.294
relay.npubhaus.com 43.6532 -79.3832
relay1.privkey.io 25.7617 -80.1918
nostr-relay.xbytez.io:443 50.6924 3.20113
relay.bullishbounty.com 43.6532 -79.3832
nostr.islandarea.net 35.4669 -97.6473
nostrrelay.taylorperron.com 45.5029 -73.5723
relay.sharegap.net 43.6532 -79.3832
relayone.geektank.ai 39.0997 -94.5786
nexus.libernet.app 43.6532 -79.3832
fanfares.nostr1.com:443 40.7057 -74.0136
relay.satlantis.io 39.0438 -77.4874
relay.hivetalk.org 40.8302 -74.1299
nos.lol 50.4754 12.3683
staging.yabu.me 35.6092 139.73
relay-us.zombi.cloudrodion.com 40.7862 -74.0743
relay.internationalright-wing.org -22.4692 -48.9875
relay.nostrcheck.me 43.6532 -79.3832
denimroad.feeds.relay.tools 38.6327 -90.1961
relay.nostr-check.me 43.6532 -79.3832
ribo.us.nostria.app:443 43.6532 -79.3832
relay.lightning.pub 39.0438 -77.4874
2 myvoiceourstory.org 37.3598 -121.981
relay.jmoose.rocks:443 37.4419 -122.143
nostr-pr02.redscrypt.org 52.3676 4.90414
nostr.purpura.cloud 43.6532 -79.3832
relay.sigit.io:443 50.4754 12.3683
relay.nostrops.com 32.71 -96.6745
relay.jmoose.rocks 37.4419 -122.143
nostr.spaceshell.xyz 43.6532 -79.3832
nostr-pub.wellorder.net 45.5201 -122.99
relay.opmaat.org 60.1699 24.9384
nostr.myshosholoza.co.za 52.3676 4.90414
offchain.bostr.online 43.6532 -79.3832
nostr-relay.nilpote.com 43.6532 -79.3832
relay.nostu.be 40.4167 -3.70329
nostr.stakey.net 52.3676 4.90414
dm-test-strfry-discovery.samt.st 43.6532 -79.3832
conduitl2.fly.dev 38.7946 -106.535
vault.iris.to:443 43.6532 -79.3832
relay.minibolt.info 43.6532 -79.3832
articles.layer3.news:443 37.3387 -121.885
nostr.overmind.lol 43.6532 -79.3832
nostr.21crypto.ch 47.5356 8.73209
nostr.tabordalab.com 60.1699 24.9384
nostr.spicyz.io 43.6532 -79.3832
nostr.sathoarder.com 48.5734 7.75211
relay.chorus.community 48.5333 10.7
dev.relay.stream 43.6532 -79.3832
nostr-relay.corb.net:443 39.6478 -104.988
relay.lightning.pub:443 39.0438 -77.4874
nostr.tbxnetworx.de 47.8226 10.0064
nostr.azzamo.net 52.2633 21.0283
relay.loveisbitcoin.com 43.6532 -79.3832
strfry.ymir.cloud 43.6532 -79.3832
chorus.mikedilger.com:444 -36.8906 174.794
relay.agilesolutionlabs.com 43.6532 -79.3832
relay.gulugulu.moe 43.6532 -79.3832
nostr.tac.lol 47.4748 -122.273
relay.mypathtofire.de 42.8864 -78.8784
nostrelay.circum.space 52.2245 8.826
bcast.girino.org 43.6532 -79.3832
relay.ordoplay.com 50.1109 8.68213
nostr.chaima.info 50.1109 8.68213
offchain.pub:443 39.1585 -94.5728
relay.wisp.talk 49.4543 11.0746
strfry.bonsai.com 39.0438 -77.4874
nostr.2b9t.xyz:443 34.0549 -118.243
relay.nostx.io 43.6532 -79.3832
bucket.coracle.social 37.7775 -122.397
relay.wavlake.com:443 41.2619 -95.8608
nostr.carroarmato0.be:443 50.914 3.21378
treuzkas.branruz.com 48.8575 2.35138
nostr.computingcache.com 45.5341 -122.956
cs-relay.nostrdev.com 50.4754 12.3683
relay.lanacoin-eternity.com 40.8302 -74.1299
nostr.rtvslawenia.com 49.4543 11.0746
nip85.nosfabrica.com 39.0997 -94.5786
relay.wisp.talk:443 49.4543 11.0746
nostr.stakey.net:443 52.3676 4.90414
relay.piazza.today 48.122 11.589
relay.conduit.market 38.7946 -106.535
relay.nostrmap.net 60.1699 24.9384
maxq.descendant.io 43.6532 -79.3832
nostr.hekster.org 37.3986 -121.964
nostr.christiansass.de 49.7423 8.76687
tribune-panel-growing-noon.trycloudflare.com 43.6532 -79.3832
relay.lanacoin-eternity.com:443 40.8302 -74.1299
relay.scuba323.com 40.8218 -74.45
schnorr.me 43.6532 -79.3832
relay.cypherflow.ai 48.8575 2.35138
nostr.snowbla.de:443 50.4754 12.3683
3 nostr.whitenode45.ddns.net 40.55 -74.4758
adre.su 59.9311 30.3609
relay.getsafebox.app 43.6532 -79.3832
auth.nostr1.com 40.7057 -74.0136
relay2.veganostr.com 60.1699 24.9384
relay.i9.eti.br 43.6532 -79.3832
relay.satsmarkt.club 52.6907 4.8181
nostr.bitcoiner.social:443 47.6743 -117.112
cache.trustr.ing 43.6548 -79.3885
relay.mitchelltribe.com 39.0438 -77.4874
21milionidinostr.duckdns.org 41.8967 12.4822
public.crostr.com:443 43.6532 -79.3832
relay.otrta.me 50.1109 8.68213
relay.bornheimer.app 50.1109 8.68213
relay.sigit.io 50.4754 12.3683
nostr-relay.xbytez.io 50.6924 3.20113
relayone.soundhsa.com 39.0997 -94.5786
vm-1734.lnvps.cloud 53.3498 -6.26031
nrs-01.darkcloudarcade.com:443 39.0997 -94.5786
nostr.twinkle.lol 51.902 7.6657
nostr.data.haus 50.4754 12.3683
nostr.mas-family.eu 60.3478 15.7505
relay.nostrhub.fr 48.1045 11.6004
nostr.openhoofd.nl 51.5717 3.70417
relay.nostreon.com 60.1699 24.9384
relay.tdw.lol 41.8781 -87.6298
top.testrelay.top 43.6532 -79.3832
syb.lol 34.0549 -118.243
relay.primal.net 43.6532 -79.3832
n.musicroadmap.com 35.694 139.754
ribo.eu.nostria.app 43.6532 -79.3832
nostr.red5d.dev 43.6532 -79.3832
nostr.ltd 19.076 72.8777
relay.olas.app 60.1699 24.9384
chat-relay.zap-work.com 43.6532 -79.3832
relay.islandbitcoin.com 12.8498 77.6545
relay.chatbett.de 40.7128 -74.006
relay.novospes.com 43.6532 -79.3832
nostr.bond 50.1109 8.68213
inbox.scuba323.com 40.8218 -74.45
0x-nostr-relay.fly.dev 38.7946 -106.535
relay.sincensura.org 43.6532 -79.3832
nostr-02.uid.ovh 50.9871 2.12554
relay.solomonstr.com 43.6532 -79.3832
purplerelay.com 43.6532 -79.3832
nostr.emanuelemiani.it 45.778 8.79414
relay.damustr.com 43.6532 -79.3832
fanfares.nostr1.com 40.7057 -74.0136
relay.plebeian.market:443 50.1109 8.68213
buzz.ac2n-share.kozow.com 45.764 4.83566
nostr-01.yakihonne.com:443 1.32123 103.695
relay.snotr.nl:49999 51.9758 4.31389
relay.ltgnet.work:8443 51.0511 -114.075
nostras.app 38.7946 -106.535
purplerelay.com:443 43.6532 -79.3832
relay.bnos.space 43.6532 -79.3832
porchlight.social 43.6532 -79.3832
relay.mostr.pub:443 43.6532 -79.3832
relay.layer.systems 49.0291 8.35695
nostrcity-club.fly.dev 38.7946 -106.535
relay.guggero.org 46.5971 9.59652
relay.layer.systems:443 49.0291 8.35695
relay.edufeed.org 49.4521 11.0767
nostr.mom:443 50.4754 12.3683
relay.mccormick.cx 52.3563 4.95714
public.crostr.com 43.6532 -79.3832
mostro-p2p.tech 50.1109 8.68213
strfry.shock.network:443 39.0438 -77.4874
relay.agentry.com 42.8864 -78.8784
relay-can.zombi.cloudrodion.com 43.6532 -79.3832
relay.nostrian-conquest.com 41.223 -111.974
relay.fountain.fm 43.6532 -79.3832
nosflare-leefcore.leefcore.workers.dev 43.6532 -79.3832
rele.speyhard.fi 50.1109 8.68213
reraw.pbla2fish.cc 43.6532 -79.3832
relay.pyramid.li 47.4093 8.46503
relay.trotters.cc 43.6532 -79.3832
relay.ru.ac.th 13.7607 100.627
relay.orly.dev 32.7767 -96.797
nostr.yutakobayashi.com 43.6532 -79.3832
nostr-01.yakihonne.com 1.32123 103.695
relay.lanavault.space 60.1699 24.9384
chat-relay.zap-work.com:443 43.6532 -79.3832
strfry.shock.network 39.0438 -77.4874
wheat.happytavern.co 43.6532 -79.3832
relay.klabo.world 47.674 -122.122
relay.littlebitstudios.com 43.6532 -79.3832
nostr-relay.corb.net 39.6478 -104.988
relay.flashapp.me 43.6548 -79.3885
espelho.girino.org 43.6532 -79.3832
budabit.nostr1.com 40.7057 -74.0136
nostr.vulpem.com 49.4543 11.0746
relay.nostrfeed.com 60.1699 24.9384
basspistol.org 49.0291 8.35696
relay-rpi.edufeed.org 49.4521 11.0767
relay.mitchelltribe.com:443 39.0438 -77.4874
spamspamspamspam.rest 43.6532 -79.3832
relayrs.notoshi.win:443 43.6532 -79.3832
relay.endfiat.money 59.3327 18.0656
nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
nostr.tagomago.me 42.3601 -71.0589
nostr-kyomu-haskell.onrender.com 37.7775 -122.397
nostr.rtvslawenia.com:443 49.4543 11.0746
no.str.cr:443 8.96171 -83.5246
relay.ditto.pub 43.6532 -79.3832
relay.shadowbip.com 54.352 18.6466
nostr.liberty.fans 36.8767 -89.5879
prl.plus 55.7628 37.5983
relay.agorist.space 52.3734 4.89406
articles.layer3.news 37.3387 -121.885
nostr.thalheim.io:443 60.1699 24.9384
relay.mccormick.cx:443 52.3563 4.95714
support.flotilla.social 32.9483 -96.7299
nostr.mikoshi.de 50.1109 8.68213
nostrmxn.lulus.com.mx 59.4016 17.9455
bridge.tagomago.me 42.3601 -71.0589
relayrs.notoshi.win 43.6532 -79.3832
nostr.88mph.life 52.1941 -2.21905
nostr.fullstackcash.net 45.5201 -122.99
4 relay5.bitransfer.org 43.6532 -79.3832
5 relay.0xchat.com relay.littlebitstudios.com 43.6532 -79.3832
6 relay.nostriot.com public.crostr.com 41.5695 43.6532 -83.9786 -79.3832
7 relay.wellorder.net nostr-verified.wellorder.net 45.5201 -122.99
8 relay-fra.zombi.cloudrodion.com nostr-relay.xbytez.io 48.8566 50.6924 2.35222 3.20113
9 nostr.relay.hedwig.sh nostr.azzamo.net:443 60.1699 52.2633 24.9384 21.0283
10 relay.openspecs.uid.ovh yabu.me 50.9871 35.6092 2.12554 139.73
11 relay.keykeeper.world relay.arx-ccn.com 40.7824 50.4754 -74.0711 12.3683
nostr.wecsats.io:443 43.6532 -79.3832
relay.wavlake.com 41.2619 -95.8608
relay.lacrypta.ar 43.6532 -79.3832
nos.lol:443 50.4754 12.3683
dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
relay.nmail.li 50.9871 2.12554
relay.mostro.network 40.8302 -74.1299
bitcoinostr.duckdns.org 41.1976 1.11167
relay.s-w.art 43.6532 -79.3832
12 relay.ohstr.com:443 43.6532 -79.3832
13 nostr-dev.wellorder.net relay.nearhood.co.uk 45.5201 51.5134 -122.99 -0.0890675
14 nostr.notribe.net purplerelay.com 40.8302 43.6532 -74.1299 -79.3832
15 relay.mleku.dev cs-relay.nostrdev.com 32.7767 50.4754 -96.797 12.3683
16 nostr-rs-relay.dev.fedibtc.com nostr.red5d.dev 39.0438 43.6532 -77.4874 -79.3832
17 nostr2.girino.org:443 relay-fra.zombi.cloudrodion.com 43.6532 48.8566 -79.3832 2.35222
18 relay.cosmicbolt.net:443 relay.lightning.pub:443 37.3986 39.0438 -121.964 -77.4874
19 ribo.us.nostria.app relay.mccormick.cx:443 43.6532 52.3563 -79.3832 4.95714
20 nostr.janx.com fanfares.nostr1.com:443 43.6532 40.7057 -79.3832 -74.0136
21 familiamartins.net.br relay.shadowbip.com -22.8833 54.352 -43.1036 18.6466
22 relay.yoinekodo.jp nip85.nosfabrica.com 43.6532 39.0997 -79.3832 -94.5786
nostr.4rs.nl 49.0291 8.35696
relay.routstr.com 59.4016 17.9455
relay.bitmacro.cloud 43.6532 -79.3832
relay.mappingbitcoin.com 43.6532 -79.3832
nostr.dlcdevkit.com:443 40.0992 -83.1141
relay-testnet.k8s.layer3.news 37.3387 -121.885
nostr.overmind.lol:443 43.6532 -79.3832
relayone.geektank.ai:443 39.0997 -94.5786
relay.staging.plebeian.market:443 51.5072 -0.127586
relay.kilombino.com 43.6532 -79.3832
relay.endfiat.money:443 59.3327 18.0656
23 social.amanah.eblessing.co 48.1046 11.6002
24 nostr.2b9t.xyz relay.openspecs.uid.ovh 34.0549 50.9871 -118.243 2.12554
25 relay.sector01.com relay.pyramid.li 41.4513 47.4093 -81.7021 8.46503
26 relay-dev.gulugulu.moe:443 bcast.girino.org 43.6532 -79.3832
27 relay.nostr.dev.br relay.jmoose.rocks:443 48.1046 32.8009 11.6002 -96.8195
28 x.kojira.io relay.solife.me 43.6532 -79.3832
29 node.kommonzenze.de relay.flashapp.me 49.4521 43.6548 11.0767 -79.3885
30 relay.earthly.city relay.wavlake.com:443 34.1749 41.2619 -118.54 -95.8608
31 buzz.cashu.space nostr.plantroon.com:443 50.1109 50.1013 8.68213 8.62643
32 relay.kizuna-miki.com relay.lanacoin-eternity.com 35.694 40.8302 139.754 -74.1299
33 bitchat.nostr1.com nostr.infero.net 40.7057 35.6764 -74.0136 139.65
34 bruh.samt.st nostrelay.circum.space:443 43.6532 52.2245 -79.3832 8.826
35 dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
36 relay2.fiatdenier.com 50.1013 8.62643
37 nostr.oxtr.dev nostr.l484.com 50.4754 30.2672 12.3683 -97.7431
38 relay.nostr.net 43.6532 -79.3832
39 relay.nuts.cash 52.3676 4.90414
40 nostr.bond 50.1109 8.68213
41 nostr.debate.report 50.1109 8.68213
42 prl.plus 55.7558 37.6173
43 temp.iris.to 43.6532 -79.3832
44 relay.noeudlibre.fr 50.6924 3.20113
45 relay.routstr.com 59.4016 17.9455
46 relay.islandbitcoin.com 20.3898 78.0903
47 nostr.linky.fit 50.1109 8.68213
48 bitchat.nostr1.com 40.7057 -74.0136
49 relay.lanavault.space 60.1699 24.9384
50 relayone.geektank.ai:443 39.0997 -94.5786
51 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
52 relay.kizuna-miki.com 35.694 139.754
53 relay.edufeed.org:443 49.4521 11.0767
54 nr.yay.so 46.2126 6.1154
55 nostr.oxtr.dev:443 50.4754 12.3683
56 relay.primal.net 43.6532 -79.3832
57 relay.staging.plebeian.market 51.5072 -0.127586
58 nostr.planix.org 43.6532 -79.3832
59 relay.artio.inf.unibe.ch 46.9501 7.43678
60 chorus.mikedilger.com:444 -36.8906 174.794
61 nrs-01.darkcloudarcade.com:443 39.0997 -94.5786
62 relay.pocketstr.com 40.8302 -74.1299
63 nostr.thalheim.io nostr.middling.mydns.jp 60.1699 35.8099 24.9384 140.12
64 offchain.pub:443 39.1585 -94.5728
65 cache.trustr.ing 43.6548 -79.3885
66 nostrride.io 37.3986 -121.964
67 relay.nostrfy.org 35.6916 139.768
68 wheat.happytavern.co 43.6532 -79.3832
69 relayone.soundhsa.com:443 39.0997 -94.5786
70 relay.satsmarkt.club 52.6907 4.8181
71 nexus.libernet.app 43.6532 -79.3832
72 relay.directsponsor.net 42.8864 -78.8784
73 nostr.spicyz.io 43.6532 -79.3832
74 nostr.rtvslawenia.com 49.4543 11.0746
75 relay.trotters.cc 43.6532 -79.3832
76 relayone.soundhsa.com 39.0997 -94.5786
77 nostr.na.social 43.6532 -79.3832
78 ribo.us.nostria.app:443 43.6532 -79.3832
79 nostr.hekster.org 37.3986 -121.964
80 relay.nostr.dev.br 48.1046 11.6002
81 relay.keykeeper.world 40.7824 -74.0711
82 nostr.pbfs.io 50.4754 12.3683
83 nostr.girino.org 43.6532 -79.3832
84 relay1.privkey.io 25.7617 -80.1918
85 relay.sharegap.net 43.6532 -79.3832
86 relay.dreamith.to 43.6532 -79.3832
87 nostr2.girino.org 43.6532 -79.3832
88 nostr.bitcoiner.social 47.6743 -117.112
89 relay.nostu.be 40.4167 -3.70329
90 relay.mwaters.net 50.9871 2.12554
91 nostr.islandarea.net 35.4669 -97.6473
92 chorus.bonsai.com 39.0438 -77.4874
93 nostr.yutakobayashi.com 43.6532 -79.3832
94 nostras.app 38.7946 -106.535
95 relay.yoinekodo.jp 43.6532 -79.3832
96 top.testrelay.top 43.6532 -79.3832
97 nostr-01.uid.ovh 50.9871 2.12554
98 relay.orly.dev 32.7767 -96.797
99 offchain.bostr.online 43.6532 -79.3832
100 nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
101 conduitl2.fly.dev 38.7946 -106.535
102 nostr.n7ekb.net 47.4941 -122.294
103 relay.44billion.net 43.6532 -79.3832
104 nostrcity-club.fly.dev 38.7946 -106.535
105 syb.lol 34.0549 -118.243
106 relay.mypathtofire.de 42.8864 -78.8784
107 nostr.relay.hedwig.sh 60.1699 24.9384
108 nostr.myshosholoza.co.za:443 52.3676 4.90414
109 relay.minibolt.info 43.6532 -79.3832
110 nostr.fullstackcash.net 45.5201 -122.99
111 bendernostur.duckdns.org:8443 50.1109 8.68213
112 nostr.21crypto.ch 47.5356 8.73209
113 relay.qstr.app 50.1109 8.68213
114 ynostr.yael.at 60.1699 24.9384
115 relay.zone667.com nostr.stakey.net 60.1699 52.3676 24.9384 4.90414
116 relay.underorion.se espelho.girino.org 50.1109 43.6532 8.68213 -79.3832
117 relay.beamhop.com nostr.snowbla.de:443 43.6532 50.4754 -79.3832 12.3683
relay.paulstephenborile.com 49.4543 11.0746
nostr.easycryptosend.it 43.6532 -79.3832
nostr-verified.wellorder.net 45.5201 -122.99
nostr.hekster.org:443 37.3986 -121.964
relay.samt.st 40.8302 -74.1299
nostr.islandarea.net:443 35.4669 -97.6473
nostr-01.uid.ovh 50.9871 2.12554
relay-dev.gulugulu.moe 43.6532 -79.3832
relay.44billion.net 43.6532 -79.3832
dev-relay.nostreon.com 60.1699 24.9384
nostr.pbfs.io 50.4754 12.3683
nrs-01.darkcloudarcade.com 39.0997 -94.5786
relay01.lnfi.network 35.6764 139.65
relay.mostr.pub 43.6532 -79.3832
strfry.apps3.slidestr.net 40.4167 -3.70329
nostr.linky.fit 50.1109 8.68213
nostr.davenov.com 50.1109 8.68213
relay.noeudlibre.fr 50.6924 3.20113
118 hasky.chat 40.7862 -74.0743
119 relay.artio.inf.unibe.ch relay.plebeian.market 46.9501 50.1109 7.43678 8.68213
120 relay.manneken.brussels relay.zone667.com 49.4543 60.1699 11.0746 24.9384
121 relay.veganostr.com cs-relay.nostrdev.com:443 60.1699 50.4754 24.9384 12.3683
122 relay.nexterz.com relay.staging.plebeian.market:443 43.6532 51.5072 -79.3832 -0.127586
123 nostr.snowbla.de relay.openresist.com 50.4754 43.6532 12.3683 -79.3832
124 nostr.unkn0wn.world nostr.spaceshell.xyz 46.8499 43.6532 9.53287 -79.3832
125 nostr.wild-vibes.ts.net vault.iris.to:443 48.8566 43.6532 2.35222 -79.3832
126 nostr.easycryptosend.it 43.6532 -79.3832
127 nostr.bitcoiner.social:443 47.6743 -117.112
128 relay.fiatdenier.com 43.7787 -79.5393
129 relay.gulugulu.moe:443 43.6532 -79.3832
130 relay.mitosystem.net -23.5558 -46.6396
131 relay.mostro.network 40.8302 -74.1299
132 relay1.nostrchat.io 60.1699 24.9384
133 nostr.tabordalab.com 60.1699 24.9384
134 relay.gulugulu.moe 43.6532 -79.3832
135 pocketrelay.live 40.8302 -74.1299
136 relay.nostrfeed.com 60.1699 24.9384
137 testr.nymble.world 40.8054 -74.0241
138 x.kojira.io 43.6532 -79.3832
139 relay.sigit.io 50.4754 12.3683
140 nostrcity-club.fly.dev:443 38.7946 -106.535
141 relay.wisp.talk:443 49.4543 11.0746
142 relay-testnet.k8s.layer3.news 37.3387 -121.885
143 nostr-dev.wellorder.net 45.5201 -122.99
144 nostr.pbfs.io:443 50.4754 12.3683
145 nostr.takasaki.dev 43.6532 -79.3832
146 relay.snort.social 53.3498 -6.26031
147 articles.layer3.news 37.3387 -121.885
148 staging.yabu.me 35.6092 139.73
149 nostr.rtvslawenia.com:443 49.4543 11.0746
150 nostr-relay.xbytez.io:443 50.6924 3.20113
151 public.crostr.com:443 43.6532 -79.3832
152 nostr.ltd 19.076 72.8777
153 nostr-02.uid.ovh 50.9871 2.12554
154 nostr.islandarea.net:443 35.4669 -97.6473
155 relay.kilombino.com 43.6532 -79.3832
156 relay.satlantis.io 39.0438 -77.4874
157 nostr.computingcache.com 45.5341 -122.956
158 nostr.wecsats.io 43.6532 -79.3832
159 nostr-relay.zimage.com 34.282 -118.439
160 nostr.tbxnetworx.de 47.8226 10.0064
161 relay-can.zombi.cloudrodion.com 43.6532 -79.3832
162 relay.klabo.world 47.674 -122.122
163 auth.nostr1.com 40.7057 -74.0136
164 relay.degmods.com 50.4754 12.3683
165 relay.olas.app 60.1699 24.9384
166 fanfares.nostr1.com 40.7057 -74.0136
167 node.kommonzenze.de 49.4521 11.0767
168 relay.bitos.space 43.6532 -79.3832
169 relay-na1.metanomalist.com relay01.lnfi.network 43.6532 35.6764 -79.3832 139.65
170 relay.favillakey.io relay.froth.zone -27.4705 60.1699 153.026 24.9384
171 relay.nearhood.co.uk denimroad.feeds.relay.tools 51.5134 38.6327 -0.0890675 -90.1961
172 relay.illuminodes.com nostr.myshosholoza.co.za 43.6532 52.3676 -79.3832 4.90414
173 nostr.dlcdevkit.com 21milionidinostr.duckdns.org 40.0992 41.8945 -83.1141 12.6493
174 relay.nostr.net relay.getvia.xyz 43.6532 60.1699 -79.3832 24.9384
175 relay.trotters.cc:443 ribo.us.nostria.app 43.6532 -79.3832
176 cyberspace.nostr1.com relay.nostrhub.fr 40.7057 48.1045 -74.0136 11.6004
177 nostr-relay.nextblockvending.com talon.quest 47.2343 43.6532 -119.853 -79.3832
178 portal-relay.pareto.space 49.4521 11.0767
179 relayone.geektank.ai 39.0997 -94.5786
180 relay.orangesync.tech 40.7128 -74.006
181 strfry.apps3.slidestr.net 40.4167 -3.70329
182 relay.mleku.dev 32.7767 -96.797
183 relay.nostrian-conquest.com 41.223 -111.974
184 relay.underorion.se 50.1109 8.68213
185 vault.iris.to 43.6532 -79.3832
186 schnorr.me 43.6532 -79.3832
187 relay.bullishbounty.com:443 43.6532 -79.3832
188 relay.i9.eti.br 43.6532 -79.3832
189 nostr-pub.wellorder.net 45.5201 -122.99
190 relay.cyberguy.fyi 52.6907 4.8181
191 relay.mappingbitcoin.com 43.6532 -79.3832
192 nostr-relay.acloud.kdns.fr 43.6532 -79.3832
193 nostr.2b9t.xyz 34.0549 -118.243
194 nostr.2b9t.xyz:443 34.0549 -118.243
195 dev-relay.nostreon.com 60.1699 24.9384
196 nostr.purpura.cloud 43.6532 -79.3832
197 nos.lol:443 50.4754 12.3683
198 nostr-relay.corb.net 39.6478 -104.988
199 relay.scuba323.com 40.8218 -74.45
200 relay.agora.social 50.7383 15.0648
201 nostr.data.haus 50.4754 12.3683
202 relay.satsapp.me 50.4754 12.3683
203 nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
204 nostr.hekster.org:443 37.3986 -121.964
205 relay.btcforplebs.com 43.6532 -79.3832
206 nostr.azzamo.net:443 relay-arg.zombi.cloudrodion.com 52.2633 1.35208 21.0283 103.82
207 relay.tdw.gg nostr.wild-vibes.ts.net 34.0549 48.8566 -118.243 2.35222
208 relay.veganostr.com:443 relay.bornheimer.app 60.1699 50.1109 24.9384 8.68213
209 wisp.djorivaltech.com relay02.lnfi.network 43.6532 35.6764 -79.3832 139.65
relay.cosmicbolt.net 37.3986 -121.964
nostr.debate.report 50.1109 8.68213
nostr.na.social 43.6532 -79.3832
210 relay.kaleidoswap.com 50.8476 4.35717
211 strfry.shock.network 39.0438 -77.4874
212 nostr.txlyre.website 60.1699 24.9384
213 nostr.twinkle.lol 51.902 7.6657
214 relay.satmaxt.xyz 43.6532 -79.3832
215 relay.vrtmrz.net:443 43.6532 -79.3832
216 aurum.saturnali.net 46.2044 6.14316
217 nostrrelay.taylorperron.com 45.5029 -73.5723
218 nostrelay.circum.space 52.2245 8.826
219 relay.mostr.pub:443 43.6532 -79.3832
220 nostr.data.haus:443 50.4754 12.3683
221 relay.nostreon.com 60.1699 24.9384
222 relay.plebeian.market:443 50.1109 8.68213
223 relay.damus.io 43.6532 -79.3832
224 budabit.nostr1.com 40.7057 -74.0136
225 bucket.coracle.social 37.7775 -122.397
226 relay.chorus.community:443 50.7529 6.84585
227 relay.hivetalk.org 40.8302 -74.1299
228 relay.favillakey.io -27.4705 153.026
229 nostr.mom 50.4754 12.3683
230 relay2.orangesync.tech 40.7128 -74.006
231 relay.artiostr.ch 43.6532 -79.3832
232 relay.lacrypta.ar 43.6532 -79.3832
233 treuzkas.branruz.com 48.8575 2.35138
234 relay2.veganostr.com 60.1699 24.9384
235 borgar.lol 52.3676 4.90414
236 relay.ltgnet.work:8443 51.0511 -114.075
237 relay.nostrmap.net 60.1699 24.9384
238 nostr.mom:443 50.4754 12.3683
239 relay.decentralia.fr 48.122 11.589
240 relay.atsocy.com 43.6532 -79.3832
241 strfry.shock.network:443 39.0438 -77.4874
242 relay.vrtmrz.net 43.6532 -79.3832
243 0x-nostr-relay.fly.dev 38.7946 -106.535
244 relay.veganostr.com 60.1699 24.9384
245 relay.nostr.bond 40.1885 29.061
246 relay.angor.io 48.1046 11.6002
247 nostr.sathoarder.com:443 48.5734 7.75211
248 nostr.oxtr.dev 50.4754 12.3683
249 dev.relay.stream 43.6532 -79.3832
250 relay.mitchelltribe.com 39.0438 -77.4874
251 nostr-relay.cbrx.io 43.6532 -79.3832
252 nostr2.girino.org:443 43.6532 -79.3832
253 relay.snotr.nl:49999 51.9758 4.31389
254 n.musicroadmap.com 35.694 139.754
255 relay.loveisbitcoin.com 43.6532 -79.3832
256 relay.conduit.market 38.7946 -106.535
257 schnorr.me:443 43.6532 -79.3832
258 nostr-kyomu-haskell.onrender.com 37.7775 -122.397
259 nostr.dlcdevkit.com 40.0992 -83.1141
260 relay2.angor.io 48.1046 11.6002
261 nostr.hifish.org 47.4009 8.57814
262 rele.speyhard.fi 51.5072 -0.127586
263 nostr.chaima.info:443 51.5072 -0.127586
264 relay.trotters.cc:443 43.6532 -79.3832
265 nostrmxn.lulus.com.mx 59.4016 17.9455
266 chat-relay.zap-work.com:443 43.6532 -79.3832
267 relay.tdw.gg 34.0549 -118.243
268 relay.ohstr.com 43.6532 -79.3832
269 relay.guggero.org 46.5971 9.59652
270 nostr.stakey.net:443 52.3676 4.90414
271 dev-relay.nostr.space 43.6532 -79.3832
272 nostr.snowbla.de 50.4754 12.3683
273 relay.piazza.today 48.122 11.589
274 relay.fountain.fm:443 43.6532 -79.3832
275 relay-dev.gulugulu.moe 43.6532 -79.3832
276 relay.ditto.pub:443 43.6532 -79.3832
277 vm-1734.lnvps.cloud 53.3498 -6.26031
278 relay.beginningend.com 35.2227 -97.4786
279 nos.lol 50.4754 12.3683
280 nostr-01.yakihonne.com:443 1.32123 103.695
281 relay.sigit.io:443 50.4754 12.3683
282 nostr.janx.com 43.6532 -79.3832
283 relay.wavlake.com 41.2619 -95.8608
284 relayrs.notoshi.win:443 43.6532 -79.3832
285 openrelay.ziomc.com 45.4642 9.18998
286 nostr.88mph.life 52.1941 -2.21905
287 maxq.descendant.io 43.6532 -79.3832
288 nostr.wecsats.io:443 43.6532 -79.3832
289 relay.bitmacro.cloud 43.6532 -79.3832
290 relay.lightning.pub 39.0438 -77.4874
291 strfry.bonsai.com 39.0438 -77.4874
292 chat-relay.zap-work.com 43.6532 -79.3832
293 relay.illuminodes.com 43.6532 -79.3832
294 nostr.thebiglake.org 32.71 -96.6745
295 ribo.eu.nostria.app 43.6532 -79.3832
296 relay.novospes.com 43.6532 -79.3832
297 nostr.notribe.net 40.8302 -74.1299
298 nostr-relay.corb.net:443 39.6478 -104.988
299 relay.pprgb.app 52.52 13.405
300 relay.wellorder.net 45.5201 -122.99
301 relay.mitchelltribe.com:443 39.0438 -77.4874
302 cyberspace.nostr1.com 40.7057 -74.0136
303 bruh.samt.st 43.6532 -79.3832
304 nostr.davenov.com 50.1109 8.68213
305 nostr.sovereignservices.xyz 43.6532 -79.3832
306 relay-dev.gulugulu.moe:443 43.6532 -79.3832
307 nostr.unkn0wn.world 46.8499 9.53287
308 relay.nostr.space 43.6532 -79.3832
309 infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
310 relay.ezernode.net 52.6921 6.19372
311 no.str.cr 8.96171 -83.5246
312 spamspamspamspam.rest 43.6532 -79.3832
313 relay.ru.ac.th 13.7607 100.627
314 relay.tdw.lol 41.8781 -87.6298
315 buzz.cashu.space 50.1109 8.68213
316 relay.duck1123.com 43.6532 -79.3832
317 relay.tapestry.ninja 40.8054 -74.0241
318 relay.layer.systems 49.0291 8.35695
319 relay.dyne.org 49.0291 8.35705
320 relay.aidatanorge.no 43.6532 -79.3832
321 relay.opmaat.org 60.1699 24.9384
322 nostr.azzamo.net 52.2633 21.0283
323 relay.mccormick.cx 52.3563 4.95714
324 relay.nostr.com 50.1109 8.68213
325 nostr.plantroon.com 50.1013 8.62643
326 support.flotilla.social 32.9483 -96.7299
327 relay.veganostr.com:443 60.1699 24.9384
328 relay.notoshi.win 12.9333 100.883
329 relay.edufeed.org 49.4521 11.0767
330 memlay.v0l.io 53.3498 -6.26031
331 bitcoiner.social 47.6743 -117.112
332 relay.manneken.brussels 49.4543 11.0746
333 nosflare-leefcore.leefcore.workers.dev 43.6532 -79.3832
334 no.str.cr:443 8.96171 -83.5246
335 relay.cosmicbolt.net 37.3986 -121.964
336 relay.getsafebox.app:443 43.6532 -79.3832
337 relay.aarpia.com 37.3986 -121.964
338 relay.satsapp.me nostr.sathoarder.com 50.4754 48.5734 12.3683 7.75211
339 relay.decentralia.fr relay.getsafebox.app 48.122 43.6532 11.589 -79.3832
340 relay.beginningend.com nostr.chaima.info 35.2227 51.5072 -97.4786 -0.127586
341 relay.ditto.pub 43.6532 -79.3832
342 nostr.4rs.nl 49.0291 8.35696
343 rilo.nostria.app 43.6532 -79.3832
344 nostr.christiansass.de 51.7883 6.13865
345 relay.paulstephenborile.com:443 49.4543 11.0746
346 relay.satmaxt.xyz relay.earthly.city 43.6532 34.1749 -79.3832 -118.54
347 nostr.oxtr.dev:443 inbox.scuba323.com 50.4754 40.8218 12.3683 -74.45
348 vault.iris.to nostr-01.yakihonne.com 43.6532 1.32123 -79.3832 103.695
349 relay.ditto.pub:443 articles.layer3.news:443 43.6532 37.3387 -79.3832 -121.885
relay.atsocy.com 43.6532 -79.3832
relay.vrtmrz.net 43.6532 -79.3832
soloco.nl 43.6532 -79.3832
nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
relayone.soundhsa.com:443 39.0997 -94.5786
nostr.wawasoft.net 43.6532 -79.3832
relay.solife.me 43.6532 -79.3832
nostr.mom 50.4754 12.3683
relay.getvia.xyz 60.1699 24.9384
freelay.sovbit.host 60.1699 24.9384
relay-rpi.edufeed.org:443 49.4521 11.0767
relay.bullishbounty.com:443 43.6532 -79.3832
schnorr.me:443 43.6532 -79.3832
temp.iris.to 43.6532 -79.3832
nostr-relay.cbrx.io 43.6532 -79.3832
strfry.bonsai.com:443 39.0438 -77.4874
nostr.hoppe-relay.it.com 42.8864 -78.8784
dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
relay.nostriches.club 43.6532 -79.3832
relay.dyne.org 49.0291 8.35705
yabu.me 35.6092 139.73
relay.chorus.community:443 48.5333 10.7
cs-relay.nostrdev.com:443 50.4754 12.3683
relay.mwaters.net 50.9871 2.12554
no.str.cr 8.96171 -83.5246
nostr.hifish.org 47.4009 8.57814
relay.directsponsor.net 42.8864 -78.8784
public.obelisk.ar 43.6532 -79.3832
nostr.carroarmato0.be 50.914 3.21378
nostr.sovereignservices.xyz 43.6532 -79.3832
nostr.pbfs.io:443 50.4754 12.3683
relay.degmods.com 50.4754 12.3683
relay.plebeian.market 50.1109 8.68213
350 offchain.pub 39.1585 -94.5728
351 nostr.thebiglake.org strfry.bonsai.com:443 32.71 39.0438 -96.6745 -77.4874
352 bendernostur.duckdns.org:8443 relay.nostriot.com 50.1109 41.5695 8.68213 -83.9786
353 nostr-relay.zimage.com relay.bullishbounty.com 34.282 43.6532 -118.439 -79.3832
354 nostr.ac dm-test-strfry-generic.samt.st 38.958 43.6532 -77.3592 -79.3832
355 relay.nostr.com relay.lanacoin-eternity.com:443 50.1109 40.8302 8.68213 -74.1299
356 bitcoiner.social dm-test-strfry-discovery.samt.st 47.6743 43.6532 -117.112 -79.3832
357 nostr.wecsats.io nostr.emanuelemiani.it 43.6532 45.778 -79.3832 8.79414
358 relay.pprgb.app reraw.pbla2fish.cc 52.52 43.6532 13.405 -79.3832
359 nostr.girino.org relay.s-w.art 43.6532 -79.3832
360 nostr.bitcoiner.social relay.nostrcheck.me 47.6743 43.6532 -117.112 -79.3832
361 nostr.planix.org relay.npubhaus.com 43.6532 -79.3832
362 nostr.data.haus:443 nostr.vulpem.com 50.4754 49.4543 12.3683 11.0746
363 relay.wasabiwallet.io 43.6532 -79.3832
364 nostr.liberty.fans 36.8767 -89.5879
365 relay.paulstephenborile.com 49.4543 11.0746
366 relay.nostrmap.net:443 60.1699 24.9384
367 relay.arx-ccn.com tribune-panel-growing-noon.trycloudflare.com 50.4754 43.6532 12.3683 -79.3832
368 relay.agorist.space:443 relay-na1.metanomalist.com 52.3734 43.6532 4.89406 -79.3832
369 relay.agentry.com 42.8864 -78.8784
370 relay.samt.st 40.8302 -74.1299
371 relay.nostx.io 43.6532 -79.3832
372 relay.cosmicbolt.net:443 37.3986 -121.964
373 relayrs.notoshi.win 43.6532 -79.3832
374 adre.su 59.9311 30.3609
375 nostrcheck.me 43.6532 -79.3832
376 relay-us.zombi.cloudrodion.com 40.7862 -74.0743
377 nrs-01.darkcloudarcade.com 39.0997 -94.5786
378 relay.nmail.li 50.9871 2.12554
379 relay.wisp.talk 49.4543 11.0746
380 relay.mostr.pub 43.6532 -79.3832
381 relay.jmoose.rocks 32.8009 -96.8195
382 relay.laantungir.net -19.4692 -42.5315
relay.edufeed.org:443 49.4521 11.0767
nostrcity-club.fly.dev:443 38.7946 -106.535
383 nostr.nodesmap.com 59.3327 18.0656
384 freelay.sovbit.host 60.1699 24.9384
385 soloco.nl 43.6532 -79.3832
386 ribo.nostria.app 43.6532 -79.3832
387 relay.chatbett.de 40.7128 -74.006
388 groups.beginningend.com 35.2227 -97.4786
389 relay.chorus.community 50.7529 6.84585
390 wisp.djorivaltech.com 43.6532 -79.3832
391 nostr-02.yakihonne.com 1.32123 103.695

View File

@ -3,6 +3,7 @@ package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.sync.PacketIdUtil
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.AuthenticatedNoiseSession
@ -238,21 +239,9 @@ class SecurityManager(private val encryptionService: EncryptionService, private
return encryptionService.getCombinedPublicKeyData()
}
/**
* Generate message ID for duplicate detection
*/
/** Deduplicates by peer and a hash of packet type, sender, timestamp, and full payload. */
private fun generateMessageID(packet: BitchatPacket, peerID: String): String {
return when (MessageType.fromValue(packet.type)) {
MessageType.FRAGMENT -> {
// For fragments, include the payload hash to distinguish different fragments
"${packet.timestamp}-$peerID-${packet.type}-${packet.payload.contentHashCode()}"
}
else -> {
// For other messages, use a truncated payload hash
val payloadHash = packet.payload.sliceArray(0 until minOf(64, packet.payload.size)).contentHashCode()
"${packet.timestamp}-$peerID-$payloadHash"
}
}
return "$peerID-${PacketIdUtil.computeIdHex(packet)}"
}
/**

View File

@ -21,7 +21,9 @@ import java.math.BigInteger
* Includes secp256k1 operations, ECDH, and NIP-44 encryption
*/
object NostrCrypto {
internal const val NIP17_DEFAULT_MAX_PAST_SECONDS = 79_200
private val secureRandom = SecureRandom()
// NIP-44 v2 only
@ -317,9 +319,9 @@ object NostrCrypto {
}
/**
* Random timestamp up to maxPastSeconds in the past (default 2 days)
* Random timestamp in the past, defaulting to 22 hours to leave 2 hours of slack inside iOS's 24-hour lookback.
*/
fun randomizeTimestampUpToPast(maxPastSeconds: Int = 172800): Int {
fun randomizeTimestampUpToPast(maxPastSeconds: Int = NIP17_DEFAULT_MAX_PAST_SECONDS): Int {
val now = (System.currentTimeMillis() / 1000).toInt()
val offset = if (maxPastSeconds > 0) secureRandom.nextInt(maxPastSeconds + 1) else 0
return now - offset

View File

@ -79,9 +79,9 @@ class NostrEventDeduplicator(
* @return true if the event is a duplicate (already seen), false if it's new
*/
fun isDuplicate(eventId: String): Boolean {
totalChecks++
synchronized(lruLock) {
totalChecks++
val existingNode = nodeMap[eventId]
if (existingNode != null) {

View File

@ -37,7 +37,7 @@ object NostrProtocol {
val rumorId = rumorBase.computeEventIdHex()
val rumor = rumorBase.copy(id = rumorId)
// 2. Seal the rumor (kind 13) signed by sender, timestamp randomized up to 2 days
// 2. Seal the rumor with 2 hours of slack inside iOS's 24-hour lookback.
val sealedEvent = createSeal(
rumor = rumor,
recipientPubkey = recipientPubkey,

View File

@ -14,8 +14,6 @@ import okhttp3.*
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.min
import kotlin.math.pow
/**
* Manages WebSocket connections to Nostr relays
@ -48,12 +46,8 @@ class NostrRelayManager private constructor() {
"wss://nostr21.com"
)
// Exponential backoff configuration (same as iOS)
private const val INITIAL_BACKOFF_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.INITIAL_BACKOFF_INTERVAL_MS // 1 second
private const val MAX_BACKOFF_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS // 5 minutes
private const val BACKOFF_MULTIPLIER = com.bitchat.android.util.AppConstants.Nostr.BACKOFF_MULTIPLIER
private const val MAX_RECONNECT_ATTEMPTS = com.bitchat.android.util.AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS
// Reconnect backoff lives in RelayReconnectPolicy.
// Track gift-wraps we initiated for logging
private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet<String>()
@ -1018,36 +1012,15 @@ class NostrRelayManager private constructor() {
if (!desiredConnected.get() ||
!isNetworkActionAllowed(connectionToken)
) return
// Check if this is a DNS error
val errorMessage = error.message?.lowercase() ?: ""
if (errorMessage.contains("hostname could not be found") ||
errorMessage.contains("dns") ||
errorMessage.contains("unable to resolve host")) {
val relay = relaysList.find { it.url == relayUrl }
if (relay?.lastError == null) {
Log.w(TAG, "Nostr relay DNS failure; not retrying")
}
return
}
// Implement exponential backoff for non-DNS errors
// Every failure backs off and retries, including a name-resolution
// failure: "unable to resolve host" is what this device reports when it
// simply has no network, so treating it as permanent turns a walk
// through a tunnel into a dead relay layer for the rest of the process.
val relay = relaysList.find { it.url == relayUrl } ?: return
relay.reconnectAttempts++
// Stop attempting after max attempts
if (relay.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
Log.w(TAG, "Max Nostr relay reconnection attempts reached")
return
}
// Calculate backoff interval
val backoffInterval = min(
INITIAL_BACKOFF_INTERVAL * BACKOFF_MULTIPLIER.pow(relay.reconnectAttempts - 1.0),
MAX_BACKOFF_INTERVAL.toDouble()
).toLong()
relay.reconnectAttempts = RelayReconnectPolicy.nextAttempt(relay.reconnectAttempts)
val backoffInterval = RelayReconnectPolicy.backoffMs(relay.reconnectAttempts)
relay.nextReconnectTime = System.currentTimeMillis() + backoffInterval
Log.d(TAG, "Scheduling Nostr relay reconnection")

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

@ -598,4 +598,65 @@ class SecurityManagerTest {
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// Duplicate-detection identity.
/**
* Two distinct packets that agree on their first 64 bytes and share a
* timestamp. The old key hashed only that prefix, with a 32-bit
* `contentHashCode`, so these were "the same packet" and the second was
* silently dropped.
*/
private fun prefixSharingPair(): Pair<BitchatPacket, BitchatPacket> {
val shared = ByteArray(64) { 0x7 }
val first = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = otherPeerID.hexToByteArrayForTest(),
recipientID = myPeerID.hexToByteArrayForTest(),
timestamp = 1_700_000_000_000uL,
payload = shared + byteArrayOf(0x01, 0x02, 0x03),
ttl = 7u
)
val second = first.copy(payload = shared + byteArrayOf(0x0A, 0x0B, 0x0C))
return first to second
}
@Test
fun `packets differing only past the first 64 bytes are not treated as duplicates`() {
val (first, second) = prefixSharingPair()
assertTrue(securityManager.validatePacket(first, otherPeerID))
assertTrue(
"A distinct packet must not be dropped as a duplicate",
securityManager.validatePacket(second, otherPeerID)
)
}
@Test
fun `a genuine replay of the same packet is still rejected`() {
// The other half: strengthening the identity must not weaken replay
// protection, which is the reason this cache exists.
val (first, _) = prefixSharingPair()
assertTrue(securityManager.validatePacket(first, otherPeerID))
assertFalse(
"The identical packet must still be caught",
securityManager.validatePacket(first, otherPeerID)
)
}
@Test
fun `the same packet from two different peers is tracked separately`() {
// Peer scoping is deliberately kept: PacketIdUtil covers the packet's
// own senderID, which is not the peer it was received from once a
// packet has been relayed.
val (first, _) = prefixSharingPair()
assertTrue(securityManager.validatePacket(first, otherPeerID))
assertTrue(securityManager.validatePacket(first, unknownPeerID))
}
private fun String.hexToByteArrayForTest(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}

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

@ -3,6 +3,7 @@ package com.bitchat.android.nostr
import com.google.gson.Gson
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NostrProtocolTest {
@ -41,6 +42,52 @@ class NostrProtocolTest {
assertNull(decrypted)
}
@Test
fun createPrivateMessage_reservesSlackInsideIosLookback() {
val sender = NostrIdentity.generate()
val recipient = NostrIdentity.generate()
assertEquals(
IOS_DM_LOOKBACK_SECONDS - TIMESTAMP_SAFETY_SLACK_SECONDS,
NostrCrypto.NIP17_DEFAULT_MAX_PAST_SECONDS
)
repeat(20) {
val beforeCreation = (System.currentTimeMillis() / 1000).toInt()
val giftWrap = NostrProtocol.createPrivateMessage(
content = "bitchat1:test",
recipientPubkey = recipient.publicKeyHex,
senderIdentity = sender
).single()
val afterCreation = (System.currentTimeMillis() / 1000).toInt()
val sealJson = NostrCrypto.decryptNIP44(
ciphertext = giftWrap.content,
senderPublicKeyHex = giftWrap.pubkey,
recipientPrivateKeyHex = recipient.privateKeyHex
)
val seal = gson.fromJson(sealJson, NostrEvent::class.java)
assertTimestampWithinIosLookback("gift wrap", giftWrap.createdAt, beforeCreation, afterCreation)
assertTimestampWithinIosLookback("seal", seal.createdAt, beforeCreation, afterCreation)
}
}
private fun assertTimestampWithinIosLookback(
envelope: String,
createdAt: Int,
beforeCreation: Int,
afterCreation: Int
) {
assertTrue(
"$envelope timestamp must leave 2 hours inside the iOS lookback",
createdAt >= beforeCreation - MAX_OUTBOUND_BACKDATE_SECONDS
)
assertTrue(
"$envelope timestamp must not be in the future",
createdAt <= afterCreation
)
}
private fun forgedGiftWrap(
content: String,
claimedSender: NostrIdentity,
@ -82,4 +129,11 @@ class NostrProtocolTest {
content = giftWrapContent
).sign(wrapPrivateKey)
}
private companion object {
const val IOS_DM_LOOKBACK_SECONDS = 86_400
const val TIMESTAMP_SAFETY_SLACK_SECONDS = 7_200
const val MAX_OUTBOUND_BACKDATE_SECONDS =
IOS_DM_LOOKBACK_SECONDS - TIMESTAMP_SAFETY_SLACK_SECONDS
}
}

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

@ -17,7 +17,7 @@ The remaining implementation work and milestone progress are tracked in
| Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, live-voice bursts, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `VoiceBurstPacketTest`, `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` |
| 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
@ -31,6 +31,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

@ -19,8 +19,8 @@ android {
targetSdk = libs.versions.targetSdk.get().toInt()
// Wear releases use a separate high range because Play requires every artifact in
// one application ID to have a unique version code across all form factors.
versionCode = 1_000_000_003
versionName = "0.1.2"
versionCode = 1_000_000_004
versionName = "0.1.3"
vectorDrawables {
useSupportLibrary = true

View File

@ -18,9 +18,6 @@ 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.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -30,7 +27,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -39,7 +35,7 @@ import androidx.core.content.ContextCompat
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Text
import androidx.wear.compose.material3.TextButton
import androidx.wear.compose.material3.OutlinedButton
import com.bitchat.watch.mesh.WearMeshService
import com.bitchat.watch.notification.WearNotificationCoordinator
import com.bitchat.watch.service.WearMeshForegroundService
@ -50,6 +46,7 @@ import com.bitchat.watch.ui.PeopleScreen
import com.bitchat.watch.ui.UserDetailScreen
import com.bitchat.watch.ui.VerificationCodeScreen
import com.bitchat.watch.ui.WearChatState
import com.bitchat.watch.ui.WearFormScreen
import com.bitchat.watch.ui.sendPrivateMessage
import com.bitchat.watch.ui.sendPublicMessage
import com.bitchat.watch.ui.theme.BitchatWearTheme
@ -460,104 +457,110 @@ internal fun WearNavHost(
@Composable
fun NotificationPermissionScreen(onResult: (Boolean) -> Unit, onSkip: () -> Unit) {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> onResult(granted) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Message alerts",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Alerts for encrypted direct messages",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 10.dp)
)
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
onResult(true)
}
}
) {
Text("Enable")
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
onResult(granted)
}
TextButton(onClick = onSkip) {
Text("Not now")
WearFormScreen {
item {
Text(
text = "Message alerts",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
item {
Text(
text = "Alerts for encrypted direct messages",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 10.dp),
)
}
item {
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
onResult(true)
}
}
) {
Text("Enable")
}
}
item {
OutlinedButton(onClick = onSkip) {
Text("Not now")
}
}
}
}
@Composable
fun PermissionRequestScreen(onGranted: () -> Unit) {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { onGranted() }
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
onGranted()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "bitchat",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Needs Bluetooth to mesh with nearby devices",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 12.dp)
)
Button(onClick = {
launcher.launch(MainActivity.requiredPermissions().toTypedArray())
}) {
Text("Grant access")
WearFormScreen {
item {
Text(
text = "bitchat",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
item {
Text(
text = "Needs Bluetooth to mesh with nearby devices",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 12.dp),
)
}
item {
Button(
onClick = {
launcher.launch(MainActivity.requiredPermissions().toTypedArray())
}
) {
Text("Grant access", textAlign = TextAlign.Center)
}
}
}
}
@Composable
fun BluetoothEnableScreen(onEnabled: () -> Unit) {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { onEnabled() }
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
onEnabled()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Bluetooth is off",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
Button(
onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) },
modifier = Modifier.padding(top = 10.dp)
) {
Text("Turn on")
WearFormScreen {
item {
Text(
text = "Bluetooth is off",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
}
item {
Button(
onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) },
modifier = Modifier.padding(top = 10.dp),
) {
Text("Turn on")
}
}
}
}

View File

@ -13,6 +13,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@ -36,6 +37,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@ -74,7 +76,7 @@ fun ChatActionBar(
) {
Box(
modifier = Modifier
.size(38.dp)
.size(48.dp)
.clip(CircleShape)
.background(palette.inputButton)
.clickable { onKeyboard() },
@ -89,7 +91,7 @@ fun ChatActionBar(
}
Box(
modifier = Modifier
.size(38.dp)
.size(48.dp)
.clip(CircleShape)
.background(
when {
@ -150,7 +152,7 @@ fun VoiceRecordOverlay(
hoveringCancel: Boolean,
proximity: Float,
magnetPull: Offset,
onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit
onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit,
) {
val palette = LocalBitchatPalette.current
// The cancel morph, choreographed for feel:
@ -159,101 +161,127 @@ fun VoiceRecordOverlay(
// - the button leans toward the approaching finger (magnetic pull), chasing it with a
// smooth spring so it lags and settles naturally
// - scale blooms with a soft bounce on activation — no rotation, no wobble
val cancelScale by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity,
animationSpec = androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
),
label = "cancelSnap"
)
val pull by androidx.compose.animation.core.animateOffsetAsState(
targetValue = magnetPull,
animationSpec = androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
),
label = "magnetPull"
)
val cancelColor = androidx.compose.ui.graphics.lerp(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.error,
if (hoveringCancel) 1f else proximity * 0.85f
)
val cancelScale by
androidx.compose.animation.core.animateFloatAsState(
targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity,
animationSpec =
androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium,
),
label = "cancelSnap",
)
val pull by
androidx.compose.animation.core.animateOffsetAsState(
targetValue = magnetPull,
animationSpec =
androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium,
),
label = "magnetPull",
)
val cancelColor =
androidx.compose.ui.graphics.lerp(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.error,
if (hoveringCancel) 1f else proximity * 0.85f,
)
AnimatedVisibility(
visible = voice.recording,
enter = fadeIn(tween(BitchatMotion.EMPHASIZED_MS)),
exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS))
exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS)),
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.96f))
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
BoxWithConstraints(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.onGloballyPositioned { coords ->
onCancelBounds(
androidx.compose.ui.geometry.Rect(
coords.localToRoot(androidx.compose.ui.geometry.Offset.Zero),
coords.size.toSize()
)
)
}
.size(52.dp)
.graphicsLayer {
translationX = pull.x
translationY = pull.y
scaleX = cancelScale
scaleY = cancelScale
}
.clip(CircleShape)
.background(cancelColor),
contentAlignment = Alignment.Center
) {
androidx.compose.animation.Crossfade(
targetState = hoveringCancel,
animationSpec = tween(BitchatMotion.STANDARD_MS),
label = "cancelIcon"
) { cancel ->
Icon(
imageVector = if (cancel) Icons.Filled.Close else Icons.Filled.Mic,
contentDescription = if (cancel) "cancel recording" else null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(26.dp)
)
val side =
if (LocalConfiguration.current.isScreenRound) {
roundContentSide(maxWidth.value, maxHeight.value).dp
} else {
minOf(maxWidth, maxHeight) - 24.dp
}
Column(
modifier = Modifier.size(side),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
// Text gets its natural height first. The decorative waveform and cancel icon
// share the remaining room instead of pushing instructions off the display.
BoxWithConstraints(
Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
val iconSize = minOf(52.dp, maxHeight / 1.32f, maxWidth / 1.32f)
Box(
modifier =
Modifier.onGloballyPositioned { coords ->
onCancelBounds(
androidx.compose.ui.geometry.Rect(
coords.localToRoot(
androidx.compose.ui.geometry.Offset.Zero
),
coords.size.toSize(),
)
)
}
.size(iconSize)
.graphicsLayer {
translationX = pull.x
translationY = pull.y
scaleX = cancelScale
scaleY = cancelScale
}
.clip(CircleShape)
.background(cancelColor),
contentAlignment = Alignment.Center,
) {
androidx.compose.animation.Crossfade(
targetState = hoveringCancel,
animationSpec = tween(BitchatMotion.STANDARD_MS),
label = "cancelIcon",
) { cancel ->
Icon(
imageVector = if (cancel) Icons.Filled.Close else Icons.Filled.Mic,
contentDescription = if (cancel) "cancel recording" else null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(iconSize / 2),
)
}
}
}
WaveformBars(
samples = voice.liveSamples,
progress = 1f,
activeColor = MaterialTheme.colorScheme.primary,
inactiveColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth().height(12.dp),
)
Text(
text =
(if (voice.isLive) "LIVE " else "") +
"%d:%02d"
.format(
voice.elapsedMs / 1000 / 60,
voice.elapsedMs / 1000 % 60,
) +
"/0:10",
style = ChatVisualTokens.SystemActionStyle,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = if (hoveringCancel) "Release to cancel" else "Lift finger to send",
style = ChatVisualTokens.SystemActionStyle,
color =
if (hoveringCancel) MaterialTheme.colorScheme.error
else palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
WaveformBars(
samples = voice.liveSamples,
progress = 1f,
activeColor = MaterialTheme.colorScheme.primary,
inactiveColor = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 16.dp)
.fillMaxWidth()
.height(44.dp)
)
Text(
text = (if (voice.isLive) "LIVE · " else "") + "%d:%02d".format(
voice.elapsedMs / 1000 / 60,
voice.elapsedMs / 1000 % 60
) + " / 0:10",
style = ChatVisualTokens.SenderStyle,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 10.dp)
)
Text(
text = if (hoveringCancel) "Release to cancel" else "Lift finger to send",
style = ChatVisualTokens.SystemActionStyle,
color = if (hoveringCancel) MaterialTheme.colorScheme.error
else palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}

View File

@ -28,14 +28,19 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState
import androidx.wear.compose.foundation.lazy.items
@ -50,13 +55,12 @@ import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlin.math.sign
/**
* The shared chat body for global chat and DM threads, following the classic messenger
* pattern: a TransformingLazyColumn message list (native Wear center-scaling/fade, rotary,
* scrollbar) with the header and action bar as floating overlays that get out of the way
* while scrolling up into history and return on any downward scroll; at the newest message
* while scrolling up into history and return on a deliberate reverse scroll; at the newest message
* they are always visible.
*
* The list's contentPadding is CONSTANT and both overlays are layout-neutral, so showing or
@ -92,33 +96,38 @@ fun ChatScaffold(
// Follow intent is changed only by an actual user scroll away from the newest item or by
// reaching the end again. A new item temporarily makes canScrollForward true before layout;
// treating that transient range change as user intent breaks automatic following.
var followNewest by remember { mutableStateOf(true) }
val controlsVisible = remember { mutableStateOf(true) }
var scrollIntent by remember { mutableStateOf(ChatScrollIntentState()) }
val density = LocalDensity.current
val scrollConnection = remember(columnState, density) {
object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
// Both Wear rotary and touch dispatch consumed movement here. Positive list
// movement is toward newer messages. Layout changes never enter this path.
scrollIntent = updatedChatScrollIntent(
current = scrollIntent,
deltaDp = -consumed.y / density.density,
isUserInput = source == NestedScrollSource.UserInput,
atNewest = !columnState.canScrollForward
)
return Offset.Zero
}
}
}
LaunchedEffect(columnState) {
var lastPosition = -1
var scrollIntent = ChatScrollIntentState()
snapshotFlow {
val first = columnState.layoutInfo.visibleItems.firstOrNull()
ChatScrollSnapshot(
canScrollForward = columnState.canScrollForward,
isScrollInProgress = columnState.isScrollInProgress,
position = (first?.index ?: 0) * 100_000 + (first?.offset ?: 0)
)
}.collect { snapshot ->
scrollIntent = updatedChatScrollIntent(
current = scrollIntent,
snapshot = snapshot,
previousPosition = lastPosition
)
followNewest = scrollIntent.followsNewest
controlsVisible.value = scrollIntent.controlsVisible
lastPosition = snapshot.position
!columnState.canScrollForward && !columnState.isScrollInProgress
}.collect { atNewest ->
if (atNewest) scrollIntent = ChatScrollIntentState()
}
}
// Stick to bottom when the user has not intentionally moved into history.
LaunchedEffect(columnState, messages.size) {
if (messages.isNotEmpty() && followNewest) {
if (messages.isNotEmpty() && scrollIntent.followsNewest) {
val expectedSingleMessageKey = messages.singleOrNull()?.id
scrollToNewestAfterItemsMeasured(
expectedItemCount = messages.size,
@ -137,7 +146,14 @@ fun ChatScaffold(
) {
// scrollBy to the end of the range: animateScrollToItem stops as soon as the
// item is partially visible, which left the last message cropped.
columnState.scroll { scrollBy(Float.MAX_VALUE) }
// Do not seize the list from an active drag/crown gesture, or follow an
// append whose measurement completed after the user entered history.
followNewestWhenIdle(
scrolling = snapshotFlow { columnState.isScrollInProgress },
shouldFollow = { scrollIntent.followsNewest }
) {
columnState.scroll { scrollBy(Float.MAX_VALUE) }
}
}
}
}
@ -149,10 +165,10 @@ fun ChatScaffold(
voice = voice,
onOpenImage = onOpenImage,
columnState = columnState,
controlsVisible = controlsVisible.value,
controlsVisible = scrollIntent.controlsVisible,
header = header,
actionBar = actionBar,
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize().nestedScroll(scrollConnection)
)
}
@ -161,47 +177,30 @@ internal data class MeasuredChatLayout(
val singleVisibleItemKey: Any?
)
internal data class ChatScrollSnapshot(
val canScrollForward: Boolean,
val isScrollInProgress: Boolean,
val position: Int
)
internal data class ChatScrollIntentState(
val followsNewest: Boolean = true,
val controlsVisible: Boolean = true,
val accumulatedDeltaPx: Int = 0
val reversalDp: Float = 0f
)
internal fun updatedChatScrollIntent(
current: ChatScrollIntentState,
snapshot: ChatScrollSnapshot,
previousPosition: Int
deltaDp: Float,
isUserInput: Boolean,
atNewest: Boolean
): ChatScrollIntentState {
if (!snapshot.canScrollForward) return ChatScrollIntentState()
if (!snapshot.isScrollInProgress || previousPosition < 0) return current
val delta = snapshot.position - previousPosition
val accumulatedDelta = when {
delta == 0 -> current.accumulatedDeltaPx
current.accumulatedDeltaPx == 0 ||
current.accumulatedDeltaPx.sign == delta.sign ->
current.accumulatedDeltaPx + delta
else -> delta
}
val movedAway = accumulatedDelta <= -CHAT_SCROLL_DIRECTION_THRESHOLD_PX
val movedTowardNewest = accumulatedDelta >= CHAT_SCROLL_DIRECTION_THRESHOLD_PX
if (atNewest) return ChatScrollIntentState()
if (!isUserInput || !deltaDp.isFinite() || deltaDp == 0f) return current
// Hysteresis measures net travel opposite the current controls state, not the sum of
// tiny back-and-forth movements. Keep it across discrete crown ticks and idle periods.
val reversal = (current.reversalDp + if (current.controlsVisible) -deltaDp else deltaDp)
.coerceAtLeast(0f)
val threshold = if (current.controlsVisible) 12f else 24f
val toggle = reversal >= threshold
return current.copy(
followsNewest = current.followsNewest && !movedAway,
controlsVisible = when {
movedAway -> false
movedTowardNewest -> true
else -> current.controlsVisible
},
// Keep sub-threshold movement across discrete rotary events. Once intent is clear,
// start a fresh accumulator so reversing direction gets the same threshold treatment.
accumulatedDeltaPx = if (movedAway || movedTowardNewest) 0 else accumulatedDelta
followsNewest = current.followsNewest && deltaDp >= 0f,
controlsVisible = if (toggle) !current.controlsVisible else current.controlsVisible,
reversalDp = if (toggle) 0f else reversal
)
}
@ -219,6 +218,15 @@ internal suspend fun scrollToNewestAfterItemsMeasured(
scrollToEnd()
}
internal suspend fun followNewestWhenIdle(
scrolling: Flow<Boolean>,
shouldFollow: () -> Boolean,
scrollToEnd: suspend () -> Unit
) {
scrolling.first { !it }
if (shouldFollow()) scrollToEnd()
}
@Composable
private fun ChatBody(
messages: List<BitchatMessage>,
@ -236,6 +244,9 @@ private fun ChatBody(
val context = LocalContext.current
val transformationSpec = rememberTransformationSpec()
val isScreenRound = LocalConfiguration.current.isScreenRound
val headerClearance = with(LocalDensity.current) {
maxOf(40.dp, 24.dp + (14f * 1.3f).sp.toDp() / 2 + 6.dp)
}
// Slide-to-cancel: while recording, the finger's position is tracked globally; the
// overlay's mic button reports its bounds and becomes the cancel target when the
// finger hovers it (with generous slack so the snap engages on approach).
@ -362,7 +373,7 @@ private fun ChatBody(
// duplicated padding and a shortened list viewport.
contentPadding = scaffoldPadding.withVerticalClearance(
layoutDirection = layoutDirection,
top = CHAT_HEADER_CONTENT_CLEARANCE,
top = headerClearance,
bottom = CHAT_ACTION_BAR_CLEARANCE
)
) {
@ -437,8 +448,6 @@ private fun ChatBody(
// Extra finger slack (px, ~28dp at watch density) around the cancel target so the snap
// engages as the finger approaches, not only on exact contact.
private const val CANCEL_HOVER_SLANT_PX = 56f
private const val CHAT_SCROLL_DIRECTION_THRESHOLD_PX = 24
private val CHAT_HEADER_CONTENT_CLEARANCE = 30.dp
private val CHAT_ACTION_BAR_CLEARANCE = 64.dp
private val CHAT_HEADER_EDGE_FADE = 36.dp
private val CHAT_ACTION_BAR_EDGE_FADE = 72.dp

View File

@ -1,9 +1,7 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@ -118,77 +116,79 @@ private fun ChatHeader(
peerCount: Int,
unreadDms: Int,
expanded: Boolean,
onOpenPeople: () -> Unit
onOpenPeople: () -> Unit,
) {
// Floating title row: full-size at the newest messages, shrinks to its dense form
// while scrolling up into history. Rendered as an overlay, so the animation only
// relayouts this row, never the message list.
val spec = androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val iconSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 16.dp else 11.dp, animationSpec = spec, label = "hdrIcon"
)
val titleSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "hdrTitle"
)
val vPadding by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "hdrPad"
)
val spec =
androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val iconSize by
androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 14.dp else 12.dp,
animationSpec = spec,
label = "hdrIcon",
)
val titleSize by
androidx.compose.animation.core.animateFloatAsState(
targetValue = if (expanded) 14f else 12f,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "hdrTitle",
)
// The entire header region opens the People screen. When there are unread DMs the
// title gives way so the people and mail icons (with counts) fit side by side on the
// round screen instead of clipping at the edges.
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenPeople() }
.padding(horizontal = 8.dp, vertical = vPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
WearChatHeader(
fontSize = titleSize,
onClickLabel = "Open people",
onClick = onOpenPeople,
) {
if (unreadDms == 0) {
Text(
text = "bitchat",
style = MaterialTheme.typography.titleSmall,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) { titleSize.toSp() },
fontSize = titleSize.sp,
lineHeight = (titleSize * 1.3f).sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp)
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false).padding(end = 2.dp),
)
}
Icon(
imageVector = Icons.Filled.People,
contentDescription = "people",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(iconSize)
modifier = Modifier.size(iconSize),
)
Text(
text = "$peerCount",
text = if (peerCount > 99) "99+" else "$peerCount",
style = MaterialTheme.typography.bodySmall,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
(iconSize.value * 0.85f).dp.toSp()
},
fontSize = 12.sp,
lineHeight = (titleSize * 1.3f).sp,
maxLines = 1,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 2.dp)
modifier = Modifier.padding(start = 2.dp),
)
if (unreadDms > 0) {
Icon(
imageVector = Icons.Filled.MailOutline,
contentDescription = "$unreadDms unread messages",
tint = LocalBitchatPalette.current.accentOrange,
modifier = Modifier
.padding(start = 6.dp)
.size(iconSize)
modifier = Modifier.padding(start = 6.dp).size(iconSize),
)
Text(
text = "$unreadDms",
text = if (unreadDms > 99) "99+" else "$unreadDms",
style = MaterialTheme.typography.bodySmall,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
(iconSize.value * 0.85f).dp.toSp()
},
fontSize = 12.sp,
lineHeight = (titleSize * 1.3f).sp,
maxLines = 1,
color = LocalBitchatPalette.current.accentOrange,
modifier = Modifier.padding(start = 2.dp)
modifier = Modifier.padding(start = 2.dp),
)
}
}
@ -240,8 +240,7 @@ fun MessageItem(
)
Text(
text = " ${formatTime(message.timestamp)}",
style = ChatVisualTokens.SystemActionStyle,
fontSize = 9.sp,
style = ChatVisualTokens.TimestampStyle,
color = palette.textTertiary
)
}

View File

@ -1,11 +1,8 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -20,7 +17,6 @@ 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.focus.focusRequester
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@ -32,6 +28,8 @@ import androidx.wear.compose.foundation.rotary.rotaryScrollable
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.text.style.TextOverflow
import androidx.compose.foundation.lazy.items
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Icon
@ -140,59 +138,58 @@ private fun DmHeader(
expanded: Boolean,
isFavorite: Boolean,
isVerified: Boolean,
onClick: () -> Unit
onClick: () -> Unit,
) {
val palette = LocalBitchatPalette.current
// Floating title row: full-size at the newest messages, shrinks to its dense form
// while scrolling up into history. Rendered as an overlay, so the animation only
// relayouts this row, never the message list.
val spec = androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val headerIconSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 16.dp else 11.dp, animationSpec = spec, label = "dmHdrIcon"
)
val headerTitleSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "dmHdrTitle"
)
val headerVPadding by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "dmHdrPad"
)
val spec =
androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val headerIconSize by
androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 14.dp else 12.dp,
animationSpec = spec,
label = "dmHdrIcon",
)
val headerTitleSize by
androidx.compose.animation.core.animateFloatAsState(
targetValue = if (expanded) 14f else 12f,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "dmHdrTitle",
)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
onClickLabel = "Open user details",
onClick = onClick
)
.padding(horizontal = 8.dp, vertical = headerVPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
WearChatHeader(
fontSize = headerTitleSize,
onClickLabel = "Open user details",
onClick = onClick,
) {
Text(
text = nickname,
style = MaterialTheme.typography.titleSmall,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
headerTitleSize.toSp()
},
fontSize = headerTitleSize.sp,
lineHeight = (headerTitleSize * 1.3f).sp,
fontWeight = FontWeight.Bold,
color = colorForPeer(nickname + peerID, palette)
color = colorForPeer(nickname + peerID, palette),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
NoiseLockIcon(
state = if (sessionEstablished) NoiseSessionUiState.Established
else NoiseSessionUiState.Handshaking,
state =
if (sessionEstablished) NoiseSessionUiState.Established
else NoiseSessionUiState.Handshaking,
size = headerIconSize,
modifier = Modifier.padding(start = 5.dp)
modifier = Modifier.padding(start = 5.dp),
)
if (isFavorite) {
Icon(
painter = painterResource(R.drawable.ic_spec_star_filled),
contentDescription = "Favorite",
tint = palette.accentOrange,
modifier = Modifier
.padding(start = 4.dp)
.size(headerIconSize)
modifier = Modifier.padding(start = 4.dp).size(headerIconSize),
)
}
if (isVerified) {
@ -200,9 +197,7 @@ private fun DmHeader(
imageVector = Icons.Filled.Verified,
contentDescription = "Verified",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(start = 4.dp)
.size(headerIconSize)
modifier = Modifier.padding(start = 4.dp).size(headerIconSize),
)
}
}

View File

@ -1,10 +1,7 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
@ -47,7 +44,7 @@ fun NicknameSetupScreen(
title: String = "bitchat",
subtitle: String = "Pick a nickname",
confirmLabel: String = "Join the mesh",
onConfirm: (String) -> Unit
onConfirm: (String) -> Unit,
) {
val palette = LocalBitchatPalette.current
// Pre-fill with the cursor at the end of the existing name, not the start.
@ -55,7 +52,7 @@ fun NicknameSetupScreen(
mutableStateOf(
TextFieldValue(
text = initialNickname,
selection = TextRange(initialNickname.length)
selection = TextRange(initialNickname.length),
)
)
}
@ -64,71 +61,78 @@ fun NicknameSetupScreen(
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp)
)
BasicTextField(
value = name,
onValueChange = { newValue ->
val trimmed = newValue.text.trim().take(24)
name = if (trimmed == newValue.text) {
newValue
} else {
newValue.copy(text = trimmed, selection = TextRange(trimmed.length))
}
},
singleLine = true,
textStyle = ChatVisualTokens.MessageBodyStyle.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
}),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.clip(RoundedCornerShape(18.dp))
.background(palette.inputSurface)
.padding(horizontal = 12.dp, vertical = 8.dp),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.Center) {
if (name.text.isEmpty()) {
Text(
text = "Nickname",
style = ChatVisualTokens.MessageBodyStyle,
color = palette.textTertiary
)
WearFormScreen {
item {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
item {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp),
)
}
item {
BasicTextField(
value = name,
onValueChange = { newValue ->
val trimmed = newValue.text.trim().take(24)
name =
if (trimmed == newValue.text) {
newValue
} else {
newValue.copy(text = trimmed, selection = TextRange(trimmed.length))
}
},
singleLine = true,
textStyle =
ChatVisualTokens.MessageBodyStyle.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
keyboardController?.hide()
}
),
modifier =
Modifier.fillMaxWidth()
.focusRequester(focusRequester)
.clip(RoundedCornerShape(18.dp))
.background(palette.inputSurface)
.padding(horizontal = 12.dp, vertical = 8.dp),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.Center) {
if (name.text.isEmpty()) {
Text(
text = "Nickname",
style = ChatVisualTokens.MessageBodyStyle,
color = palette.textTertiary,
)
}
innerTextField()
}
innerTextField()
}
},
)
}
item {
Button(
onClick = { if (name.text.isNotBlank()) onConfirm(name.text.trim()) },
enabled = name.text.isNotBlank(),
modifier = Modifier.padding(top = 10.dp),
) {
Text(confirmLabel, textAlign = TextAlign.Center)
}
)
Button(
onClick = { if (name.text.isNotBlank()) onConfirm(name.text.trim()) },
enabled = name.text.isNotBlank(),
modifier = Modifier.padding(top = 10.dp)
) {
Text(confirmLabel)
}
}
}

View File

@ -134,7 +134,7 @@ fun TextInputScreen(onSend: (String) -> Unit) {
}
)
},
modifier = Modifier.size(38.dp)
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.Filled.Mic,
@ -145,7 +145,7 @@ fun TextInputScreen(onSend: (String) -> Unit) {
IconButton(
onClick = { send() },
enabled = text.isNotBlank(),
modifier = Modifier.size(38.dp)
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,

View File

@ -18,7 +18,6 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
@ -45,7 +44,7 @@ fun UserDetailScreen(
WearPeerIdentityState.snapshot(peerID, mesh)
}
val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
val listState = rememberScalingLazyListState()
val listState = rememberScalingLazyListState(initialCenterItemIndex = 0)
val palette = LocalBitchatPalette.current
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
@ -53,12 +52,13 @@ fun UserDetailScreen(
ScalingLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
autoCentering = null,
contentPadding = scaffoldPadding
.withAdditionalPadding(
layoutDirection = layoutDirection,
horizontal = 10.dp,
vertical = 8.dp
horizontal = 10.dp
)
.withVerticalClearance(layoutDirection, top = 28.dp, bottom = 28.dp)
) {
item {
ListHeader {
@ -71,8 +71,7 @@ fun UserDetailScreen(
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = colorForPeer(nickname + peerID, palette),
maxLines = 1,
overflow = TextOverflow.Ellipsis
textAlign = TextAlign.Center
)
Text(
text = "User details",
@ -174,13 +173,13 @@ fun UserDetailScreen(
text = if (identity.isVerified) {
"Identity verified"
} else {
"Verification code"
"Identity code"
},
style = ChatVisualTokens.SenderStyle,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Compare cryptographic fingerprints",
text = "Compare identity codes",
style = ChatVisualTokens.SystemActionStyle,
color = palette.textTertiary
)

View File

@ -40,7 +40,7 @@ fun VerificationCodeScreen(peerID: String) {
WearPeerIdentityState.snapshot(peerID, mesh)
}
val myFingerprint = WearPeerIdentityState.myFingerprint(mesh)
val listState = rememberScalingLazyListState()
val listState = rememberScalingLazyListState(initialCenterItemIndex = 0)
val palette = LocalBitchatPalette.current
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
@ -48,12 +48,13 @@ fun VerificationCodeScreen(peerID: String) {
ScalingLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
autoCentering = null,
contentPadding = scaffoldPadding
.withAdditionalPadding(
layoutDirection = layoutDirection,
horizontal = 10.dp,
vertical = 8.dp
horizontal = 10.dp
)
.withVerticalClearance(layoutDirection, top = 28.dp, bottom = 28.dp)
) {
item {
ListHeader {
@ -126,7 +127,8 @@ fun VerificationCodeScreen(peerID: String) {
"Remove verification"
} else {
"Mark verified"
}
},
textAlign = TextAlign.Center
)
}
}
@ -158,8 +160,8 @@ private fun FingerprintCard(
text = fingerprint?.let(::formatVerificationCode) ?: "Handshake pending",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
lineHeight = 13.sp
fontSize = 12.sp,
lineHeight = 16.sp
),
color = if (fingerprint == null) {
palette.accentOrange
@ -178,6 +180,6 @@ fun formatVerificationCode(fingerprint: String): String {
return fingerprint
.uppercase()
.chunked(4)
.chunked(4)
.chunked(2)
.joinToString("\n") { line -> line.joinToString(" ") }
}

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,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

@ -8,6 +8,7 @@ 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.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
@ -43,6 +44,7 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@ -53,6 +55,7 @@ import androidx.wear.compose.material3.Text
import com.bitchat.android.features.voice.AudioWaveformExtractor
import com.bitchat.android.features.voice.VoiceWaveformCache
import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.roundContentSide
import com.bitchat.watch.ui.theme.LocalBitchatPalette
import kotlinx.coroutines.delay
import java.io.File
@ -92,7 +95,7 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Box(
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
@ -101,11 +104,16 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
) {
val bitmap = remember(path) { BitmapFactory.decodeFile(path) }
if (bitmap != null) {
val imageModifier = if (LocalConfiguration.current.isScreenRound) {
Modifier.size(roundContentSide(maxWidth.value, maxHeight.value).dp)
} else {
Modifier.fillMaxSize()
}
Image(
painter = BitmapPainter(bitmap.asImageBitmap()),
contentDescription = "image fullscreen",
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize()
modifier = imageModifier
)
}
Icon(
@ -114,7 +122,7 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 24.dp)
.padding(top = 8.dp)
.size(20.dp)
)
}

View File

@ -37,7 +37,9 @@ object ChatVisualTokens {
val SystemActionStyle = TextStyle(
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 14.sp,
fontSize = 12.sp,
lineHeight = 15.sp,
)
val TimestampStyle = SystemActionStyle.copy(fontSize = 10.sp, lineHeight = 12.sp)
}

View File

@ -11,99 +11,131 @@ import org.junit.Test
class ChatAutoScrollTest {
@Test
fun `new scroll range from appended message keeps follow intent`() {
val updated = updatedChatScrollIntent(
current = ChatScrollIntentState(),
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = false,
position = 100
),
previousPosition = 100
)
assertEquals(ChatScrollIntentState(), updated)
fun `append waits for active gesture and rechecks history intent`() = runTest {
val scrolling = MutableStateFlow(true)
var followsNewest = true
var scrollCount = 0
val job =
launch(start = CoroutineStart.UNDISPATCHED) {
followNewestWhenIdle(scrolling, { followsNewest }) { scrollCount++ }
}
assertFalse(job.isCompleted)
assertEquals(0, scrollCount)
followsNewest = false
scrolling.value = false
job.join()
assertEquals(0, scrollCount)
}
@Test
fun `user scroll away disables follow until list reaches newest again`() {
val browsingHistory = updatedChatScrollIntent(
current = ChatScrollIntentState(),
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = true,
position = 60
),
previousPosition = 100
)
assertFalse(browsingHistory.followsNewest)
assertFalse(browsingHistory.controlsVisible)
fun `append follows after gesture settles when user remains at newest`() = runTest {
val scrolling = MutableStateFlow(true)
var scrollCount = 0
val job =
launch(start = CoroutineStart.UNDISPATCHED) {
followNewestWhenIdle(scrolling, { true }) { scrollCount++ }
}
assertEquals(0, scrollCount)
scrolling.value = false
job.join()
assertEquals(1, scrollCount)
}
val dockedAgain = updatedChatScrollIntent(
current = browsingHistory,
snapshot = ChatScrollSnapshot(
canScrollForward = false,
isScrollInProgress = false,
position = 200
),
previousPosition = 60
)
assertEquals(ChatScrollIntentState(), dockedAgain)
private fun move(
state: ChatScrollIntentState,
dp: Float,
user: Boolean = true,
newest: Boolean = false,
) = updatedChatScrollIntent(state, dp, user, newest)
@Test
fun `append and programmatic movement never change intent`() {
val docked = ChatScrollIntentState()
assertEquals(docked, move(docked, 1000f, user = false))
val history = ChatScrollIntentState(false, false)
assertEquals(history, move(history, 1000f, user = false))
}
@Test
fun `slow scroll away accumulates intent across sub-threshold updates`() {
var state = ChatScrollIntentState()
var previousPosition = 100
listOf(94, 88, 82, 76).forEach { position ->
state = updatedChatScrollIntent(
current = state,
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = true,
position = position
),
previousPosition = previousPosition
)
previousPosition = position
state = updatedChatScrollIntent(
current = state,
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = false,
position = position
),
previousPosition = previousPosition
)
}
assertFalse(state.followsNewest)
assertFalse(state.controlsVisible)
assertEquals(0, state.accumulatedDeltaPx)
}
@Test
fun `slow scroll toward newest reveals controls without restoring follow early`() {
var state = ChatScrollIntentState(followsNewest = false, controlsVisible = false)
var previousPosition = 60
listOf(66, 72, 78, 84).forEach { position ->
state = updatedChatScrollIntent(
current = state,
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = true,
position = position
),
previousPosition = previousPosition
)
previousPosition = position
}
fun `first consumed movement away suspends following before controls hide`() {
val state = move(ChatScrollIntentState(), -1f)
assertFalse(state.followsNewest)
assertEquals(true, state.controlsVisible)
assertEquals(0, state.accumulatedDeltaPx)
assertFalse(move(state, -11f).controlsVisible)
}
@Test
fun `deliberate reversal reveals controls but does not resume following`() {
val history = move(ChatScrollIntentState(), -12f)
val almost = move(history, 23f)
assertFalse(almost.controlsVisible)
val revealed = move(almost, 1f)
assertEquals(true, revealed.controlsVisible)
assertFalse(revealed.followsNewest)
assertEquals(ChatScrollIntentState(), move(revealed, 1f, newest = true))
}
@Test
fun `jitter does not accumulate into repeated toggles`() {
var state = move(ChatScrollIntentState(), -12f)
repeat(100) {
state = move(state, 3f)
state = move(state, -3f)
}
assertFalse(state.controlsVisible)
assertEquals(0f, state.reversalDp)
}
@Test
fun `pauses and discrete crown ticks retain net movement`() {
var state = ChatScrollIntentState()
repeat(4) {
state = move(state, -3f)
state = move(state, 0f, user = false)
}
assertFalse(state.controlsVisible)
repeat(8) {
state = move(state, 3f)
state = move(state, 0f, user = false)
}
assertEquals(true, state.controlsVisible)
assertFalse(state.followsNewest)
}
@Test
fun `fling preserves controls until newest is reached`() {
val history = move(ChatScrollIntentState(), -12f)
assertEquals(history, move(history, 1000f, user = false))
assertEquals(history, move(history, -1000f, user = false))
assertEquals(ChatScrollIntentState(), move(history, 1f, user = false, newest = true))
}
@Test
fun `consumed distance is independent of item boundaries and event chunking`() {
val initial = ChatScrollIntentState()
val oneEvent = move(initial, -12f)
val acrossRows =
listOf(-2f, -3f, -1f, -6f).fold(initial) { state, delta ->
move(state, delta)
}
assertEquals(oneEvent, acrossRows)
}
@Test
fun `pixel distances normalize to the same dp thresholds`() {
for (density in listOf(1f, 1.6875f, 2f, 3f)) {
val history = move(ChatScrollIntentState(), (-12f * density) / density)
assertFalse(history.controlsVisible)
assertEquals(true, move(history, (24f * density) / density).controlsVisible)
}
}
@Test
fun `invalid and unconsumed input is ignored`() {
val initial = ChatScrollIntentState()
assertEquals(initial, move(initial, Float.NaN))
assertEquals(initial, move(initial, Float.POSITIVE_INFINITY))
assertEquals(initial, move(initial, 0f))
}
@Test
@ -111,15 +143,16 @@ class ChatAutoScrollTest {
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null))
var scrollCount = 0
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
val scrollJob =
launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts,
) {
scrollCount += 1
}
}
}
assertFalse(scrollJob.isCompleted)
assertEquals(0, scrollCount)
@ -138,7 +171,7 @@ class ChatAutoScrollTest {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts
measuredLayouts = measuredLayouts,
) {
scrollCount += 1
}
@ -149,28 +182,31 @@ class ChatAutoScrollTest {
@Test
fun `first message waits past stale empty placeholder layout`() = runTest {
val messageKey = "first-message"
val measuredLayouts = MutableStateFlow(
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
)
val measuredLayouts =
MutableStateFlow(
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
)
var scrollCount = 0
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 1,
expectedSingleMessageKey = messageKey,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
val scrollJob =
launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 1,
expectedSingleMessageKey = messageKey,
measuredLayouts = measuredLayouts,
) {
scrollCount += 1
}
}
}
assertFalse(scrollJob.isCompleted)
assertEquals(0, scrollCount)
measuredLayouts.value = MeasuredChatLayout(
itemCount = 1,
singleVisibleItemKey = messageKey
)
measuredLayouts.value =
MeasuredChatLayout(
itemCount = 1,
singleVisibleItemKey = messageKey,
)
scrollJob.join()
assertEquals(1, scrollCount)

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

View File

@ -32,7 +32,7 @@ class WearPeerIdentityStateTest {
val formatted = formatVerificationCode(fingerprint)
assertEquals(fingerprint.uppercase(), formatted.filterNot(Char::isWhitespace))
assertEquals(4, formatted.lines().size)
assertEquals(listOf(4, 4, 4, 4), formatted.lines().map { it.split(" ").size })
assertEquals(8, formatted.lines().size)
assertEquals(List(8) { 2 }, formatted.lines().map { it.split(" ").size })
}
}