mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c127eb83ab | ||
|
|
945c9e025b | ||
|
|
010789adde | ||
|
|
9444acd33e | ||
|
|
9e5a07b782 | ||
|
|
fa1727de2f | ||
|
|
936a4cdf6d | ||
|
|
0fce9faa1f | ||
|
|
f352a3e4bc | ||
|
|
eb9ce6555f | ||
|
|
08974107dc | ||
|
|
9a399cc333 | ||
|
|
afad3c5513 | ||
|
|
e50cf9c8cc | ||
|
|
5800583cd2 | ||
|
|
e65039fe76 | ||
|
|
6bdcd46b33 | ||
|
|
4ef1b9c765 | ||
|
|
87ccfe3f6c | ||
|
|
acfbacf78f | ||
|
|
bc03d78972 | ||
|
|
bad78f8b99 | ||
|
|
76bf226f98 | ||
|
|
ac47af9101 |
@ -1,389 +1,391 @@
|
|||||||
Relay URL,Latitude,Longitude
|
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
|
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
|
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
|
relay5.bitransfer.org,43.6532,-79.3832
|
||||||
relay.0xchat.com,43.6532,-79.3832
|
relay.littlebitstudios.com,43.6532,-79.3832
|
||||||
relay.nostriot.com,41.5695,-83.9786
|
public.crostr.com,43.6532,-79.3832
|
||||||
relay.wellorder.net,45.5201,-122.99
|
nostr-verified.wellorder.net,45.5201,-122.99
|
||||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
nostr-relay.xbytez.io,50.6924,3.20113
|
||||||
nostr.relay.hedwig.sh,60.1699,24.9384
|
nostr.azzamo.net:443,52.2633,21.0283
|
||||||
relay.openspecs.uid.ovh,50.9871,2.12554
|
yabu.me,35.6092,139.73
|
||||||
relay.keykeeper.world,40.7824,-74.0711
|
relay.arx-ccn.com,50.4754,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
|
|
||||||
relay.ohstr.com:443,43.6532,-79.3832
|
relay.ohstr.com:443,43.6532,-79.3832
|
||||||
nostr-dev.wellorder.net,45.5201,-122.99
|
relay.nearhood.co.uk,51.5134,-0.0890675
|
||||||
nostr.notribe.net,40.8302,-74.1299
|
purplerelay.com,43.6532,-79.3832
|
||||||
relay.mleku.dev,32.7767,-96.797
|
cs-relay.nostrdev.com,50.4754,12.3683
|
||||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
nostr.red5d.dev,43.6532,-79.3832
|
||||||
nostr2.girino.org:443,43.6532,-79.3832
|
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||||
relay.cosmicbolt.net:443,37.3986,-121.964
|
relay.lightning.pub:443,39.0438,-77.4874
|
||||||
ribo.us.nostria.app,43.6532,-79.3832
|
relay.mccormick.cx:443,52.3563,4.95714
|
||||||
nostr.janx.com,43.6532,-79.3832
|
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||||
familiamartins.net.br,-22.8833,-43.1036
|
relay.shadowbip.com,54.352,18.6466
|
||||||
relay.yoinekodo.jp,43.6532,-79.3832
|
nip85.nosfabrica.com,39.0997,-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
|
|
||||||
social.amanah.eblessing.co,48.1046,11.6002
|
social.amanah.eblessing.co,48.1046,11.6002
|
||||||
nostr.2b9t.xyz,34.0549,-118.243
|
relay.openspecs.uid.ovh,50.9871,2.12554
|
||||||
relay.sector01.com,41.4513,-81.7021
|
relay.pyramid.li,47.4093,8.46503
|
||||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
bcast.girino.org,43.6532,-79.3832
|
||||||
relay.nostr.dev.br,48.1046,11.6002
|
relay.jmoose.rocks:443,32.8009,-96.8195
|
||||||
x.kojira.io,43.6532,-79.3832
|
relay.solife.me,43.6532,-79.3832
|
||||||
node.kommonzenze.de,49.4521,11.0767
|
relay.flashapp.me,43.6548,-79.3885
|
||||||
relay.earthly.city,34.1749,-118.54
|
relay.wavlake.com:443,41.2619,-95.8608
|
||||||
buzz.cashu.space,50.1109,8.68213
|
nostr.plantroon.com:443,50.1013,8.62643
|
||||||
relay.kizuna-miki.com,35.694,139.754
|
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||||
bitchat.nostr1.com,40.7057,-74.0136
|
nostr.infero.net,35.6764,139.65
|
||||||
bruh.samt.st,43.6532,-79.3832
|
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
|
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
|
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
|
relay.qstr.app,50.1109,8.68213
|
||||||
ynostr.yael.at,60.1699,24.9384
|
ynostr.yael.at,60.1699,24.9384
|
||||||
relay.zone667.com,60.1699,24.9384
|
nostr.stakey.net,52.3676,4.90414
|
||||||
relay.underorion.se,50.1109,8.68213
|
espelho.girino.org,43.6532,-79.3832
|
||||||
relay.beamhop.com,43.6532,-79.3832
|
nostr.snowbla.de:443,50.4754,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
|
|
||||||
hasky.chat,40.7862,-74.0743
|
hasky.chat,40.7862,-74.0743
|
||||||
relay.artio.inf.unibe.ch,46.9501,7.43678
|
relay.plebeian.market,50.1109,8.68213
|
||||||
relay.manneken.brussels,49.4543,11.0746
|
relay.zone667.com,60.1699,24.9384
|
||||||
relay.veganostr.com,60.1699,24.9384
|
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||||
relay.nexterz.com,43.6532,-79.3832
|
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||||
nostr.snowbla.de,50.4754,12.3683
|
relay.openresist.com,43.6532,-79.3832
|
||||||
nostr.unkn0wn.world,46.8499,9.53287
|
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||||
nostr.wild-vibes.ts.net,48.8566,2.35222
|
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.bitos.space,43.6532,-79.3832
|
||||||
relay-na1.metanomalist.com,43.6532,-79.3832
|
relay01.lnfi.network,35.6764,139.65
|
||||||
relay.favillakey.io,-27.4705,153.026
|
relay.froth.zone,60.1699,24.9384
|
||||||
relay.nearhood.co.uk,51.5134,-0.0890675
|
denimroad.feeds.relay.tools,38.6327,-90.1961
|
||||||
relay.illuminodes.com,43.6532,-79.3832
|
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||||
nostr.dlcdevkit.com,40.0992,-83.1141
|
21milionidinostr.duckdns.org,41.8945,12.6493
|
||||||
relay.nostr.net,43.6532,-79.3832
|
relay.getvia.xyz,60.1699,24.9384
|
||||||
relay.trotters.cc:443,43.6532,-79.3832
|
ribo.us.nostria.app,43.6532,-79.3832
|
||||||
cyberspace.nostr1.com,40.7057,-74.0136
|
relay.nostrhub.fr,48.1045,11.6004
|
||||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
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
|
relay.btcforplebs.com,43.6532,-79.3832
|
||||||
nostr.azzamo.net:443,52.2633,21.0283
|
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||||
relay.tdw.gg,34.0549,-118.243
|
nostr.wild-vibes.ts.net,48.8566,2.35222
|
||||||
relay.veganostr.com:443,60.1699,24.9384
|
relay.bornheimer.app,50.1109,8.68213
|
||||||
wisp.djorivaltech.com,43.6532,-79.3832
|
relay02.lnfi.network,35.6764,139.65
|
||||||
relay.cosmicbolt.net,37.3986,-121.964
|
|
||||||
nostr.debate.report,50.1109,8.68213
|
|
||||||
nostr.na.social,43.6532,-79.3832
|
|
||||||
relay.kaleidoswap.com,50.8476,4.35717
|
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.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.getsafebox.app:443,43.6532,-79.3832
|
||||||
relay.aarpia.com,37.3986,-121.964
|
relay.aarpia.com,37.3986,-121.964
|
||||||
relay.satsapp.me,50.4754,12.3683
|
nostr.sathoarder.com,48.5734,7.75211
|
||||||
relay.decentralia.fr,48.122,11.589
|
relay.getsafebox.app,43.6532,-79.3832
|
||||||
relay.beginningend.com,35.2227,-97.4786
|
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.paulstephenborile.com:443,49.4543,11.0746
|
||||||
relay.satmaxt.xyz,43.6532,-79.3832
|
relay.earthly.city,34.1749,-118.54
|
||||||
nostr.oxtr.dev:443,50.4754,12.3683
|
inbox.scuba323.com,40.8218,-74.45
|
||||||
vault.iris.to,43.6532,-79.3832
|
nostr-01.yakihonne.com,1.32123,103.695
|
||||||
relay.ditto.pub:443,43.6532,-79.3832
|
articles.layer3.news:443,37.3387,-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
|
|
||||||
offchain.pub,39.1585,-94.5728
|
offchain.pub,39.1585,-94.5728
|
||||||
nostr.thebiglake.org,32.71,-96.6745
|
strfry.bonsai.com:443,39.0438,-77.4874
|
||||||
bendernostur.duckdns.org:8443,50.1109,8.68213
|
relay.nostriot.com,41.5695,-83.9786
|
||||||
nostr-relay.zimage.com,34.282,-118.439
|
relay.bullishbounty.com,43.6532,-79.3832
|
||||||
nostr.ac,38.958,-77.3592
|
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
||||||
relay.nostr.com,50.1109,8.68213
|
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
||||||
bitcoiner.social,47.6743,-117.112
|
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||||
nostr.wecsats.io,43.6532,-79.3832
|
nostr.emanuelemiani.it,45.778,8.79414
|
||||||
relay.pprgb.app,52.52,13.405
|
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||||
nostr.girino.org,43.6532,-79.3832
|
relay.s-w.art,43.6532,-79.3832
|
||||||
nostr.bitcoiner.social,47.6743,-117.112
|
relay.nostrcheck.me,43.6532,-79.3832
|
||||||
nostr.planix.org,43.6532,-79.3832
|
relay.npubhaus.com,43.6532,-79.3832
|
||||||
nostr.data.haus:443,50.4754,12.3683
|
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.nostrmap.net:443,60.1699,24.9384
|
||||||
relay.arx-ccn.com,50.4754,12.3683
|
tribune-panel-growing-noon.trycloudflare.com,43.6532,-79.3832
|
||||||
relay.agorist.space:443,52.3734,4.89406
|
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.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
|
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
|
||||||
|
|||||||
|
@ -3,6 +3,7 @@ package com.bitchat.android.mesh
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.bitchat.android.crypto.EncryptionService
|
import com.bitchat.android.crypto.EncryptionService
|
||||||
import com.bitchat.android.protocol.BitchatPacket
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
|
import com.bitchat.android.sync.PacketIdUtil
|
||||||
import com.bitchat.android.protocol.MessageType
|
import com.bitchat.android.protocol.MessageType
|
||||||
import com.bitchat.android.model.RoutedPacket
|
import com.bitchat.android.model.RoutedPacket
|
||||||
import com.bitchat.android.noise.AuthenticatedNoiseSession
|
import com.bitchat.android.noise.AuthenticatedNoiseSession
|
||||||
@ -238,21 +239,9 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
|||||||
return encryptionService.getCombinedPublicKeyData()
|
return encryptionService.getCombinedPublicKeyData()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Deduplicates by peer and a hash of packet type, sender, timestamp, and full payload. */
|
||||||
* Generate message ID for duplicate detection
|
|
||||||
*/
|
|
||||||
private fun generateMessageID(packet: BitchatPacket, peerID: String): String {
|
private fun generateMessageID(packet: BitchatPacket, peerID: String): String {
|
||||||
return when (MessageType.fromValue(packet.type)) {
|
return "$peerID-${PacketIdUtil.computeIdHex(packet)}"
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -22,6 +22,8 @@ import java.math.BigInteger
|
|||||||
*/
|
*/
|
||||||
object NostrCrypto {
|
object NostrCrypto {
|
||||||
|
|
||||||
|
internal const val NIP17_DEFAULT_MAX_PAST_SECONDS = 79_200
|
||||||
|
|
||||||
private val secureRandom = SecureRandom()
|
private val secureRandom = SecureRandom()
|
||||||
// NIP-44 v2 only
|
// 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 now = (System.currentTimeMillis() / 1000).toInt()
|
||||||
val offset = if (maxPastSeconds > 0) secureRandom.nextInt(maxPastSeconds + 1) else 0
|
val offset = if (maxPastSeconds > 0) secureRandom.nextInt(maxPastSeconds + 1) else 0
|
||||||
return now - offset
|
return now - offset
|
||||||
|
|||||||
@ -79,9 +79,9 @@ class NostrEventDeduplicator(
|
|||||||
* @return true if the event is a duplicate (already seen), false if it's new
|
* @return true if the event is a duplicate (already seen), false if it's new
|
||||||
*/
|
*/
|
||||||
fun isDuplicate(eventId: String): Boolean {
|
fun isDuplicate(eventId: String): Boolean {
|
||||||
totalChecks++
|
|
||||||
|
|
||||||
synchronized(lruLock) {
|
synchronized(lruLock) {
|
||||||
|
totalChecks++
|
||||||
|
|
||||||
val existingNode = nodeMap[eventId]
|
val existingNode = nodeMap[eventId]
|
||||||
|
|
||||||
if (existingNode != null) {
|
if (existingNode != null) {
|
||||||
|
|||||||
@ -37,7 +37,7 @@ object NostrProtocol {
|
|||||||
val rumorId = rumorBase.computeEventIdHex()
|
val rumorId = rumorBase.computeEventIdHex()
|
||||||
val rumor = rumorBase.copy(id = rumorId)
|
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(
|
val sealedEvent = createSeal(
|
||||||
rumor = rumor,
|
rumor = rumor,
|
||||||
recipientPubkey = recipientPubkey,
|
recipientPubkey = recipientPubkey,
|
||||||
|
|||||||
@ -14,8 +14,6 @@ import okhttp3.*
|
|||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlin.math.min
|
|
||||||
import kotlin.math.pow
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages WebSocket connections to Nostr relays
|
* Manages WebSocket connections to Nostr relays
|
||||||
@ -48,11 +46,7 @@ class NostrRelayManager private constructor() {
|
|||||||
"wss://nostr21.com"
|
"wss://nostr21.com"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Exponential backoff configuration (same as iOS)
|
// Reconnect backoff lives in RelayReconnectPolicy.
|
||||||
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
|
|
||||||
|
|
||||||
// Track gift-wraps we initiated for logging
|
// Track gift-wraps we initiated for logging
|
||||||
private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet<String>()
|
private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet<String>()
|
||||||
@ -1019,34 +1013,13 @@ class NostrRelayManager private constructor() {
|
|||||||
!isNetworkActionAllowed(connectionToken)
|
!isNetworkActionAllowed(connectionToken)
|
||||||
) return
|
) return
|
||||||
|
|
||||||
// Check if this is a DNS error
|
// Every failure backs off and retries, including a name-resolution
|
||||||
val errorMessage = error.message?.lowercase() ?: ""
|
// failure: "unable to resolve host" is what this device reports when it
|
||||||
if (errorMessage.contains("hostname could not be found") ||
|
// simply has no network, so treating it as permanent turns a walk
|
||||||
errorMessage.contains("dns") ||
|
// through a tunnel into a dead relay layer for the rest of the process.
|
||||||
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
|
|
||||||
val relay = relaysList.find { it.url == relayUrl } ?: return
|
val relay = relaysList.find { it.url == relayUrl } ?: return
|
||||||
relay.reconnectAttempts++
|
relay.reconnectAttempts = RelayReconnectPolicy.nextAttempt(relay.reconnectAttempts)
|
||||||
|
val backoffInterval = RelayReconnectPolicy.backoffMs(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.nextReconnectTime = System.currentTimeMillis() + backoffInterval
|
relay.nextReconnectTime = System.currentTimeMillis() + backoffInterval
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -598,4 +598,65 @@ class SecurityManagerTest {
|
|||||||
|
|
||||||
private fun String.hexToBytes(): ByteArray =
|
private fun String.hexToBytes(): ByteArray =
|
||||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package com.bitchat.android.nostr
|
|||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class NostrProtocolTest {
|
class NostrProtocolTest {
|
||||||
@ -41,6 +42,52 @@ class NostrProtocolTest {
|
|||||||
assertNull(decrypted)
|
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(
|
private fun forgedGiftWrap(
|
||||||
content: String,
|
content: String,
|
||||||
claimedSender: NostrIdentity,
|
claimedSender: NostrIdentity,
|
||||||
@ -82,4 +129,11 @@ class NostrProtocolTest {
|
|||||||
content = giftWrapContent
|
content = giftWrapContent
|
||||||
).sign(wrapPrivateKey)
|
).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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| Application state | Peer unions, canonical private conversations, chronological history, delivery/read behavior, media migration policy | `AppStateStoreTest`, `PrivateChatManagerTest`, `MediaSendingManagerMigrationTest` |
|
||||||
|
|
||||||
## Golden-vector policy
|
## 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
|
and decoder can share the same defect. Each critical wire format therefore has
|
||||||
at least one literal vector.
|
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
|
## Rewrite acceptance gate
|
||||||
|
|
||||||
From a configured Android development environment, run:
|
From a configured Android development environment, run:
|
||||||
|
|||||||
@ -19,8 +19,8 @@ android {
|
|||||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||||
// Wear releases use a separate high range because Play requires every artifact in
|
// 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.
|
// one application ID to have a unique version code across all form factors.
|
||||||
versionCode = 1_000_000_003
|
versionCode = 1_000_000_004
|
||||||
versionName = "0.1.2"
|
versionName = "0.1.3"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@ -18,9 +18,6 @@ import androidx.compose.animation.core.tween
|
|||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.togetherWith
|
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.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@ -30,7 +27,6 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.saveable.listSaver
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.Button
|
||||||
import androidx.wear.compose.material3.MaterialTheme
|
import androidx.wear.compose.material3.MaterialTheme
|
||||||
import androidx.wear.compose.material3.Text
|
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.mesh.WearMeshService
|
||||||
import com.bitchat.watch.notification.WearNotificationCoordinator
|
import com.bitchat.watch.notification.WearNotificationCoordinator
|
||||||
import com.bitchat.watch.service.WearMeshForegroundService
|
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.UserDetailScreen
|
||||||
import com.bitchat.watch.ui.VerificationCodeScreen
|
import com.bitchat.watch.ui.VerificationCodeScreen
|
||||||
import com.bitchat.watch.ui.WearChatState
|
import com.bitchat.watch.ui.WearChatState
|
||||||
|
import com.bitchat.watch.ui.WearFormScreen
|
||||||
import com.bitchat.watch.ui.sendPrivateMessage
|
import com.bitchat.watch.ui.sendPrivateMessage
|
||||||
import com.bitchat.watch.ui.sendPublicMessage
|
import com.bitchat.watch.ui.sendPublicMessage
|
||||||
import com.bitchat.watch.ui.theme.BitchatWearTheme
|
import com.bitchat.watch.ui.theme.BitchatWearTheme
|
||||||
@ -460,104 +457,110 @@ internal fun WearNavHost(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NotificationPermissionScreen(onResult: (Boolean) -> Unit, onSkip: () -> Unit) {
|
fun NotificationPermissionScreen(onResult: (Boolean) -> Unit, onSkip: () -> Unit) {
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher =
|
||||||
ActivityResultContracts.RequestPermission()
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
) { granted -> onResult(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")
|
|
||||||
}
|
}
|
||||||
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
|
@Composable
|
||||||
fun PermissionRequestScreen(onGranted: () -> Unit) {
|
fun PermissionRequestScreen(onGranted: () -> Unit) {
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher =
|
||||||
ActivityResultContracts.RequestMultiplePermissions()
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||||
) { onGranted() }
|
onGranted()
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
WearFormScreen {
|
||||||
modifier = Modifier
|
item {
|
||||||
.fillMaxSize()
|
Text(
|
||||||
.padding(horizontal = 20.dp),
|
text = "bitchat",
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
verticalArrangement = Arrangement.Center
|
fontWeight = FontWeight.Bold,
|
||||||
) {
|
color = MaterialTheme.colorScheme.primary,
|
||||||
Text(
|
)
|
||||||
text = "bitchat",
|
}
|
||||||
style = MaterialTheme.typography.titleMedium,
|
item {
|
||||||
fontWeight = FontWeight.Bold,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.primary
|
text = "Needs Bluetooth to mesh with nearby devices",
|
||||||
)
|
style = MaterialTheme.typography.bodySmall,
|
||||||
Text(
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
text = "Needs Bluetooth to mesh with nearby devices",
|
textAlign = TextAlign.Center,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
modifier = Modifier.padding(top = 6.dp, bottom = 12.dp),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
)
|
||||||
textAlign = TextAlign.Center,
|
}
|
||||||
modifier = Modifier.padding(top = 6.dp, bottom = 12.dp)
|
item {
|
||||||
)
|
Button(
|
||||||
Button(onClick = {
|
onClick = {
|
||||||
launcher.launch(MainActivity.requiredPermissions().toTypedArray())
|
launcher.launch(MainActivity.requiredPermissions().toTypedArray())
|
||||||
}) {
|
}
|
||||||
Text("Grant access")
|
) {
|
||||||
|
Text("Grant access", textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BluetoothEnableScreen(onEnabled: () -> Unit) {
|
fun BluetoothEnableScreen(onEnabled: () -> Unit) {
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher =
|
||||||
ActivityResultContracts.StartActivityForResult()
|
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
) { onEnabled() }
|
onEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
WearFormScreen {
|
||||||
modifier = Modifier
|
item {
|
||||||
.fillMaxSize()
|
Text(
|
||||||
.padding(horizontal = 20.dp),
|
text = "Bluetooth is off",
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
verticalArrangement = Arrangement.Center
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
) {
|
textAlign = TextAlign.Center,
|
||||||
Text(
|
)
|
||||||
text = "Bluetooth is off",
|
}
|
||||||
style = MaterialTheme.typography.titleSmall,
|
item {
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
Button(
|
||||||
)
|
onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) },
|
||||||
Button(
|
modifier = Modifier.padding(top = 10.dp),
|
||||||
onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) },
|
) {
|
||||||
modifier = Modifier.padding(top = 10.dp)
|
Text("Turn on")
|
||||||
) {
|
}
|
||||||
Text("Turn on")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.unit.toSize
|
import androidx.compose.ui.unit.toSize
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@ -74,7 +76,7 @@ fun ChatActionBar(
|
|||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(38.dp)
|
.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(palette.inputButton)
|
.background(palette.inputButton)
|
||||||
.clickable { onKeyboard() },
|
.clickable { onKeyboard() },
|
||||||
@ -89,7 +91,7 @@ fun ChatActionBar(
|
|||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(38.dp)
|
.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
when {
|
when {
|
||||||
@ -150,7 +152,7 @@ fun VoiceRecordOverlay(
|
|||||||
hoveringCancel: Boolean,
|
hoveringCancel: Boolean,
|
||||||
proximity: Float,
|
proximity: Float,
|
||||||
magnetPull: Offset,
|
magnetPull: Offset,
|
||||||
onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit
|
onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit,
|
||||||
) {
|
) {
|
||||||
val palette = LocalBitchatPalette.current
|
val palette = LocalBitchatPalette.current
|
||||||
// The cancel morph, choreographed for feel:
|
// 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
|
// - the button leans toward the approaching finger (magnetic pull), chasing it with a
|
||||||
// smooth spring so it lags and settles naturally
|
// smooth spring so it lags and settles naturally
|
||||||
// - scale blooms with a soft bounce on activation — no rotation, no wobble
|
// - scale blooms with a soft bounce on activation — no rotation, no wobble
|
||||||
val cancelScale by androidx.compose.animation.core.animateFloatAsState(
|
val cancelScale by
|
||||||
targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity,
|
androidx.compose.animation.core.animateFloatAsState(
|
||||||
animationSpec = androidx.compose.animation.core.spring(
|
targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity,
|
||||||
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
|
animationSpec =
|
||||||
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
|
androidx.compose.animation.core.spring(
|
||||||
),
|
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
|
||||||
label = "cancelSnap"
|
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium,
|
||||||
)
|
),
|
||||||
val pull by androidx.compose.animation.core.animateOffsetAsState(
|
label = "cancelSnap",
|
||||||
targetValue = magnetPull,
|
)
|
||||||
animationSpec = androidx.compose.animation.core.spring(
|
val pull by
|
||||||
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
|
androidx.compose.animation.core.animateOffsetAsState(
|
||||||
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
|
targetValue = magnetPull,
|
||||||
),
|
animationSpec =
|
||||||
label = "magnetPull"
|
androidx.compose.animation.core.spring(
|
||||||
)
|
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
|
||||||
val cancelColor = androidx.compose.ui.graphics.lerp(
|
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium,
|
||||||
MaterialTheme.colorScheme.primary,
|
),
|
||||||
MaterialTheme.colorScheme.error,
|
label = "magnetPull",
|
||||||
if (hoveringCancel) 1f else proximity * 0.85f
|
)
|
||||||
)
|
val cancelColor =
|
||||||
|
androidx.compose.ui.graphics.lerp(
|
||||||
|
MaterialTheme.colorScheme.primary,
|
||||||
|
MaterialTheme.colorScheme.error,
|
||||||
|
if (hoveringCancel) 1f else proximity * 0.85f,
|
||||||
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = voice.recording,
|
visible = voice.recording,
|
||||||
enter = fadeIn(tween(BitchatMotion.EMPHASIZED_MS)),
|
enter = fadeIn(tween(BitchatMotion.EMPHASIZED_MS)),
|
||||||
exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS))
|
exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS)),
|
||||||
) {
|
) {
|
||||||
Column(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
|
||||||
.fillMaxSize()
|
contentAlignment = Alignment.Center,
|
||||||
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.96f))
|
|
||||||
.padding(horizontal = 24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
) {
|
||||||
Box(
|
val side =
|
||||||
modifier = Modifier
|
if (LocalConfiguration.current.isScreenRound) {
|
||||||
.onGloballyPositioned { coords ->
|
roundContentSide(maxWidth.value, maxHeight.value).dp
|
||||||
onCancelBounds(
|
} else {
|
||||||
androidx.compose.ui.geometry.Rect(
|
minOf(maxWidth, maxHeight) - 24.dp
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,14 +28,19 @@ import androidx.compose.ui.graphics.Brush
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.CompositingStrategy
|
import androidx.compose.ui.graphics.CompositingStrategy
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
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.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.changedToUp
|
import androidx.compose.ui.input.pointer.changedToUp
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
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.TransformingLazyColumn
|
||||||
import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState
|
import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState
|
||||||
import androidx.wear.compose.foundation.lazy.items
|
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 com.bitchat.watch.ui.theme.LocalBitchatPalette
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlin.math.sign
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shared chat body for global chat and DM threads, following the classic messenger
|
* 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,
|
* 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
|
* 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.
|
* they are always visible.
|
||||||
*
|
*
|
||||||
* The list's contentPadding is CONSTANT and both overlays are layout-neutral, so showing or
|
* 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
|
// 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;
|
// reaching the end again. A new item temporarily makes canScrollForward true before layout;
|
||||||
// treating that transient range change as user intent breaks automatic following.
|
// treating that transient range change as user intent breaks automatic following.
|
||||||
var followNewest by remember { mutableStateOf(true) }
|
var scrollIntent by remember { mutableStateOf(ChatScrollIntentState()) }
|
||||||
val controlsVisible = remember { mutableStateOf(true) }
|
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) {
|
LaunchedEffect(columnState) {
|
||||||
var lastPosition = -1
|
|
||||||
var scrollIntent = ChatScrollIntentState()
|
|
||||||
snapshotFlow {
|
snapshotFlow {
|
||||||
val first = columnState.layoutInfo.visibleItems.firstOrNull()
|
!columnState.canScrollForward && !columnState.isScrollInProgress
|
||||||
ChatScrollSnapshot(
|
}.collect { atNewest ->
|
||||||
canScrollForward = columnState.canScrollForward,
|
if (atNewest) scrollIntent = ChatScrollIntentState()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stick to bottom when the user has not intentionally moved into history.
|
// Stick to bottom when the user has not intentionally moved into history.
|
||||||
LaunchedEffect(columnState, messages.size) {
|
LaunchedEffect(columnState, messages.size) {
|
||||||
if (messages.isNotEmpty() && followNewest) {
|
if (messages.isNotEmpty() && scrollIntent.followsNewest) {
|
||||||
val expectedSingleMessageKey = messages.singleOrNull()?.id
|
val expectedSingleMessageKey = messages.singleOrNull()?.id
|
||||||
scrollToNewestAfterItemsMeasured(
|
scrollToNewestAfterItemsMeasured(
|
||||||
expectedItemCount = messages.size,
|
expectedItemCount = messages.size,
|
||||||
@ -137,7 +146,14 @@ fun ChatScaffold(
|
|||||||
) {
|
) {
|
||||||
// scrollBy to the end of the range: animateScrollToItem stops as soon as the
|
// scrollBy to the end of the range: animateScrollToItem stops as soon as the
|
||||||
// item is partially visible, which left the last message cropped.
|
// 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,
|
voice = voice,
|
||||||
onOpenImage = onOpenImage,
|
onOpenImage = onOpenImage,
|
||||||
columnState = columnState,
|
columnState = columnState,
|
||||||
controlsVisible = controlsVisible.value,
|
controlsVisible = scrollIntent.controlsVisible,
|
||||||
header = header,
|
header = header,
|
||||||
actionBar = actionBar,
|
actionBar = actionBar,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize().nestedScroll(scrollConnection)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,47 +177,30 @@ internal data class MeasuredChatLayout(
|
|||||||
val singleVisibleItemKey: Any?
|
val singleVisibleItemKey: Any?
|
||||||
)
|
)
|
||||||
|
|
||||||
internal data class ChatScrollSnapshot(
|
|
||||||
val canScrollForward: Boolean,
|
|
||||||
val isScrollInProgress: Boolean,
|
|
||||||
val position: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
internal data class ChatScrollIntentState(
|
internal data class ChatScrollIntentState(
|
||||||
val followsNewest: Boolean = true,
|
val followsNewest: Boolean = true,
|
||||||
val controlsVisible: Boolean = true,
|
val controlsVisible: Boolean = true,
|
||||||
val accumulatedDeltaPx: Int = 0
|
val reversalDp: Float = 0f
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun updatedChatScrollIntent(
|
internal fun updatedChatScrollIntent(
|
||||||
current: ChatScrollIntentState,
|
current: ChatScrollIntentState,
|
||||||
snapshot: ChatScrollSnapshot,
|
deltaDp: Float,
|
||||||
previousPosition: Int
|
isUserInput: Boolean,
|
||||||
|
atNewest: Boolean
|
||||||
): ChatScrollIntentState {
|
): ChatScrollIntentState {
|
||||||
if (!snapshot.canScrollForward) return ChatScrollIntentState()
|
if (atNewest) return ChatScrollIntentState()
|
||||||
if (!snapshot.isScrollInProgress || previousPosition < 0) return current
|
if (!isUserInput || !deltaDp.isFinite() || deltaDp == 0f) return current
|
||||||
|
// Hysteresis measures net travel opposite the current controls state, not the sum of
|
||||||
val delta = snapshot.position - previousPosition
|
// tiny back-and-forth movements. Keep it across discrete crown ticks and idle periods.
|
||||||
val accumulatedDelta = when {
|
val reversal = (current.reversalDp + if (current.controlsVisible) -deltaDp else deltaDp)
|
||||||
delta == 0 -> current.accumulatedDeltaPx
|
.coerceAtLeast(0f)
|
||||||
current.accumulatedDeltaPx == 0 ||
|
val threshold = if (current.controlsVisible) 12f else 24f
|
||||||
current.accumulatedDeltaPx.sign == delta.sign ->
|
val toggle = reversal >= threshold
|
||||||
current.accumulatedDeltaPx + delta
|
|
||||||
else -> delta
|
|
||||||
}
|
|
||||||
val movedAway = accumulatedDelta <= -CHAT_SCROLL_DIRECTION_THRESHOLD_PX
|
|
||||||
val movedTowardNewest = accumulatedDelta >= CHAT_SCROLL_DIRECTION_THRESHOLD_PX
|
|
||||||
|
|
||||||
return current.copy(
|
return current.copy(
|
||||||
followsNewest = current.followsNewest && !movedAway,
|
followsNewest = current.followsNewest && deltaDp >= 0f,
|
||||||
controlsVisible = when {
|
controlsVisible = if (toggle) !current.controlsVisible else current.controlsVisible,
|
||||||
movedAway -> false
|
reversalDp = if (toggle) 0f else reversal
|
||||||
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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,6 +218,15 @@ internal suspend fun scrollToNewestAfterItemsMeasured(
|
|||||||
scrollToEnd()
|
scrollToEnd()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal suspend fun followNewestWhenIdle(
|
||||||
|
scrolling: Flow<Boolean>,
|
||||||
|
shouldFollow: () -> Boolean,
|
||||||
|
scrollToEnd: suspend () -> Unit
|
||||||
|
) {
|
||||||
|
scrolling.first { !it }
|
||||||
|
if (shouldFollow()) scrollToEnd()
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatBody(
|
private fun ChatBody(
|
||||||
messages: List<BitchatMessage>,
|
messages: List<BitchatMessage>,
|
||||||
@ -236,6 +244,9 @@ private fun ChatBody(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val transformationSpec = rememberTransformationSpec()
|
val transformationSpec = rememberTransformationSpec()
|
||||||
val isScreenRound = LocalConfiguration.current.isScreenRound
|
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
|
// 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
|
// 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).
|
// 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.
|
// duplicated padding and a shortened list viewport.
|
||||||
contentPadding = scaffoldPadding.withVerticalClearance(
|
contentPadding = scaffoldPadding.withVerticalClearance(
|
||||||
layoutDirection = layoutDirection,
|
layoutDirection = layoutDirection,
|
||||||
top = CHAT_HEADER_CONTENT_CLEARANCE,
|
top = headerClearance,
|
||||||
bottom = CHAT_ACTION_BAR_CLEARANCE
|
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
|
// 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.
|
// engages as the finger approaches, not only on exact contact.
|
||||||
private const val CANCEL_HOVER_SLANT_PX = 56f
|
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_ACTION_BAR_CLEARANCE = 64.dp
|
||||||
private val CHAT_HEADER_EDGE_FADE = 36.dp
|
private val CHAT_HEADER_EDGE_FADE = 36.dp
|
||||||
private val CHAT_ACTION_BAR_EDGE_FADE = 72.dp
|
private val CHAT_ACTION_BAR_EDGE_FADE = 72.dp
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
package com.bitchat.watch.ui
|
package com.bitchat.watch.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@ -118,77 +116,79 @@ private fun ChatHeader(
|
|||||||
peerCount: Int,
|
peerCount: Int,
|
||||||
unreadDms: Int,
|
unreadDms: Int,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
onOpenPeople: () -> Unit
|
onOpenPeople: () -> Unit,
|
||||||
) {
|
) {
|
||||||
// Floating title row: full-size at the newest messages, shrinks to its dense form
|
// 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
|
// while scrolling up into history. Rendered as an overlay, so the animation only
|
||||||
// relayouts this row, never the message list.
|
// relayouts this row, never the message list.
|
||||||
val spec = androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
|
val spec =
|
||||||
BitchatMotion.STANDARD_MS
|
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 iconSize by
|
||||||
)
|
androidx.compose.animation.core.animateDpAsState(
|
||||||
val titleSize by androidx.compose.animation.core.animateDpAsState(
|
targetValue = if (expanded) 14.dp else 12.dp,
|
||||||
targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "hdrTitle"
|
animationSpec = spec,
|
||||||
)
|
label = "hdrIcon",
|
||||||
val vPadding by androidx.compose.animation.core.animateDpAsState(
|
)
|
||||||
targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "hdrPad"
|
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
|
// 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
|
// 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.
|
// round screen instead of clipping at the edges.
|
||||||
Row(
|
WearChatHeader(
|
||||||
modifier = Modifier
|
fontSize = titleSize,
|
||||||
.fillMaxWidth()
|
onClickLabel = "Open people",
|
||||||
.clickable { onOpenPeople() }
|
onClick = onOpenPeople,
|
||||||
.padding(horizontal = 8.dp, vertical = vPadding),
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
if (unreadDms == 0) {
|
if (unreadDms == 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "bitchat",
|
text = "bitchat",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
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,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
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(
|
Icon(
|
||||||
imageVector = Icons.Filled.People,
|
imageVector = Icons.Filled.People,
|
||||||
contentDescription = "people",
|
contentDescription = "people",
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(iconSize)
|
modifier = Modifier.size(iconSize),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "$peerCount",
|
text = if (peerCount > 99) "99+" else "$peerCount",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
|
fontSize = 12.sp,
|
||||||
(iconSize.value * 0.85f).dp.toSp()
|
lineHeight = (titleSize * 1.3f).sp,
|
||||||
},
|
maxLines = 1,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(start = 2.dp)
|
modifier = Modifier.padding(start = 2.dp),
|
||||||
)
|
)
|
||||||
if (unreadDms > 0) {
|
if (unreadDms > 0) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.MailOutline,
|
imageVector = Icons.Filled.MailOutline,
|
||||||
contentDescription = "$unreadDms unread messages",
|
contentDescription = "$unreadDms unread messages",
|
||||||
tint = LocalBitchatPalette.current.accentOrange,
|
tint = LocalBitchatPalette.current.accentOrange,
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(start = 6.dp).size(iconSize),
|
||||||
.padding(start = 6.dp)
|
|
||||||
.size(iconSize)
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "$unreadDms",
|
text = if (unreadDms > 99) "99+" else "$unreadDms",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
|
fontSize = 12.sp,
|
||||||
(iconSize.value * 0.85f).dp.toSp()
|
lineHeight = (titleSize * 1.3f).sp,
|
||||||
},
|
maxLines = 1,
|
||||||
color = LocalBitchatPalette.current.accentOrange,
|
color = LocalBitchatPalette.current.accentOrange,
|
||||||
modifier = Modifier.padding(start = 2.dp)
|
modifier = Modifier.padding(start = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -240,8 +240,7 @@ fun MessageItem(
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = " ${formatTime(message.timestamp)}",
|
text = " ${formatTime(message.timestamp)}",
|
||||||
style = ChatVisualTokens.SystemActionStyle,
|
style = ChatVisualTokens.TimestampStyle,
|
||||||
fontSize = 9.sp,
|
|
||||||
color = palette.textTertiary
|
color = palette.textTertiary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,8 @@
|
|||||||
package com.bitchat.watch.ui
|
package com.bitchat.watch.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@ -20,7 +17,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
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.compose.foundation.lazy.items
|
||||||
import androidx.wear.compose.material3.MaterialTheme
|
import androidx.wear.compose.material3.MaterialTheme
|
||||||
import androidx.wear.compose.material3.Icon
|
import androidx.wear.compose.material3.Icon
|
||||||
@ -140,59 +138,58 @@ private fun DmHeader(
|
|||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
isFavorite: Boolean,
|
isFavorite: Boolean,
|
||||||
isVerified: Boolean,
|
isVerified: Boolean,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val palette = LocalBitchatPalette.current
|
val palette = LocalBitchatPalette.current
|
||||||
// Floating title row: full-size at the newest messages, shrinks to its dense form
|
// 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
|
// while scrolling up into history. Rendered as an overlay, so the animation only
|
||||||
// relayouts this row, never the message list.
|
// relayouts this row, never the message list.
|
||||||
val spec = androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
|
val spec =
|
||||||
BitchatMotion.STANDARD_MS
|
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 headerIconSize by
|
||||||
)
|
androidx.compose.animation.core.animateDpAsState(
|
||||||
val headerTitleSize by androidx.compose.animation.core.animateDpAsState(
|
targetValue = if (expanded) 14.dp else 12.dp,
|
||||||
targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "dmHdrTitle"
|
animationSpec = spec,
|
||||||
)
|
label = "dmHdrIcon",
|
||||||
val headerVPadding by androidx.compose.animation.core.animateDpAsState(
|
)
|
||||||
targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "dmHdrPad"
|
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(
|
WearChatHeader(
|
||||||
modifier = Modifier
|
fontSize = headerTitleSize,
|
||||||
.fillMaxWidth()
|
onClickLabel = "Open user details",
|
||||||
.clickable(
|
onClick = onClick,
|
||||||
onClickLabel = "Open user details",
|
|
||||||
onClick = onClick
|
|
||||||
)
|
|
||||||
.padding(horizontal = 8.dp, vertical = headerVPadding),
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = nickname,
|
text = nickname,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
|
fontSize = headerTitleSize.sp,
|
||||||
headerTitleSize.toSp()
|
lineHeight = (headerTitleSize * 1.3f).sp,
|
||||||
},
|
|
||||||
fontWeight = FontWeight.Bold,
|
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(
|
NoiseLockIcon(
|
||||||
state = if (sessionEstablished) NoiseSessionUiState.Established
|
state =
|
||||||
else NoiseSessionUiState.Handshaking,
|
if (sessionEstablished) NoiseSessionUiState.Established
|
||||||
|
else NoiseSessionUiState.Handshaking,
|
||||||
size = headerIconSize,
|
size = headerIconSize,
|
||||||
modifier = Modifier.padding(start = 5.dp)
|
modifier = Modifier.padding(start = 5.dp),
|
||||||
)
|
)
|
||||||
if (isFavorite) {
|
if (isFavorite) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_spec_star_filled),
|
painter = painterResource(R.drawable.ic_spec_star_filled),
|
||||||
contentDescription = "Favorite",
|
contentDescription = "Favorite",
|
||||||
tint = palette.accentOrange,
|
tint = palette.accentOrange,
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(start = 4.dp).size(headerIconSize),
|
||||||
.padding(start = 4.dp)
|
|
||||||
.size(headerIconSize)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isVerified) {
|
if (isVerified) {
|
||||||
@ -200,9 +197,7 @@ private fun DmHeader(
|
|||||||
imageVector = Icons.Filled.Verified,
|
imageVector = Icons.Filled.Verified,
|
||||||
contentDescription = "Verified",
|
contentDescription = "Verified",
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(start = 4.dp).size(headerIconSize),
|
||||||
.padding(start = 4.dp)
|
|
||||||
.size(headerIconSize)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
package com.bitchat.watch.ui
|
package com.bitchat.watch.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@ -47,7 +44,7 @@ fun NicknameSetupScreen(
|
|||||||
title: String = "bitchat",
|
title: String = "bitchat",
|
||||||
subtitle: String = "Pick a nickname",
|
subtitle: String = "Pick a nickname",
|
||||||
confirmLabel: String = "Join the mesh",
|
confirmLabel: String = "Join the mesh",
|
||||||
onConfirm: (String) -> Unit
|
onConfirm: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val palette = LocalBitchatPalette.current
|
val palette = LocalBitchatPalette.current
|
||||||
// Pre-fill with the cursor at the end of the existing name, not the start.
|
// Pre-fill with the cursor at the end of the existing name, not the start.
|
||||||
@ -55,7 +52,7 @@ fun NicknameSetupScreen(
|
|||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
TextFieldValue(
|
TextFieldValue(
|
||||||
text = initialNickname,
|
text = initialNickname,
|
||||||
selection = TextRange(initialNickname.length)
|
selection = TextRange(initialNickname.length),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -64,71 +61,78 @@ fun NicknameSetupScreen(
|
|||||||
|
|
||||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
|
|
||||||
Column(
|
WearFormScreen {
|
||||||
modifier = Modifier
|
item {
|
||||||
.fillMaxSize()
|
Text(
|
||||||
.padding(horizontal = 20.dp),
|
text = title,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
verticalArrangement = Arrangement.Center
|
fontWeight = FontWeight.Bold,
|
||||||
) {
|
color = MaterialTheme.colorScheme.primary,
|
||||||
Text(
|
)
|
||||||
text = title,
|
}
|
||||||
style = MaterialTheme.typography.titleMedium,
|
item {
|
||||||
fontWeight = FontWeight.Bold,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.primary
|
text = subtitle,
|
||||||
)
|
style = MaterialTheme.typography.bodySmall,
|
||||||
Text(
|
color = palette.textTertiary,
|
||||||
text = subtitle,
|
textAlign = TextAlign.Center,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp),
|
||||||
color = palette.textTertiary,
|
)
|
||||||
textAlign = TextAlign.Center,
|
}
|
||||||
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp)
|
item {
|
||||||
)
|
BasicTextField(
|
||||||
BasicTextField(
|
value = name,
|
||||||
value = name,
|
onValueChange = { newValue ->
|
||||||
onValueChange = { newValue ->
|
val trimmed = newValue.text.trim().take(24)
|
||||||
val trimmed = newValue.text.trim().take(24)
|
name =
|
||||||
name = if (trimmed == newValue.text) {
|
if (trimmed == newValue.text) {
|
||||||
newValue
|
newValue
|
||||||
} else {
|
} else {
|
||||||
newValue.copy(text = trimmed, selection = TextRange(trimmed.length))
|
newValue.copy(text = trimmed, selection = TextRange(trimmed.length))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
textStyle = ChatVisualTokens.MessageBodyStyle.copy(
|
textStyle =
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
ChatVisualTokens.MessageBodyStyle.copy(
|
||||||
textAlign = TextAlign.Center
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
),
|
textAlign = TextAlign.Center,
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
),
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
keyboardActions = KeyboardActions(onDone = {
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
keyboardController?.hide()
|
keyboardActions =
|
||||||
}),
|
KeyboardActions(
|
||||||
modifier = Modifier
|
onDone = {
|
||||||
.fillMaxWidth()
|
keyboardController?.hide()
|
||||||
.focusRequester(focusRequester)
|
}
|
||||||
.clip(RoundedCornerShape(18.dp))
|
),
|
||||||
.background(palette.inputSurface)
|
modifier =
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
Modifier.fillMaxWidth()
|
||||||
decorationBox = { innerTextField ->
|
.focusRequester(focusRequester)
|
||||||
Box(contentAlignment = Alignment.Center) {
|
.clip(RoundedCornerShape(18.dp))
|
||||||
if (name.text.isEmpty()) {
|
.background(palette.inputSurface)
|
||||||
Text(
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
text = "Nickname",
|
decorationBox = { innerTextField ->
|
||||||
style = ChatVisualTokens.MessageBodyStyle,
|
Box(contentAlignment = Alignment.Center) {
|
||||||
color = palette.textTertiary
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -134,7 +134,7 @@ fun TextInputScreen(onSend: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(38.dp)
|
modifier = Modifier.size(48.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Mic,
|
imageVector = Icons.Filled.Mic,
|
||||||
@ -145,7 +145,7 @@ fun TextInputScreen(onSend: (String) -> Unit) {
|
|||||||
IconButton(
|
IconButton(
|
||||||
onClick = { send() },
|
onClick = { send() },
|
||||||
enabled = text.isNotBlank(),
|
enabled = text.isNotBlank(),
|
||||||
modifier = Modifier.size(38.dp)
|
modifier = Modifier.size(48.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import androidx.compose.ui.platform.LocalLayoutDirection
|
|||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||||
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
|
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
|
||||||
@ -45,7 +44,7 @@ fun UserDetailScreen(
|
|||||||
WearPeerIdentityState.snapshot(peerID, mesh)
|
WearPeerIdentityState.snapshot(peerID, mesh)
|
||||||
}
|
}
|
||||||
val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
|
val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
|
||||||
val listState = rememberScalingLazyListState()
|
val listState = rememberScalingLazyListState(initialCenterItemIndex = 0)
|
||||||
val palette = LocalBitchatPalette.current
|
val palette = LocalBitchatPalette.current
|
||||||
|
|
||||||
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
|
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
|
||||||
@ -53,12 +52,13 @@ fun UserDetailScreen(
|
|||||||
ScalingLazyColumn(
|
ScalingLazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
autoCentering = null,
|
||||||
contentPadding = scaffoldPadding
|
contentPadding = scaffoldPadding
|
||||||
.withAdditionalPadding(
|
.withAdditionalPadding(
|
||||||
layoutDirection = layoutDirection,
|
layoutDirection = layoutDirection,
|
||||||
horizontal = 10.dp,
|
horizontal = 10.dp
|
||||||
vertical = 8.dp
|
|
||||||
)
|
)
|
||||||
|
.withVerticalClearance(layoutDirection, top = 28.dp, bottom = 28.dp)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
ListHeader {
|
ListHeader {
|
||||||
@ -71,8 +71,7 @@ fun UserDetailScreen(
|
|||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = colorForPeer(nickname + peerID, palette),
|
color = colorForPeer(nickname + peerID, palette),
|
||||||
maxLines = 1,
|
textAlign = TextAlign.Center
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "User details",
|
text = "User details",
|
||||||
@ -174,13 +173,13 @@ fun UserDetailScreen(
|
|||||||
text = if (identity.isVerified) {
|
text = if (identity.isVerified) {
|
||||||
"Identity verified"
|
"Identity verified"
|
||||||
} else {
|
} else {
|
||||||
"Verification code"
|
"Identity code"
|
||||||
},
|
},
|
||||||
style = ChatVisualTokens.SenderStyle,
|
style = ChatVisualTokens.SenderStyle,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Compare cryptographic fingerprints",
|
text = "Compare identity codes",
|
||||||
style = ChatVisualTokens.SystemActionStyle,
|
style = ChatVisualTokens.SystemActionStyle,
|
||||||
color = palette.textTertiary
|
color = palette.textTertiary
|
||||||
)
|
)
|
||||||
|
|||||||
@ -40,7 +40,7 @@ fun VerificationCodeScreen(peerID: String) {
|
|||||||
WearPeerIdentityState.snapshot(peerID, mesh)
|
WearPeerIdentityState.snapshot(peerID, mesh)
|
||||||
}
|
}
|
||||||
val myFingerprint = WearPeerIdentityState.myFingerprint(mesh)
|
val myFingerprint = WearPeerIdentityState.myFingerprint(mesh)
|
||||||
val listState = rememberScalingLazyListState()
|
val listState = rememberScalingLazyListState(initialCenterItemIndex = 0)
|
||||||
val palette = LocalBitchatPalette.current
|
val palette = LocalBitchatPalette.current
|
||||||
|
|
||||||
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
|
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
|
||||||
@ -48,12 +48,13 @@ fun VerificationCodeScreen(peerID: String) {
|
|||||||
ScalingLazyColumn(
|
ScalingLazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
autoCentering = null,
|
||||||
contentPadding = scaffoldPadding
|
contentPadding = scaffoldPadding
|
||||||
.withAdditionalPadding(
|
.withAdditionalPadding(
|
||||||
layoutDirection = layoutDirection,
|
layoutDirection = layoutDirection,
|
||||||
horizontal = 10.dp,
|
horizontal = 10.dp
|
||||||
vertical = 8.dp
|
|
||||||
)
|
)
|
||||||
|
.withVerticalClearance(layoutDirection, top = 28.dp, bottom = 28.dp)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
ListHeader {
|
ListHeader {
|
||||||
@ -126,7 +127,8 @@ fun VerificationCodeScreen(peerID: String) {
|
|||||||
"Remove verification"
|
"Remove verification"
|
||||||
} else {
|
} else {
|
||||||
"Mark verified"
|
"Mark verified"
|
||||||
}
|
},
|
||||||
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -158,8 +160,8 @@ private fun FingerprintCard(
|
|||||||
text = fingerprint?.let(::formatVerificationCode) ?: "Handshake pending",
|
text = fingerprint?.let(::formatVerificationCode) ?: "Handshake pending",
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontSize = 10.sp,
|
fontSize = 12.sp,
|
||||||
lineHeight = 13.sp
|
lineHeight = 16.sp
|
||||||
),
|
),
|
||||||
color = if (fingerprint == null) {
|
color = if (fingerprint == null) {
|
||||||
palette.accentOrange
|
palette.accentOrange
|
||||||
@ -178,6 +180,6 @@ fun formatVerificationCode(fingerprint: String): String {
|
|||||||
return fingerprint
|
return fingerprint
|
||||||
.uppercase()
|
.uppercase()
|
||||||
.chunked(4)
|
.chunked(4)
|
||||||
.chunked(4)
|
.chunked(2)
|
||||||
.joinToString("\n") { line -> line.joinToString(" ") }
|
.joinToString("\n") { line -> line.joinToString(" ") }
|
||||||
}
|
}
|
||||||
|
|||||||
75
wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt
Normal file
75
wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
package com.bitchat.watch.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.wear.compose.material3.MaterialTheme
|
||||||
|
|
||||||
|
/** The title is outside the scrolling list, so it needs its own physical display bounds. */
|
||||||
|
@Composable
|
||||||
|
internal fun WearChatHeader(
|
||||||
|
fontSize: Float,
|
||||||
|
onClickLabel: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
content: @Composable RowScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val lineHeight = with(LocalDensity.current) { (fontSize * 1.3f).sp.toDp() }
|
||||||
|
val rowHeight = maxOf(48.dp, lineHeight)
|
||||||
|
val top = (rowHeight - lineHeight) / 2
|
||||||
|
val background = MaterialTheme.colorScheme.background
|
||||||
|
// Follow the existing title animation without animating layout or the hit target.
|
||||||
|
val expansion = ((fontSize - 12f) / 2f).coerceIn(0f, 1f)
|
||||||
|
val fadeEnd = maxOf((36f + 8f * expansion).dp, top + lineHeight + 4.dp)
|
||||||
|
BoxWithConstraints(
|
||||||
|
Modifier.fillMaxWidth().drawWithCache {
|
||||||
|
val brush =
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0f to background,
|
||||||
|
0.65f to background.copy(alpha = 0.95f),
|
||||||
|
1f to Color.Transparent,
|
||||||
|
endY = fadeEnd.toPx(),
|
||||||
|
)
|
||||||
|
onDrawBehind { drawRect(brush) }
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.TopCenter,
|
||||||
|
) {
|
||||||
|
val safeWidth =
|
||||||
|
if (configuration.isScreenRound) {
|
||||||
|
roundBandWidth(
|
||||||
|
maxWidth.value,
|
||||||
|
configuration.screenHeightDp.toFloat(),
|
||||||
|
top.value,
|
||||||
|
(top + lineHeight).value,
|
||||||
|
)
|
||||||
|
.dp - 8.dp
|
||||||
|
} else {
|
||||||
|
maxWidth - 16.dp
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier.width(safeWidth.coerceAtLeast(48.dp))
|
||||||
|
.height(rowHeight)
|
||||||
|
.clickable(role = Role.Button, onClickLabel = onClickLabel, onClick = onClick),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.bitchat.watch.ui
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/** Width of the narrowest chord across a centered horizontal band in a round display. */
|
||||||
|
internal fun roundBandWidth(width: Float, height: Float, top: Float, bottom: Float): Float {
|
||||||
|
val radius = min(width, height) / 2f
|
||||||
|
val distance = maxOf(abs(top - height / 2f), abs(bottom - height / 2f))
|
||||||
|
return 2f * sqrt((radius * radius - distance * distance).coerceAtLeast(0f))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A centered square whose four corners fit inside the physical circle, with a small inset. */
|
||||||
|
internal fun roundContentSide(width: Float, height: Float): Float =
|
||||||
|
(min(width, height) / sqrt(2f) - 4f).coerceAtLeast(0f)
|
||||||
30
wear/src/main/java/com/bitchat/watch/ui/WearFormScreen.kt
Normal file
30
wear/src/main/java/com/bitchat/watch/ui/WearFormScreen.kt
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package com.bitchat.watch.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||||
|
import androidx.wear.compose.foundation.lazy.ScalingLazyListScope
|
||||||
|
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
|
||||||
|
import androidx.wear.compose.material3.ScreenScaffold
|
||||||
|
|
||||||
|
/** Independently scrollable form items remain reachable on small watches and at large fonts. */
|
||||||
|
@Composable
|
||||||
|
internal fun WearFormScreen(content: ScalingLazyListScope.() -> Unit) {
|
||||||
|
val state = rememberScalingLazyListState(initialCenterItemIndex = 0)
|
||||||
|
val direction = LocalLayoutDirection.current
|
||||||
|
ScreenScaffold(scrollState = state) { padding ->
|
||||||
|
ScalingLazyColumn(
|
||||||
|
state = state,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
autoCentering = null,
|
||||||
|
contentPadding =
|
||||||
|
padding
|
||||||
|
.withAdditionalPadding(direction, horizontal = 14.dp)
|
||||||
|
.withVerticalClearance(direction, top = 28.dp, bottom = 28.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
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.graphics.painter.BitmapPainter
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
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.AudioWaveformExtractor
|
||||||
import com.bitchat.android.features.voice.VoiceWaveformCache
|
import com.bitchat.android.features.voice.VoiceWaveformCache
|
||||||
import com.bitchat.watch.ui.theme.ChatVisualTokens
|
import com.bitchat.watch.ui.theme.ChatVisualTokens
|
||||||
|
import com.bitchat.watch.ui.roundContentSide
|
||||||
import com.bitchat.watch.ui.theme.LocalBitchatPalette
|
import com.bitchat.watch.ui.theme.LocalBitchatPalette
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@ -92,7 +95,7 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
|
|||||||
onDismissRequest = onClose,
|
onDismissRequest = onClose,
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
) {
|
) {
|
||||||
Box(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
@ -101,11 +104,16 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
val bitmap = remember(path) { BitmapFactory.decodeFile(path) }
|
val bitmap = remember(path) { BitmapFactory.decodeFile(path) }
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
|
val imageModifier = if (LocalConfiguration.current.isScreenRound) {
|
||||||
|
Modifier.size(roundContentSide(maxWidth.value, maxHeight.value).dp)
|
||||||
|
} else {
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
}
|
||||||
Image(
|
Image(
|
||||||
painter = BitmapPainter(bitmap.asImageBitmap()),
|
painter = BitmapPainter(bitmap.asImageBitmap()),
|
||||||
contentDescription = "image fullscreen",
|
contentDescription = "image fullscreen",
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = imageModifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Icon(
|
Icon(
|
||||||
@ -114,7 +122,7 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
|
|||||||
tint = Color.White.copy(alpha = 0.7f),
|
tint = Color.White.copy(alpha = 0.7f),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.padding(top = 24.dp)
|
.padding(top = 8.dp)
|
||||||
.size(20.dp)
|
.size(20.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,9 @@ object ChatVisualTokens {
|
|||||||
val SystemActionStyle = TextStyle(
|
val SystemActionStyle = TextStyle(
|
||||||
fontFamily = BitchatFontFamily,
|
fontFamily = BitchatFontFamily,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
fontSize = 11.sp,
|
fontSize = 12.sp,
|
||||||
lineHeight = 14.sp,
|
lineHeight = 15.sp,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val TimestampStyle = SystemActionStyle.copy(fontSize = 10.sp, lineHeight = 12.sp)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,99 +11,131 @@ import org.junit.Test
|
|||||||
class ChatAutoScrollTest {
|
class ChatAutoScrollTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `new scroll range from appended message keeps follow intent`() {
|
fun `append waits for active gesture and rechecks history intent`() = runTest {
|
||||||
val updated = updatedChatScrollIntent(
|
val scrolling = MutableStateFlow(true)
|
||||||
current = ChatScrollIntentState(),
|
var followsNewest = true
|
||||||
snapshot = ChatScrollSnapshot(
|
var scrollCount = 0
|
||||||
canScrollForward = true,
|
val job =
|
||||||
isScrollInProgress = false,
|
launch(start = CoroutineStart.UNDISPATCHED) {
|
||||||
position = 100
|
followNewestWhenIdle(scrolling, { followsNewest }) { scrollCount++ }
|
||||||
),
|
}
|
||||||
previousPosition = 100
|
assertFalse(job.isCompleted)
|
||||||
)
|
assertEquals(0, scrollCount)
|
||||||
|
followsNewest = false
|
||||||
assertEquals(ChatScrollIntentState(), updated)
|
scrolling.value = false
|
||||||
|
job.join()
|
||||||
|
assertEquals(0, scrollCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `user scroll away disables follow until list reaches newest again`() {
|
fun `append follows after gesture settles when user remains at newest`() = runTest {
|
||||||
val browsingHistory = updatedChatScrollIntent(
|
val scrolling = MutableStateFlow(true)
|
||||||
current = ChatScrollIntentState(),
|
var scrollCount = 0
|
||||||
snapshot = ChatScrollSnapshot(
|
val job =
|
||||||
canScrollForward = true,
|
launch(start = CoroutineStart.UNDISPATCHED) {
|
||||||
isScrollInProgress = true,
|
followNewestWhenIdle(scrolling, { true }) { scrollCount++ }
|
||||||
position = 60
|
}
|
||||||
),
|
assertEquals(0, scrollCount)
|
||||||
previousPosition = 100
|
scrolling.value = false
|
||||||
)
|
job.join()
|
||||||
assertFalse(browsingHistory.followsNewest)
|
assertEquals(1, scrollCount)
|
||||||
assertFalse(browsingHistory.controlsVisible)
|
}
|
||||||
|
|
||||||
val dockedAgain = updatedChatScrollIntent(
|
private fun move(
|
||||||
current = browsingHistory,
|
state: ChatScrollIntentState,
|
||||||
snapshot = ChatScrollSnapshot(
|
dp: Float,
|
||||||
canScrollForward = false,
|
user: Boolean = true,
|
||||||
isScrollInProgress = false,
|
newest: Boolean = false,
|
||||||
position = 200
|
) = updatedChatScrollIntent(state, dp, user, newest)
|
||||||
),
|
|
||||||
previousPosition = 60
|
@Test
|
||||||
)
|
fun `append and programmatic movement never change intent`() {
|
||||||
assertEquals(ChatScrollIntentState(), dockedAgain)
|
val docked = ChatScrollIntentState()
|
||||||
|
assertEquals(docked, move(docked, 1000f, user = false))
|
||||||
|
val history = ChatScrollIntentState(false, false)
|
||||||
|
assertEquals(history, move(history, 1000f, user = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `slow scroll away accumulates intent across sub-threshold updates`() {
|
fun `first consumed movement away suspends following before controls hide`() {
|
||||||
var state = ChatScrollIntentState()
|
val state = move(ChatScrollIntentState(), -1f)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
assertFalse(state.followsNewest)
|
assertFalse(state.followsNewest)
|
||||||
assertEquals(true, state.controlsVisible)
|
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
|
@Test
|
||||||
@ -111,15 +143,16 @@ class ChatAutoScrollTest {
|
|||||||
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null))
|
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null))
|
||||||
var scrollCount = 0
|
var scrollCount = 0
|
||||||
|
|
||||||
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
|
val scrollJob =
|
||||||
scrollToNewestAfterItemsMeasured(
|
launch(start = CoroutineStart.UNDISPATCHED) {
|
||||||
expectedItemCount = 4,
|
scrollToNewestAfterItemsMeasured(
|
||||||
expectedSingleMessageKey = null,
|
expectedItemCount = 4,
|
||||||
measuredLayouts = measuredLayouts
|
expectedSingleMessageKey = null,
|
||||||
) {
|
measuredLayouts = measuredLayouts,
|
||||||
scrollCount += 1
|
) {
|
||||||
|
scrollCount += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
assertFalse(scrollJob.isCompleted)
|
assertFalse(scrollJob.isCompleted)
|
||||||
assertEquals(0, scrollCount)
|
assertEquals(0, scrollCount)
|
||||||
@ -138,7 +171,7 @@ class ChatAutoScrollTest {
|
|||||||
scrollToNewestAfterItemsMeasured(
|
scrollToNewestAfterItemsMeasured(
|
||||||
expectedItemCount = 4,
|
expectedItemCount = 4,
|
||||||
expectedSingleMessageKey = null,
|
expectedSingleMessageKey = null,
|
||||||
measuredLayouts = measuredLayouts
|
measuredLayouts = measuredLayouts,
|
||||||
) {
|
) {
|
||||||
scrollCount += 1
|
scrollCount += 1
|
||||||
}
|
}
|
||||||
@ -149,28 +182,31 @@ class ChatAutoScrollTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `first message waits past stale empty placeholder layout`() = runTest {
|
fun `first message waits past stale empty placeholder layout`() = runTest {
|
||||||
val messageKey = "first-message"
|
val messageKey = "first-message"
|
||||||
val measuredLayouts = MutableStateFlow(
|
val measuredLayouts =
|
||||||
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
|
MutableStateFlow(
|
||||||
)
|
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
|
||||||
|
)
|
||||||
var scrollCount = 0
|
var scrollCount = 0
|
||||||
|
|
||||||
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
|
val scrollJob =
|
||||||
scrollToNewestAfterItemsMeasured(
|
launch(start = CoroutineStart.UNDISPATCHED) {
|
||||||
expectedItemCount = 1,
|
scrollToNewestAfterItemsMeasured(
|
||||||
expectedSingleMessageKey = messageKey,
|
expectedItemCount = 1,
|
||||||
measuredLayouts = measuredLayouts
|
expectedSingleMessageKey = messageKey,
|
||||||
) {
|
measuredLayouts = measuredLayouts,
|
||||||
scrollCount += 1
|
) {
|
||||||
|
scrollCount += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
assertFalse(scrollJob.isCompleted)
|
assertFalse(scrollJob.isCompleted)
|
||||||
assertEquals(0, scrollCount)
|
assertEquals(0, scrollCount)
|
||||||
|
|
||||||
measuredLayouts.value = MeasuredChatLayout(
|
measuredLayouts.value =
|
||||||
itemCount = 1,
|
MeasuredChatLayout(
|
||||||
singleVisibleItemKey = messageKey
|
itemCount = 1,
|
||||||
)
|
singleVisibleItemKey = messageKey,
|
||||||
|
)
|
||||||
scrollJob.join()
|
scrollJob.join()
|
||||||
|
|
||||||
assertEquals(1, scrollCount)
|
assertEquals(1, scrollCount)
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,7 +32,7 @@ class WearPeerIdentityStateTest {
|
|||||||
val formatted = formatVerificationCode(fingerprint)
|
val formatted = formatVerificationCode(fingerprint)
|
||||||
|
|
||||||
assertEquals(fingerprint.uppercase(), formatted.filterNot(Char::isWhitespace))
|
assertEquals(fingerprint.uppercase(), formatted.filterNot(Char::isWhitespace))
|
||||||
assertEquals(4, formatted.lines().size)
|
assertEquals(8, formatted.lines().size)
|
||||||
assertEquals(listOf(4, 4, 4, 4), formatted.lines().map { it.split(" ").size })
|
assertEquals(List(8) { 2 }, formatted.lines().map { it.split(" ").size })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user