Compare commits

..

No commits in common. "main" and "v2.0.1" have entirely different histories.
main ... v2.0.1

51 changed files with 908 additions and 2322 deletions

View File

@ -1,156 +0,0 @@
# bitchat Privacy Policy
*Last updated: January 2025*
## Our Commitment
bitchat is designed with privacy as its foundation. We believe private communication is a fundamental human right. This policy explains how bitchat protects your privacy.
## Summary
**WE DO NOT COLLECT ANY INFORMATION.**
- **No personal data collection** - We don't collect names, emails, or phone numbers
- **No location data collection** - Location is accessed only for local processing (BLE/Geohash) and is never collected or sent to us
- **Hybrid Functionality** - bitchat offers two modes of communication:
- **Bluetooth Mesh Chat**: This mode is completely offline, using peer-to-peer Bluetooth connections. It does not use any servers or internet connection.
- **Geohash Chat**: This mode uses an internet connection to communicate with others in a specific geographic area. It relies on Nostr relays for message transport.
- **No tracking** - We have no analytics, telemetry, or user tracking
- **Open source** - You can verify these claims by reading our code
## What Information bitchat Stores
### On Your Device Only
1. **Identity Key**
- A cryptographic key generated on first launch
- Stored locally in your device's secure storage
- Allows you to maintain "favorite" relationships across app restarts
- Never leaves your device
2. **Nickname**
- The display name you choose (or auto-generated)
- Stored only on your device
- Shared with peers you communicate with
3. **Message History** (if enabled)
- When room owners enable retention, messages are saved locally
- Stored encrypted on your device
- You can delete this at any time
4. **Favorite Peers**
- Public keys of peers you mark as favorites
- Stored only on your device
- Allows you to recognize these peers in future sessions
### Temporary Session Data
During each session, bitchat temporarily maintains:
- Active peer connections (forgotten when app closes)
- Routing information for message delivery
- Cached messages for offline peers (12 hours max)
## What Information is Shared
### With Other bitchat Users
When you use bitchat, nearby peers can see:
- Your chosen nickname
- Your ephemeral public key (changes each session)
- Messages you send to public rooms or directly to them
- Your approximate Bluetooth signal strength (for connection quality)
### With Room Members
When you join a password-protected room:
- Your messages are visible to others with the password
- Your nickname appears in the member list
- Room owners can see you've joined
## What We DON'T Do
bitchat **never**:
- Collects personal information
- Collects location history
- Transmits any data to us (the developers)
- Stores data on servers
- Shares data with third parties
- Uses analytics or telemetry
- Creates user profiles
- Requires registration
## Encryption
All private messages use end-to-end encryption:
- **X25519** for key exchange
- **AES-256-GCM** for message encryption
- **Ed25519** for digital signatures
- **Argon2id** for password-protected rooms
## Your Rights
You have complete control:
- **Delete Everything**: Triple-tap the logo to instantly wipe all data
- **Leave Anytime**: Close the app and your presence disappears
- **No Account**: Nothing to delete from servers because there are none
- **Portability**: Your data never leaves your device unless you export it
## Location Data & Permissions
To provide the core functionality of bitchat, we access your device's location data. This access is necessary for the following specific purposes:
### 1. Bluetooth Low Energy (BLE) Scanning
- **Why we need it:** The Android operating system requires Location permission to scan for nearby Bluetooth LE devices (especially on Android 11 and lower). This is a system-level requirement because Bluetooth scans can theoretically be used to derive location.
- **How we use it:** We use this permission strictly to discover other bitchat peers nearby for the "Bluetooth Mesh Chat" mode.
- **Privacy protection:** We do not record or store your location during this process. The data is processed instantaneously by the Android system to facilitate the connection.
### 2. Geohash Chat Functionality
- **Why we need it:** The "Geohash Chat" mode allows you to communicate with others in your approximate geographic area.
- **How we use it:** If you enable this mode, we access your location to calculate a "geohash" (a short alphanumeric string representing a geographic region). This geohash is used to find and subscribe to relevant channels on decentralized Nostr relays.
- **Privacy protection:**
- Your precise GPS coordinates are **never** sent to any server or peer.
- Only the coarse geohash (representing an area, not a pinpoint) is shared with the Nostr network.
- You can use the "Bluetooth Mesh Chat" mode without this feature if you prefer.
**We do not collect, store, or share your location history.** Location data is processed locally on your device to enable these specific features.
## Children's Privacy
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
## Data Retention
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
- **Identity Key**: Persists until you delete the app
- **Favorites**: Persist until you remove them or delete the app
- **Everything Else**: Exists only during active sessions
## Security Measures
- All communication is encrypted
- No data transmitted to servers (there are none)
- Open source code for public audit
- Regular security updates
- Cryptographic signatures prevent tampering
## Changes to This Policy
If we update this policy:
- The "Last updated" date will change
- The updated policy will be included in the app
- No retroactive changes can affect data (since we don't collect any)
## Contact
bitchat is an open source project. For privacy questions:
- Review our code: https://github.com/yourusername/bitchat
- Open an issue on GitHub
- Join the discussion in public rooms
## Philosophy
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no servers, no surveillance. Just people talking freely.
---
*This policy is released into the public domain under The Unlicense, just like bitchat itself.*

View File

@ -30,8 +30,8 @@ android {
applicationId = "com.bitchat.droid"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 39
versionName = "2.0.2"
versionCode = 38
versionName = "2.0.1"
buildConfigField(
"String",
"GITHUB_RELEASE_CERT_SHA256",

View File

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

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

View File

@ -3,7 +3,6 @@ package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.sync.PacketIdUtil
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.AuthenticatedNoiseSession
@ -239,9 +238,21 @@ class SecurityManager(private val encryptionService: EncryptionService, private
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 {
return "$peerID-${PacketIdUtil.computeIdHex(packet)}"
return when (MessageType.fromValue(packet.type)) {
MessageType.FRAGMENT -> {
// For fragments, include the payload hash to distinguish different fragments
"${packet.timestamp}-$peerID-${packet.type}-${packet.payload.contentHashCode()}"
}
else -> {
// For other messages, use a truncated payload hash
val payloadHash = packet.payload.sliceArray(0 until minOf(64, packet.payload.size)).contentHashCode()
"${packet.timestamp}-$peerID-$payloadHash"
}
}
}
/**

View File

@ -29,23 +29,9 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
companion object {
val NONE = PeerCapabilities(0)
val PREKEYS = PeerCapabilities(1L shl 0)
val WIFI_BULK = PeerCapabilities(1L shl 1)
val GATEWAY = PeerCapabilities(1L shl 2)
val GROUPS = PeerCapabilities(1L shl 3)
val BOARD = PeerCapabilities(1L shl 4)
val VOUCH = PeerCapabilities(1L shl 5)
val MESH_DIAGNOSTICS = PeerCapabilities(1L shl 6)
val BRIDGE = PeerCapabilities(1L shl 7)
/** Noise-encrypted private BitchatFilePacket using payload type 0x20. */
val PRIVATE_MEDIA = PeerCapabilities(1L shl 8)
val PRIVATE_MEDIA_RECEIPTS = PeerCapabilities(1L shl 9)
/** Reserved by iOS; decode it but do not advertise or act on it. */
val NON_DESTRUCTIVE_NOISE_REPLACEMENT = PeerCapabilities(1L shl 10)
/** Capabilities implemented by this Android build. */
val LOCAL_SUPPORTED = PRIVATE_MEDIA

View File

@ -3,11 +3,8 @@ package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Date
/**
@ -21,8 +18,7 @@ class GeohashMessageHandler(
private val repo: GeohashRepository,
private val scope: CoroutineScope,
private val dataManager: com.bitchat.android.ui.DataManager,
private val addChannelMessage: (String, BitchatMessage) -> Unit,
private val signatureVerificationDispatcher: CoroutineDispatcher = Dispatchers.Default
private val addChannelMessage: (String, BitchatMessage) -> Unit
) {
companion object { private const val TAG = "GeohashMessageHandler" }
@ -48,13 +44,6 @@ class GeohashMessageHandler(
if (event.kind != NostrKind.EPHEMERAL_EVENT && event.kind != NostrKind.GEOHASH_PRESENCE) return@launch
val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1)
if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch
val hasValidSignature = withContext(signatureVerificationDispatcher) {
event.isValidSignature()
}
if (!hasValidSignature) {
Log.w(TAG, "Rejecting geohash event ${event.id.take(8)}... with invalid signature")
return@launch
}
if (dedupe(event.id)) return@launch
// PoW validation (if enabled) - apply to chat messages primarily

View File

@ -417,11 +417,6 @@ class LocationNotesManager private constructor() {
return
}
if (!event.isValidSignature()) {
Log.w(TAG, "Rejecting note ${event.id.take(8)}... with invalid signature")
return
}
// Check for geohash tag
val geohashTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }
if (geohashTag == null) {

View File

@ -282,11 +282,6 @@ class NostrClient private constructor(private val context: Context) {
handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit
) {
try {
if (!event.isValidSignature()) {
Log.w(TAG, "🚫 Rejecting geohash event ${event.id.take(8)}... with invalid signature")
return
}
// Check Proof of Work validation for incoming geohash events
val powSettings = PoWPreferenceManager.getCurrentSettings()
if (powSettings.enabled && powSettings.difficulty > 0) {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,45 +0,0 @@
package com.bitchat.android.nostr
import com.bitchat.android.util.AppConstants
import kotlin.math.min
import kotlin.math.pow
/**
* Reconnect schedule for a Nostr relay socket.
*
* A phone loses its data connection constantly airplane mode, a tunnel, a
* Wi-Fi to cellular handover, a dead zone and every relay fails at once when
* it does. The schedule therefore has to survive an outage of arbitrary length
* and heal on its own, because nothing else will: the relay layer registers no
* connectivity callback, the periodic subscription validator only repairs
* subscriptions on sockets that are already open, and `connect()` runs once at
* startup.
*
* So the backoff grows exponentially and then *saturates* rather than
* terminating. Retrying forever at the ceiling costs one connection attempt per
* relay per [AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS]; giving up costs the
* user every internet DM, delivery receipt and geohash channel until they
* notice and restart the app.
*/
internal object RelayReconnectPolicy {
/**
* Attempt count past which the interval stops growing. Beyond this the
* exponential term is already above the ceiling, so pinning it keeps the
* schedule at a steady [AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS] and
* keeps the exponent from running away over a long outage.
*/
const val SATURATION_ATTEMPTS: Int = AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS
/** Attempt number to record after a failure at [previousAttempts]. */
fun nextAttempt(previousAttempts: Int): Int =
(previousAttempts.coerceAtLeast(0) + 1).coerceAtMost(SATURATION_ATTEMPTS)
/** Delay before the reconnect for a given attempt number (1-based). */
fun backoffMs(attempt: Int): Long {
val step = attempt.coerceAtLeast(1)
val exponential = AppConstants.Nostr.INITIAL_BACKOFF_INTERVAL_MS *
AppConstants.Nostr.BACKOFF_MULTIPLIER.pow(step - 1.0)
return min(exponential, AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS.toDouble()).toLong()
}
}

View File

@ -1,7 +1,6 @@
package com.bitchat.android.protocol
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.nio.ByteBuffer
import java.nio.ByteOrder
@ -35,27 +34,6 @@ object SpecialRecipients {
val BROADCAST = ByteArray(8) { 0xFF.toByte() } // All 0xFF = broadcast
}
/**
* Payload as it arrived on the wire, set by [BinaryProtocol.decode].
*
* Signatures cover a re-encoding of the packet, and verification re-encodes too.
* DEFLATE output is not canonical and clients use different encoders
* (java.util.zip.Deflater here, Apple's compression_encode_buffer on iOS), so
* re-compressing can change the preimage and reject a valid packet. Reusing these
* bytes also stops a relay, which re-encodes on TTL decrement, from substituting
* its own encoding.
*
* [forPayload] ties the bytes to the payload they decode to: replace the payload
* and the encoder compresses instead.
*
* Not a data class: generated equals/hashCode over ByteArray compares references.
*/
class WirePayload(
val bytes: ByteArray,
val compressed: Boolean,
val forPayload: ByteArray
)
/**
* Binary packet format - 100% backward compatible with iOS version
*
@ -83,10 +61,7 @@ data class BitchatPacket(
val payload: ByteArray,
var signature: ByteArray? = null, // Changed from val to var for packet signing
var ttl: UByte,
var route: List<ByteArray>? = null, // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient
// Set by BinaryProtocol.decode. Not part of packet identity, so it stays out of
// the parcel, equals and hashCode. Losing it only costs a re-compression.
@IgnoredOnParcel val wirePayload: WirePayload? = null
var route: List<ByteArray>? = null // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient
) : Parcelable {
constructor(
@ -125,8 +100,7 @@ data class BitchatPacket(
payload = payload,
signature = null, // Remove signature for signing
route = route,
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS, // Use fixed TTL=0 for signing to ensure relay compatibility
wirePayload = wirePayload // preimage must use the originator's bytes
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // Use fixed TTL=0 for signing to ensure relay compatibility
)
return BinaryProtocol.encode(unsignedPacket)
}
@ -240,17 +214,7 @@ object BinaryProtocol {
var originalPayloadSize: Int? = null
var isCompressed = false
// Re-encode of a decoded packet: reuse the originator's bytes (see WirePayload).
val wire = packet.wirePayload
if (wire != null && wire.forPayload.contentEquals(packet.payload)) {
if (wire.compressed) {
payload = wire.bytes
originalPayloadSize = packet.payload.size
isCompressed = true
}
// Uncompressed on the wire: keep it that way. shouldCompress agrees
// across clients, but the "only if smaller" check need not.
} else if (CompressionUtil.shouldCompress(payload)) {
if (CompressionUtil.shouldCompress(payload)) {
CompressionUtil.compress(payload)?.let { compressedPayload ->
originalPayloadSize = payload.size
payload = compressedPayload
@ -486,8 +450,6 @@ object BinaryProtocol {
} else null
// Payload
// Kept so the packet can be re-encoded byte-identically (see WirePayload).
var receivedCompressed: ByteArray? = null
val payload = if (isCompressed) {
val lengthFieldBytes = if (version >= 2u.toUByte()) 4 else 2
if (payloadLength.toInt() < lengthFieldBytes) return null
@ -526,8 +488,6 @@ object BinaryProtocol {
val expandedPayload = CompressionUtil.withDecompressionResources(resourceBytes) {
val compressedPayload = ByteArray(compressedSize)
buffer.get(compressedPayload)
// Captured here so the allocation stays inside the reservation.
receivedCompressed = compressedPayload
decompress(compressedPayload, originalSize)
} ?: return null
if (expandedPayload.size != originalSize) {
@ -560,12 +520,7 @@ object BinaryProtocol {
payload = payload,
signature = signature,
ttl = ttl,
route = route,
wirePayload = WirePayload(
bytes = receivedCompressed ?: payload,
compressed = receivedCompressed != null,
forPayload = payload
)
route = route
)
} catch (e: Exception) {

View File

@ -317,7 +317,6 @@ class ConversationRepository internal constructor(
true
} catch (error: Exception) {
Log.e(TAG, "Unable to synchronously clear private conversations", error)
database.destroyStorage()
_storeState.value = ConversationStoreState.Error(
error.message ?: "Unable to erase conversations"
)
@ -1098,22 +1097,6 @@ internal class ConversationDatabase(
writableDatabase.rawQuery("PRAGMA incremental_vacuum", null).use { }
}
/**
* Last-resort panic cleanup when [clearAll] throws: drop the encrypted database
* files so a later reload cannot resurrect erased conversations (#699).
*/
fun destroyStorage() {
try {
clearAll()
} catch (error: Exception) {
Log.e(TAG, "clearAll failed during destroyStorage; deleting database files", error)
}
try {
close()
} catch (_: Exception) { }
applicationContext.deleteDatabase(databaseName)
}
fun pruneToRetentionLimits(): Set<String> {
val db = writableDatabase
val orphanedMediaPaths = linkedSetOf<String>()

View File

@ -1,6 +1,5 @@
package com.bitchat.android.ui
import com.bitchat.android.geohash.ChannelID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.security.MessageDigest
@ -17,8 +16,7 @@ class ChannelManager(
private val state: ChatState,
private val messageManager: MessageManager,
private val dataManager: DataManager,
private val coroutineScope: CoroutineScope,
private val onSwitchToMeshLocation: () -> Unit = {}
private val coroutineScope: CoroutineScope
) {
// Channel encryption and security
@ -111,8 +109,6 @@ class ChannelManager(
fun switchToChannel(channel: String?) {
state.setCurrentChannel(channel)
state.setSelectedPrivateChatPeer(null)
state.setSelectedLocationChannel(ChannelID.Mesh)
onSwitchToMeshLocation()
// Clear unread count
channel?.let { ch ->

View File

@ -140,17 +140,7 @@ class ChatViewModel(
private val conversationListPreferences =
com.bitchat.android.services.ConversationListPreferences.getInstance(getApplication())
private val messageManager = MessageManager(state)
private val channelManager = ChannelManager(
state,
messageManager,
dataManager,
viewModelScope,
onSwitchToMeshLocation = {
com.bitchat.android.geohash.LocationChannelManager
.getInstance(getApplication())
.select(com.bitchat.android.geohash.ChannelID.Mesh)
}
)
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
// Create Noise session delegate for clean dependency injection
private val noiseSessionDelegate = object : NoiseSessionDelegate {

View File

@ -942,61 +942,6 @@ class BinaryProtocolTest {
assertTrue("Payload must match", payload.contentEquals(decoded.payload))
}
/**
* Re-encoding preserves a payload compressed by a different DEFLATE encoder
*
* Verification re-encodes the packet, so a re-encode must reproduce the
* originator's bytes. iOS compresses with Apple's compression_encode_buffer,
* which need not match java.util.zip.Deflater byte for byte.
*
* The payload is wrapped in a raw-DEFLATE "stored" block (BTYPE=00): valid, it
* inflates correctly, and no compressor would emit it, so it stands in for a
* foreign encoder without a second compression library. Compared unpadded so
* the result does not depend on how padding is generated.
*/
@Test
fun `re-encoding preserves a foreign encoder's compressed payload`() {
val payload = "the mesh is up near the north gate. relay running all evening. "
.repeat(4)
.toByteArray()
// Raw DEFLATE stored block: BFINAL=1 BTYPE=00, then LEN and NLEN little-endian.
val stored = ByteArray(5 + payload.size)
stored[0] = 0x01
stored[1] = (payload.size and 0xFF).toByte()
stored[2] = ((payload.size shr 8) and 0xFF).toByte()
stored[3] = (payload.size.inv() and 0xFF).toByte()
stored[4] = ((payload.size.inv() shr 8) and 0xFF).toByte()
payload.copyInto(stored, 5)
val buffer = ByteBuffer.allocate(1024).apply { order(ByteOrder.BIG_ENDIAN) }
buffer.put(2.toByte()) // version = 2
buffer.put(MessageType.MESSAGE.value.toByte()) // type
buffer.put(5.toByte()) // ttl
buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes)
buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte()) // flags
buffer.putInt(stored.size + 4) // payloadLength incl. originalSize
buffer.put(hexToBytes(senderHex)) // senderID (8 bytes)
buffer.putInt(payload.size) // originalSize
buffer.put(stored) // compressed payload
val raw = ByteArray(buffer.position())
buffer.rewind()
buffer.get(raw)
val decoded = BinaryProtocol.decode(raw)
assertNotNull("Foreign-encoded frame must decode", decoded)
assertArrayEquals("Payload must inflate to the original", payload, decoded!!.payload)
val reEncoded = BinaryProtocol.encode(decoded, padding = false)
assertNotNull("Re-encode must succeed", reEncoded)
assertArrayEquals(
"Re-encode must reproduce the originator's bytes, or a valid signature stops verifying",
raw,
reEncoded
)
}
/**
* v2 compression bomb is rejected
*

View File

@ -24,12 +24,11 @@ import org.robolectric.RobolectricTestRunner
import java.util.Date
@RunWith(RobolectricTestRunner::class)
class CommandProcessorTest {
class CommandProcessorTest() {
private val context: Context = ApplicationProvider.getApplicationContext()
@OptIn(ExperimentalCoroutinesApi::class)
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
@OptIn(ExperimentalCoroutinesApi::class)
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val chatState = ChatState(scope = testScope)
private lateinit var commandProcessor: CommandProcessor
@ -64,11 +63,11 @@ class CommandProcessorTest {
val channel = "channel-1"
val result = commandProcessor.processCommand(
command = "/j $channel",
meshService = meshService,
myPeerID = "peer-id",
onSendMessage = { a, b, c -> { } },
viewModel = null
command = "/j $channel",
meshService = meshService,
myPeerID = "peer-id",
onSendMessage = { a, b, c -> { } },
viewModel = null
)
assertEquals(result, true)
@ -164,24 +163,6 @@ class CommandProcessorTest {
assertEquals(0, chatState.getMessagesValue().size)
}
@Test
fun `join command leaves active geohash selection for mesh channel routing`() {
chatState.setSelectedLocationChannel(
ChannelID.Location(GeohashChannel(GeohashChannelLevel.REGION, "9q"))
)
commandProcessor.processCommand(
command = "/join backchannel",
meshService = meshService,
myPeerID = "peer-id",
onSendMessage = { _, _, _ -> },
viewModel = null
)
assertEquals("#backchannel", chatState.getCurrentChannelValue())
assertEquals(ChannelID.Mesh, chatState.selectedLocationChannel.value)
}
@Test
fun `clearSuggestions hides the command suggestion popup`() {
// Typing "/" opens the command popup.
@ -206,6 +187,5 @@ class CommandProcessorTest {
commandProcessor.clearSuggestions()
assertFalse(chatState.getShowMentionSuggestionsValue())
assertTrue(chatState.getMentionSuggestionsValue().isEmpty())
}
}

View File

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

View File

@ -12,38 +12,9 @@ class IdentityAnnouncementTest {
private val signingKey = ByteArray(32) { 0x22 }
@Test
fun `capability assignments and encoding match iOS`() {
assertEquals(1L shl 0, PeerCapabilities.PREKEYS.rawValue)
assertEquals(1L shl 1, PeerCapabilities.WIFI_BULK.rawValue)
assertEquals(1L shl 2, PeerCapabilities.GATEWAY.rawValue)
assertEquals(1L shl 3, PeerCapabilities.GROUPS.rawValue)
assertEquals(1L shl 4, PeerCapabilities.BOARD.rawValue)
assertEquals(1L shl 5, PeerCapabilities.VOUCH.rawValue)
assertEquals(1L shl 6, PeerCapabilities.MESH_DIAGNOSTICS.rawValue)
assertEquals(1L shl 7, PeerCapabilities.BRIDGE.rawValue)
assertEquals(1L shl 8, PeerCapabilities.PRIVATE_MEDIA.rawValue)
assertEquals(1L shl 9, PeerCapabilities.PRIVATE_MEDIA_RECEIPTS.rawValue)
assertEquals(1L shl 10, PeerCapabilities.NON_DESTRUCTIVE_NOISE_REPLACEMENT.rawValue)
val assigned = PeerCapabilities((1L shl 11) - 1)
assertArrayEquals(byteArrayOf(0xFF.toByte(), 0x07), assigned.encoded())
assertEquals(assigned, PeerCapabilities.decode(assigned.encoded()))
}
@Test
fun `capability encoding is minimal and preserves low 64 unknown bits`() {
assertArrayEquals(byteArrayOf(0x00), PeerCapabilities.NONE.encoded())
assertArrayEquals(byteArrayOf(0x01), PeerCapabilities.PREKEYS.encoded())
fun `private media capability uses iOS little-endian bytes`() {
assertArrayEquals(byteArrayOf(0x00, 0x01), PeerCapabilities.PRIVATE_MEDIA.encoded())
val future = PeerCapabilities.decode(
byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80.toByte(), 0x55)
)
assertEquals(Long.MIN_VALUE or 1L, future.rawValue)
assertArrayEquals(
byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80.toByte()),
future.encoded()
)
assertTrue(PeerCapabilities.decode(byteArrayOf(0x00, 0x01)).contains(PeerCapabilities.PRIVATE_MEDIA))
}
@Test
@ -100,6 +71,5 @@ class IdentityAnnouncementTest {
.capabilities!!
.contains(PeerCapabilities.PRIVATE_MEDIA)
)
assertEquals(PeerCapabilities.PRIVATE_MEDIA, PeerCapabilities.LOCAL_SUPPORTED)
}
}

View File

@ -1,136 +0,0 @@
package com.bitchat.android.nostr
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.DataManager
import com.bitchat.android.ui.MessageManager
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* Regression coverage for GH-733: geohash events (kind 20000/20001) were accepted from the
* relay without Schnorr signature verification, allowing full pubkey impersonation.
*/
@RunWith(RobolectricTestRunner::class)
class GeohashMessageHandlerSignatureTest {
private val application: Application = ApplicationProvider.getApplicationContext()
@OptIn(ExperimentalCoroutinesApi::class)
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
private lateinit var chatState: ChatState
private lateinit var dataManager: DataManager
private lateinit var messageManager: MessageManager
private lateinit var repo: GeohashRepository
private lateinit var handler: GeohashMessageHandler
private val geohash = "u4pruy"
@Before
fun setup() {
chatState = ChatState(scope = testScope)
dataManager = DataManager(context = application)
messageManager = MessageManager(state = chatState)
repo = GeohashRepository(application, chatState, dataManager)
handler = GeohashMessageHandler(
application = application,
repo = repo,
scope = testScope,
dataManager = dataManager,
addChannelMessage = messageManager::addChannelMessage,
signatureVerificationDispatcher = testDispatcher
)
}
private fun buildSignedEvent(identity: NostrIdentity, content: String): NostrEvent {
val unsigned = NostrEvent(
pubkey = identity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.EPHEMERAL_EVENT,
tags = listOf(listOf("g", geohash)),
content = content
)
return unsigned.sign(identity.privateKeyHex)
}
@Test
fun onEvent_acceptsGenuinelySignedEvent() {
val victim = NostrIdentity.generate()
val genuine = buildSignedEvent(victim, "hello from the real victim")
handler.onEvent(genuine, geohash)
val stored = chatState.getChannelMessagesValue()["geo:$geohash"]
assertEquals(1, stored?.size)
assertEquals("hello from the real victim", stored?.first()?.content)
}
@Test
fun onEvent_rejectsEventWithForgedSignature() {
val victim = NostrIdentity.generate()
val attacker = NostrIdentity.generate()
// Attacker claims the victim's pubkey but signs with their own key - a forged event.
val forged = buildSignedEvent(victim, "impersonated message").copy(
sig = NostrEvent(
pubkey = attacker.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.EPHEMERAL_EVENT,
tags = listOf(listOf("g", geohash)),
content = "impersonated message"
).sign(attacker.privateKeyHex).sig
)
// Sanity check: this event is indeed invalid before we even touch the handler.
assertEquals(false, forged.isValidSignature())
handler.onEvent(forged, geohash)
val stored = chatState.getChannelMessagesValue()["geo:$geohash"]
assertNull("Forged event must not be rendered as a legitimate message", stored)
}
@Test
fun onEvent_stillAcceptsGenuineEventAfterForgedCopyWithSameIdWasRejected() {
val victim = NostrIdentity.generate()
val attacker = NostrIdentity.generate()
val genuine = buildSignedEvent(victim, "hello from the real victim")
// A forged event carrying the SAME id as the genuine one (ids are a content hash,
// independent of the signature), but signed by an attacker - relays can deliver this
// before the genuine copy arrives from another relay.
val forgedWithSameId = genuine.copy(
sig = NostrEvent(
pubkey = attacker.publicKeyHex,
createdAt = genuine.createdAt,
kind = NostrKind.EPHEMERAL_EVENT,
tags = listOf(listOf("g", geohash)),
content = "hello from the real victim"
).sign(attacker.privateKeyHex).sig
)
assertEquals(genuine.id, forgedWithSameId.id)
assertEquals(false, forgedWithSameId.isValidSignature())
// Forged copy arrives first and must be rejected without poisoning the dedup cache.
handler.onEvent(forgedWithSameId, geohash)
assertNull(chatState.getChannelMessagesValue()["geo:$geohash"])
// The genuine copy (same id, valid signature) arrives afterwards from another relay -
// it must still be rendered, not silently dropped as a "duplicate".
handler.onEvent(genuine, geohash)
val stored = chatState.getChannelMessagesValue()["geo:$geohash"]
assertEquals(1, stored?.size)
assertEquals("hello from the real victim", stored?.first()?.content)
}
}

View File

@ -36,21 +36,17 @@ class NostrBackgroundEventProcessorTest {
fun `cold start processes more events than the removed handoff queue capacity`() = runBlocking {
val application = ApplicationProvider.getApplicationContext<Application>()
val processor = NostrBackgroundEventProcessor(application, scope)
val identity = NostrIdentity.generate()
val expectedIds = mutableSetOf<String>()
repeat(300) { index ->
val event = NostrEvent(
pubkey = identity.publicKeyHex,
createdAt = index + 1,
kind = NostrKind.EPHEMERAL_EVENT,
tags = listOf(listOf("g", "u4pruy")),
content = "message-$index"
).sign(identity.privateKeyHex)
expectedIds += event.id
processor.onGeohashMessage(
event = event,
event = NostrEvent(
id = "cold-start-$index",
pubkey = index.toString(16).padStart(64, '0'),
createdAt = 1,
kind = NostrKind.EPHEMERAL_EVENT,
tags = listOf(listOf("g", "u4pruy")),
content = "message-$index"
),
geohash = "u4pruy"
)
}
@ -62,7 +58,7 @@ class NostrBackgroundEventProcessorTest {
}
assertEquals(
expectedIds,
(0 until 300).map { "cold-start-$it" }.toSet(),
AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().map { it.id }.toSet()
)
}

View File

@ -1,66 +0,0 @@
package com.bitchat.android.nostr
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
class NostrEventDeduplicatorTest {
@Test
fun `isDuplicate flags a repeated event id`() {
val deduplicator = NostrEventDeduplicator(maxCapacity = 10)
assertEquals(false, deduplicator.isDuplicate("event-1"))
assertEquals(true, deduplicator.isDuplicate("event-1"))
}
@Test
fun `capacity evicts the least recently used event id`() {
val deduplicator = NostrEventDeduplicator(maxCapacity = 2)
deduplicator.isDuplicate("a")
deduplicator.isDuplicate("b")
deduplicator.isDuplicate("c") // evicts "a"
assertTrue(deduplicator.contains("b"))
assertTrue(deduplicator.contains("c"))
assertEquals(false, deduplicator.contains("a"))
}
/**
* totalChecks used to be incremented outside the lruLock that guards every other
* mutation in this class, so concurrent callers could race on the read-modify-write
* and lose increments. Every other counter (duplicateCount, evictionCount) was
* already incremented under the lock, which is why only this one drifted.
*/
@Test
fun `totalChecks counts every call exactly once under concurrent access`() {
val deduplicator = NostrEventDeduplicator(maxCapacity = 10_000)
val threadCount = 8
val checksPerThread = 2_000
val executor = Executors.newFixedThreadPool(threadCount)
val start = CountDownLatch(1)
val done = CountDownLatch(threadCount)
repeat(threadCount) { threadIndex ->
executor.submit {
start.await()
repeat(checksPerThread) { callIndex ->
deduplicator.isDuplicate("thread-$threadIndex-event-$callIndex")
}
done.countDown()
}
}
start.countDown()
assertTrue(done.await(10, TimeUnit.SECONDS))
executor.shutdown()
assertEquals(
(threadCount * checksPerThread).toLong(),
deduplicator.getStats().totalChecks
)
}
}

View File

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

View File

@ -1,96 +0,0 @@
package com.bitchat.android.nostr
import com.bitchat.android.util.AppConstants
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The relay layer has no connectivity callback, its periodic validator only
* repairs subscriptions on sockets that are already open, and `connect()` runs
* once at startup. The backoff schedule is therefore the only thing that can
* bring relays back after the phone loses its data connection, so it has to
* saturate rather than terminate.
*/
class RelayReconnectPolicyTest {
private val initial = AppConstants.Nostr.INITIAL_BACKOFF_INTERVAL_MS
private val ceiling = AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS
@Test
fun `the first retry waits the initial interval`() {
assertEquals(initial, RelayReconnectPolicy.backoffMs(RelayReconnectPolicy.nextAttempt(0)))
}
@Test
fun `the interval doubles per attempt until it reaches the ceiling`() {
var attempt = 0
var previous = 0L
var sawCeiling = false
repeat(RelayReconnectPolicy.SATURATION_ATTEMPTS) {
attempt = RelayReconnectPolicy.nextAttempt(attempt)
val delay = RelayReconnectPolicy.backoffMs(attempt)
assertTrue("delay must never exceed the ceiling", delay <= ceiling)
if (delay == ceiling) {
sawCeiling = true
} else {
assertEquals("expected doubling below the ceiling", maxOf(initial, previous * 2), delay)
}
previous = delay
}
assertTrue("the schedule must actually reach the ceiling", sawCeiling)
}
@Test
fun `an outage longer than the schedule keeps retrying at the ceiling`() {
var attempt = 0
// Far past the old give-up point; a real outage can last hours.
repeat(500) { attempt = RelayReconnectPolicy.nextAttempt(attempt) }
assertEquals(RelayReconnectPolicy.SATURATION_ATTEMPTS, attempt)
assertEquals(ceiling, RelayReconnectPolicy.backoffMs(attempt))
}
@Test
fun `a long outage cannot run the attempt counter or the exponent away`() {
var attempt = 0
repeat(10_000) { attempt = RelayReconnectPolicy.nextAttempt(attempt) }
val delay = RelayReconnectPolicy.backoffMs(attempt)
assertTrue("delay must stay finite and bounded", delay in 1..ceiling)
}
@Test
fun `a successful connection resets the schedule to the initial interval`() {
var attempt = 0
repeat(6) { attempt = RelayReconnectPolicy.nextAttempt(attempt) }
assertTrue(RelayReconnectPolicy.backoffMs(attempt) > initial)
// updateRelayStatus zeroes reconnectAttempts on a successful open.
attempt = 0
assertEquals(initial, RelayReconnectPolicy.backoffMs(RelayReconnectPolicy.nextAttempt(attempt)))
}
@Test
fun `a nonsensical stored attempt count still yields a usable delay`() {
assertEquals(initial, RelayReconnectPolicy.backoffMs(RelayReconnectPolicy.nextAttempt(-5)))
assertTrue(RelayReconnectPolicy.backoffMs(0) in 1..ceiling)
assertTrue(RelayReconnectPolicy.backoffMs(Int.MAX_VALUE) in 1..ceiling)
}
@Test
fun `the whole schedule stays under an hour of total wait before the ceiling`() {
var attempt = 0
var total = 0L
repeat(RelayReconnectPolicy.SATURATION_ATTEMPTS) {
attempt = RelayReconnectPolicy.nextAttempt(attempt)
total += RelayReconnectPolicy.backoffMs(attempt)
}
assertTrue("reaching the steady state must not take an hour", total < 60 * 60 * 1000L)
}
}

View File

@ -433,22 +433,6 @@ class ConversationDatabaseTest {
}
}
@Test
fun `destroy storage deletes database files so history cannot reload`() {
database.upsertMessage(
"contact_alice",
setOf("contact_alice"),
"alice",
message("survives-failure", "alice", 1L),
true
)
assertTrue(context.databaseList().contains(databaseName))
database.destroyStorage()
assertFalse(context.databaseList().contains(databaseName))
}
@Test
fun `version one plaintext database migrates without losing history`() {
database.close()

View File

@ -29,16 +29,9 @@ Existing TLVs (unchanged):
Other optional extension:
- `0x05` CAPABILITIES: Minimal little-endian feature bitfield. Assigned bits
match iOS: 0 prekeys, 1 Wi-Fi bulk, 2 gateway, 3 private groups, 4 board,
5 vouch, 6 mesh diagnostics, 7 bridge, 8 authenticated Noise private media,
9 private-media receipts, and 10 reserved non-destructive Noise replacement.
Bit 8's exact value is `00 01`. An empty value is valid and means no
advertised capabilities. For this announcement TLV, unknown bits and bytes
beyond the low 64 bits must be tolerated for forward compatibility. This
tolerance does not apply to the Noise-authenticated peer-state payload
`0x21`, whose capability field must be canonical and 1...8 bytes; see
`PRIVATE_MEDIA_V1.md`.
- `0x05` CAPABILITIES: Minimal little-endian feature bitfield. Bit 8 advertises
authenticated Noise private media (`PRIVATE_MEDIA_V1`); its exact value is
`00 01`. An empty value is valid and means no advertised capabilities.
New TLV (optional):
@ -76,12 +69,6 @@ This matches the onwire 8byte `senderID`/`recipientID` encoding used in th
security-sensitive bit is trusted only after the signed announcement key is
bound to the matching remote static key from a live Noise handshake; see
`PRIVATE_MEDIA_V1.md`.
- Capability bits are independent discovery hints. Implementations advertise
only wire features they implement and must not infer support from platform,
app version, or unrelated transports with similar names.
- Private-media receipts and retry (bit 9) require the current Noise
generation's authenticated `0x21` peer state to prove both bits 8 and 9. An
announcement alone never authorizes that behavior.
- If a `0x04` TLV is present:
- Interpret the value as `N = length / 8` peer IDs (ignore trailing nonaligned bytes).
- Each 8byte chunk is decoded back to a 16hexchar peer ID string (lowercase).

View File

@ -17,7 +17,7 @@ The remaining implementation work and milestone progress are tracked in
| Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, live-voice bursts, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `VoiceBurstPacketTest`, `FragmentManagerTest` |
| Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest`, `ClientRewritePrimitiveContractTest` |
| Sync/routing | Stable packet IDs, GCS bitstream, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` |
| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals, 22h outbound envelope randomization | `ClientRewriteNostrContractTest`, `NostrProtocolTest` |
| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals | `ClientRewriteNostrContractTest`, `NostrProtocolTest` |
| Application state | Peer unions, canonical private conversations, chronological history, delivery/read behavior, media migration policy | `AppStateStoreTest`, `PrivateChatManagerTest`, `MediaSendingManagerMigrationTest` |
## Golden-vector policy
@ -31,11 +31,6 @@ Round-trip tests remain useful but are not sufficient on their own: an encoder
and decoder can share the same defect. Each critical wire format therefore has
at least one literal vector.
NIP-17 receivers should reserve safety slack beyond the maximum timestamp
randomization used by senders. Android caps outbound seal and gift-wrap
randomization at 22h, leaving 2 hours of slack inside iOS's 24-hour
subscription window, while retaining its 48-hour receive lookback.
## Rewrite acceptance gate
From a configured Android development environment, run:

View File

@ -2147,9 +2147,6 @@
<artifact name="aapt2-9.3.1-15703166-osx.jar">
<sha256 value="1e35bc2ce18c3aae840be2a29659ce50d6043e907a44d98ee1cf375d044fa29c" origin="Generated by Gradle"/>
</artifact>
<artifact name="aapt2-9.3.1-15703166-windows.jar">
<sha256 value="b1006ecec7e5936257e95e97f3eba7ef439d3e44178967cc048f86c9119fb231" origin="Generated by Gradle"/>
</artifact>
<artifact name="aapt2-9.3.1-15703166.pom">
<sha256 value="edb82dcbe738ca78b31612cb307bf99e5f02f25c8125048f933e792fd7778beb" origin="Generated by Gradle"/>
</artifact>
@ -4914,9 +4911,6 @@
</artifact>
</component>
<component group="org.junit" name="junit-bom" version="5.9.2">
<artifact name="junit-bom-5.9.2.module">
<sha256 value="ab137ba5a8e32c9b066bf9126a1c76dd5614b724ba5c0b02549772b5e9f4cf1f" origin="Generated by Gradle"/>
</artifact>
<artifact name="junit-bom-5.9.2.pom">
<sha256 value="2ed07d65845131f5336a86476c9a4056b59d0b58b9815ab3679bb0f36f35f705" origin="Generated by Gradle"/>
</artifact>

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -20,27 +21,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState
import androidx.wear.compose.foundation.lazy.items
@ -60,7 +49,7 @@ import kotlinx.coroutines.flow.first
* The shared chat body for global chat and DM threads, following the classic messenger
* pattern: a TransformingLazyColumn message list (native Wear center-scaling/fade, rotary,
* scrollbar) with the header and action bar as floating overlays that get out of the way
* while scrolling up into history and return on a deliberate reverse scroll; at the newest message
* while scrolling up into history and return on any downward scroll; at the newest message
* they are always visible.
*
* The list's contentPadding is CONSTANT and both overlays are layout-neutral, so showing or
@ -93,41 +82,37 @@ fun ChatScaffold(
previousCount = messages.size
}
// Follow intent is changed only by an actual user scroll away from the newest item or by
// reaching the end again. A new item temporarily makes canScrollForward true before layout;
// treating that transient range change as user intent breaks automatic following.
var scrollIntent by remember { mutableStateOf(ChatScrollIntentState()) }
val density = LocalDensity.current
val scrollConnection = remember(columnState, density) {
object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
// Both Wear rotary and touch dispatch consumed movement here. Positive list
// movement is toward newer messages. Layout changes never enter this path.
scrollIntent = updatedChatScrollIntent(
current = scrollIntent,
deltaDp = -consumed.y / density.density,
isUserInput = source == NestedScrollSource.UserInput,
atNewest = !columnState.canScrollForward
)
return Offset.Zero
}
}
}
// One state drives both overlays: visible at the bottom or when scrolling toward it,
// hidden when scrolling up into history. The 24px (~12dp) threshold is deliberately
// small so the controls answer every flick immediately.
var atNewest by remember { mutableStateOf(true) }
val controlsVisible = remember { mutableStateOf(true) }
LaunchedEffect(columnState) {
var lastPosition = -1
snapshotFlow {
!columnState.canScrollForward && !columnState.isScrollInProgress
}.collect { atNewest ->
if (atNewest) scrollIntent = ChatScrollIntentState()
val first = columnState.layoutInfo.visibleItems.firstOrNull()
Triple(columnState.canScrollForward, first?.index ?: 0, first?.offset ?: 0)
}.collect { (canScrollForward, index, offset) ->
val position = index * 100_000 + offset
atNewest = !canScrollForward
if (!canScrollForward) {
controlsVisible.value = true
} else if (lastPosition >= 0) {
when {
position > lastPosition + 24 -> controlsVisible.value = true
position < lastPosition - 24 -> controlsVisible.value = false
}
}
lastPosition = position
}
}
// Stick to bottom when the user has not intentionally moved into history.
// Stick to bottom: follow new messages while resting at the newest.
// Capture this when the message count changes, before the new layout can temporarily make
// canScrollForward true and report that the user is browsing history.
val followNewest = remember(messages.size) { atNewest }
LaunchedEffect(columnState, messages.size) {
if (messages.isNotEmpty() && scrollIntent.followsNewest) {
if (messages.isNotEmpty() && followNewest) {
val expectedSingleMessageKey = messages.singleOrNull()?.id
scrollToNewestAfterItemsMeasured(
expectedItemCount = messages.size,
@ -146,14 +131,7 @@ fun ChatScaffold(
) {
// scrollBy to the end of the range: animateScrollToItem stops as soon as the
// item is partially visible, which left the last message cropped.
// 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) }
}
columnState.scroll { scrollBy(Float.MAX_VALUE) }
}
}
}
@ -165,10 +143,10 @@ fun ChatScaffold(
voice = voice,
onOpenImage = onOpenImage,
columnState = columnState,
controlsVisible = scrollIntent.controlsVisible,
controlsVisible = controlsVisible.value,
header = header,
actionBar = actionBar,
modifier = Modifier.fillMaxSize().nestedScroll(scrollConnection)
modifier = Modifier.fillMaxSize()
)
}
@ -177,33 +155,6 @@ internal data class MeasuredChatLayout(
val singleVisibleItemKey: Any?
)
internal data class ChatScrollIntentState(
val followsNewest: Boolean = true,
val controlsVisible: Boolean = true,
val reversalDp: Float = 0f
)
internal fun updatedChatScrollIntent(
current: ChatScrollIntentState,
deltaDp: Float,
isUserInput: Boolean,
atNewest: Boolean
): ChatScrollIntentState {
if (atNewest) return ChatScrollIntentState()
if (!isUserInput || !deltaDp.isFinite() || deltaDp == 0f) return current
// Hysteresis measures net travel opposite the current controls state, not the sum of
// tiny back-and-forth movements. Keep it across discrete crown ticks and idle periods.
val reversal = (current.reversalDp + if (current.controlsVisible) -deltaDp else deltaDp)
.coerceAtLeast(0f)
val threshold = if (current.controlsVisible) 12f else 24f
val toggle = reversal >= threshold
return current.copy(
followsNewest = current.followsNewest && deltaDp >= 0f,
controlsVisible = if (toggle) !current.controlsVisible else current.controlsVisible,
reversalDp = if (toggle) 0f else reversal
)
}
internal suspend fun scrollToNewestAfterItemsMeasured(
expectedItemCount: Int,
expectedSingleMessageKey: Any?,
@ -218,15 +169,6 @@ internal suspend fun scrollToNewestAfterItemsMeasured(
scrollToEnd()
}
internal suspend fun followNewestWhenIdle(
scrolling: Flow<Boolean>,
shouldFollow: () -> Boolean,
scrollToEnd: suspend () -> Unit
) {
scrolling.first { !it }
if (shouldFollow()) scrollToEnd()
}
@Composable
private fun ChatBody(
messages: List<BitchatMessage>,
@ -243,10 +185,7 @@ private fun ChatBody(
val palette = LocalBitchatPalette.current
val context = LocalContext.current
val transformationSpec = rememberTransformationSpec()
val isScreenRound = LocalConfiguration.current.isScreenRound
val headerClearance = with(LocalDensity.current) {
maxOf(40.dp, 24.dp + (14f * 1.3f).sp.toDp() / 2 + 6.dp)
}
// Slide-to-cancel: while recording, the finger's position is tracked globally; the
// overlay's mic button reports its bounds and becomes the cancel target when the
// finger hovers it (with generous slack so the snap engages on approach).
@ -317,65 +256,16 @@ private fun ChatBody(
}
}
) {
ScreenScaffold(scrollState = columnState) { scaffoldPadding ->
val layoutDirection = LocalLayoutDirection.current
ScreenScaffold(scrollState = columnState) {
TransformingLazyColumn(
state = columnState,
// Keep the list full-screen and geometrically unclipped. Wear's transformation
// spec curves rows along the round display; a soft destination-alpha mask then
// makes them fully transparent at the physical edges instead of cutting glyphs.
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithCache {
val edgeMask = if (isScreenRound) {
// Fade toward the actual circular contour so glyphs become
// transparent before the panel can crop their left or right edge.
Brush.radialGradient(
0f to Color.Black,
CHAT_ROUND_EDGE_OPAQUE_STOP to Color.Black,
1f to Color.Transparent,
center = Offset(size.width / 2f, size.height / 2f),
radius = size.minDimension / 2f
)
} else {
val topOpaqueStop =
(CHAT_HEADER_EDGE_FADE.toPx() / size.height).coerceIn(0f, 1f)
val bottomOpaqueStop =
(1f - CHAT_ACTION_BAR_EDGE_FADE.toPx() / size.height)
.coerceIn(topOpaqueStop, 1f)
Brush.verticalGradient(
0f to Color.Transparent,
topOpaqueStop to Color.Black,
bottomOpaqueStop to Color.Black,
1f to Color.Transparent
)
}
onDrawWithContent {
drawContent()
drawRect(
brush = edgeMask,
blendMode = BlendMode.DstIn
)
}
},
modifier = Modifier.fillMaxSize(),
// Arrangement.Bottom anchors short content to the bottom: the first message
// starts just above the action bar and new messages push history upward.
// The scroll range reserves resting space for the floating controls while the
// full-screen viewport preserves autoscroll and the native transformation focal
// point. Rows may travel behind the overlays only after they have begun the Wear
// edge scale/fade treatment.
// The padding reserves permanent room for the floating header and action
// bar; being constant, it never disturbs an in-flight scroll gesture.
verticalArrangement = Arrangement.Bottom,
// Keep Wear Material's responsive horizontal inset while replacing its vertical
// inset with the overlay clearances used before the shape fix. This avoids both
// duplicated padding and a shortened list viewport.
contentPadding = scaffoldPadding.withVerticalClearance(
layoutDirection = layoutDirection,
top = headerClearance,
bottom = CHAT_ACTION_BAR_CLEARANCE
)
contentPadding = PaddingValues(top = 30.dp, bottom = 64.dp)
) {
if (messages.isEmpty()) {
item {
@ -409,12 +299,7 @@ private fun ChatBody(
// The header stays put and shrinks to its dense form instead of disappearing;
// as an overlay its size animation never touches the list's scroll geometry.
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth(),
contentAlignment = Alignment.TopCenter
) {
Box(modifier = Modifier.align(Alignment.TopCenter)) {
header(controlsVisible)
}
@ -448,10 +333,6 @@ private fun ChatBody(
// Extra finger slack (px, ~28dp at watch density) around the cancel target so the snap
// engages as the finger approaches, not only on exact contact.
private const val CANCEL_HOVER_SLANT_PX = 56f
private val CHAT_ACTION_BAR_CLEARANCE = 64.dp
private val CHAT_HEADER_EDGE_FADE = 36.dp
private val CHAT_ACTION_BAR_EDGE_FADE = 72.dp
private const val CHAT_ROUND_EDGE_OPAQUE_STOP = 0.78f
// Magnetic zone geometry (px at watch density): the button starts reacting at
// MAGNET_OUTER_PX from its center and fully blushes at MAGNET_INNER_PX (~the activation
// boundary); it leans toward the finger by up to MAGNET_PULL_PX.

View File

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

View File

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

View File

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

View File

@ -67,11 +67,10 @@ fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) {
)
}
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
ScreenScaffold(scrollState = listState) {
ScalingLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = scaffoldPadding
modifier = Modifier.fillMaxSize()
) {
item {
ListHeader {

View File

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

View File

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

View File

@ -2,6 +2,7 @@ package com.bitchat.watch.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -13,7 +14,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@ -40,21 +40,14 @@ fun VerificationCodeScreen(peerID: String) {
WearPeerIdentityState.snapshot(peerID, mesh)
}
val myFingerprint = WearPeerIdentityState.myFingerprint(mesh)
val listState = rememberScalingLazyListState(initialCenterItemIndex = 0)
val listState = rememberScalingLazyListState()
val palette = LocalBitchatPalette.current
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
val layoutDirection = LocalLayoutDirection.current
ScreenScaffold(scrollState = listState) {
ScalingLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
autoCentering = null,
contentPadding = scaffoldPadding
.withAdditionalPadding(
layoutDirection = layoutDirection,
horizontal = 10.dp
)
.withVerticalClearance(layoutDirection, top = 28.dp, bottom = 28.dp)
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)
) {
item {
ListHeader {
@ -127,8 +120,7 @@ fun VerificationCodeScreen(peerID: String) {
"Remove verification"
} else {
"Mark verified"
},
textAlign = TextAlign.Center
}
)
}
}
@ -160,8 +152,8 @@ private fun FingerprintCard(
text = fingerprint?.let(::formatVerificationCode) ?: "Handshake pending",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
lineHeight = 16.sp
fontSize = 10.sp,
lineHeight = 13.sp
),
color = if (fingerprint == null) {
palette.accentOrange
@ -180,6 +172,6 @@ fun formatVerificationCode(fingerprint: String): String {
return fingerprint
.uppercase()
.chunked(4)
.chunked(2)
.chunked(4)
.joinToString("\n") { line -> line.joinToString(" ") }
}

View File

@ -1,75 +0,0 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.wear.compose.material3.MaterialTheme
/** The title is outside the scrolling list, so it needs its own physical display bounds. */
@Composable
internal fun WearChatHeader(
fontSize: Float,
onClickLabel: String,
onClick: () -> Unit,
content: @Composable RowScope.() -> Unit,
) {
val configuration = LocalConfiguration.current
val lineHeight = with(LocalDensity.current) { (fontSize * 1.3f).sp.toDp() }
val rowHeight = maxOf(48.dp, lineHeight)
val top = (rowHeight - lineHeight) / 2
val background = MaterialTheme.colorScheme.background
// Follow the existing title animation without animating layout or the hit target.
val expansion = ((fontSize - 12f) / 2f).coerceIn(0f, 1f)
val fadeEnd = maxOf((36f + 8f * expansion).dp, top + lineHeight + 4.dp)
BoxWithConstraints(
Modifier.fillMaxWidth().drawWithCache {
val brush =
Brush.verticalGradient(
0f to background,
0.65f to background.copy(alpha = 0.95f),
1f to Color.Transparent,
endY = fadeEnd.toPx(),
)
onDrawBehind { drawRect(brush) }
},
contentAlignment = Alignment.TopCenter,
) {
val safeWidth =
if (configuration.isScreenRound) {
roundBandWidth(
maxWidth.value,
configuration.screenHeightDp.toFloat(),
top.value,
(top + lineHeight).value,
)
.dp - 8.dp
} else {
maxWidth - 16.dp
}
Row(
modifier =
Modifier.width(safeWidth.coerceAtLeast(48.dp))
.height(rowHeight)
.clickable(role = Role.Button, onClickLabel = onClickLabel, onClick = onClick),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content,
)
}
}

View File

@ -1,53 +0,0 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
/**
* Preserve the responsive, shape-aware padding supplied by Wear Material while allowing a
* screen to reserve additional room for its own content or floating controls.
*/
internal fun PaddingValues.withAdditionalPadding(
layoutDirection: LayoutDirection,
horizontal: Dp = 0.dp,
vertical: Dp = 0.dp
): PaddingValues {
return PaddingValues(
start = calculateStartPadding(layoutDirection) + horizontal,
top = calculateTopPadding() + vertical,
end = calculateEndPadding(layoutDirection) + horizontal,
bottom = calculateBottomPadding() + vertical
)
}
/**
* Keep the responsive horizontal inset supplied by Wear Material while replacing its vertical
* padding with screen-owned clearances. This keeps the lazy list full-screen for correct Wear
* transformation and scroll calculations without stacking duplicate vertical insets.
*/
internal fun PaddingValues.withVerticalClearance(
layoutDirection: LayoutDirection,
top: Dp,
bottom: Dp
): PaddingValues {
return PaddingValues(
start = calculateStartPadding(layoutDirection),
top = top,
end = calculateEndPadding(layoutDirection),
bottom = bottom
)
}
private fun PaddingValues.calculateStartPadding(layoutDirection: LayoutDirection): Dp =
when (layoutDirection) {
LayoutDirection.Ltr -> calculateLeftPadding(layoutDirection)
LayoutDirection.Rtl -> calculateRightPadding(layoutDirection)
}
private fun PaddingValues.calculateEndPadding(layoutDirection: LayoutDirection): Dp =
when (layoutDirection) {
LayoutDirection.Ltr -> calculateRightPadding(layoutDirection)
LayoutDirection.Rtl -> calculateLeftPadding(layoutDirection)
}

View File

@ -1,16 +0,0 @@
package com.bitchat.watch.ui
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.sqrt
/** Width of the narrowest chord across a centered horizontal band in a round display. */
internal fun roundBandWidth(width: Float, height: Float, top: Float, bottom: Float): Float {
val radius = min(width, height) / 2f
val distance = maxOf(abs(top - height / 2f), abs(bottom - height / 2f))
return 2f * sqrt((radius * radius - distance * distance).coerceAtLeast(0f))
}
/** A centered square whose four corners fit inside the physical circle, with a small inset. */
internal fun roundContentSide(width: Float, height: Float): Float =
(min(width, height) / sqrt(2f) - 4f).coerceAtLeast(0f)

View File

@ -1,30 +0,0 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.ScalingLazyListScope
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
import androidx.wear.compose.material3.ScreenScaffold
/** Independently scrollable form items remain reachable on small watches and at large fonts. */
@Composable
internal fun WearFormScreen(content: ScalingLazyListScope.() -> Unit) {
val state = rememberScalingLazyListState(initialCenterItemIndex = 0)
val direction = LocalLayoutDirection.current
ScreenScaffold(scrollState = state) { padding ->
ScalingLazyColumn(
state = state,
modifier = Modifier.fillMaxSize(),
autoCentering = null,
contentPadding =
padding
.withAdditionalPadding(direction, horizontal = 14.dp)
.withVerticalClearance(direction, top = 28.dp, bottom = 28.dp),
content = content,
)
}
}

View File

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

View File

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

View File

@ -10,149 +10,20 @@ import org.junit.Test
class ChatAutoScrollTest {
@Test
fun `append waits for active gesture and rechecks history intent`() = runTest {
val scrolling = MutableStateFlow(true)
var followsNewest = true
var scrollCount = 0
val job =
launch(start = CoroutineStart.UNDISPATCHED) {
followNewestWhenIdle(scrolling, { followsNewest }) { scrollCount++ }
}
assertFalse(job.isCompleted)
assertEquals(0, scrollCount)
followsNewest = false
scrolling.value = false
job.join()
assertEquals(0, scrollCount)
}
@Test
fun `append follows after gesture settles when user remains at newest`() = runTest {
val scrolling = MutableStateFlow(true)
var scrollCount = 0
val job =
launch(start = CoroutineStart.UNDISPATCHED) {
followNewestWhenIdle(scrolling, { true }) { scrollCount++ }
}
assertEquals(0, scrollCount)
scrolling.value = false
job.join()
assertEquals(1, scrollCount)
}
private fun move(
state: ChatScrollIntentState,
dp: Float,
user: Boolean = true,
newest: Boolean = false,
) = updatedChatScrollIntent(state, dp, user, newest)
@Test
fun `append and programmatic movement never change intent`() {
val docked = ChatScrollIntentState()
assertEquals(docked, move(docked, 1000f, user = false))
val history = ChatScrollIntentState(false, false)
assertEquals(history, move(history, 1000f, user = false))
}
@Test
fun `first consumed movement away suspends following before controls hide`() {
val state = move(ChatScrollIntentState(), -1f)
assertFalse(state.followsNewest)
assertEquals(true, state.controlsVisible)
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
fun `newest scroll waits until appended message is measured`() = runTest {
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null))
var scrollCount = 0
val scrollJob =
launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts,
) {
scrollCount += 1
}
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
}
}
assertFalse(scrollJob.isCompleted)
assertEquals(0, scrollCount)
@ -171,7 +42,7 @@ class ChatAutoScrollTest {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
}
@ -182,31 +53,28 @@ class ChatAutoScrollTest {
@Test
fun `first message waits past stale empty placeholder layout`() = runTest {
val messageKey = "first-message"
val measuredLayouts =
MutableStateFlow(
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
)
val measuredLayouts = MutableStateFlow(
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
)
var scrollCount = 0
val scrollJob =
launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 1,
expectedSingleMessageKey = messageKey,
measuredLayouts = measuredLayouts,
) {
scrollCount += 1
}
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 1,
expectedSingleMessageKey = messageKey,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
}
}
assertFalse(scrollJob.isCompleted)
assertEquals(0, scrollCount)
measuredLayouts.value =
MeasuredChatLayout(
itemCount = 1,
singleVisibleItemKey = messageKey,
)
measuredLayouts.value = MeasuredChatLayout(
itemCount = 1,
singleVisibleItemKey = messageKey
)
scrollJob.join()
assertEquals(1, scrollCount)

View File

@ -1,70 +0,0 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Test
class WearContentPaddingTest {
@Test
fun `additional padding preserves scaffold insets`() {
val resolved = PaddingValues(start = 10.dp, top = 18.dp, end = 12.dp, bottom = 20.dp)
.withAdditionalPadding(
layoutDirection = LayoutDirection.Ltr,
horizontal = 10.dp,
vertical = 8.dp
)
assertEquals(20.dp, resolved.calculateLeftPadding(LayoutDirection.Ltr))
assertEquals(22.dp, resolved.calculateRightPadding(LayoutDirection.Ltr))
assertEquals(26.dp, resolved.calculateTopPadding())
assertEquals(28.dp, resolved.calculateBottomPadding())
}
@Test
fun `vertical clearance preserves responsive width and replaces scaffold height inset`() {
val resolved = PaddingValues(start = 10.dp, top = 20.dp, end = 12.dp, bottom = 20.dp)
.withVerticalClearance(
layoutDirection = LayoutDirection.Ltr,
top = 30.dp,
bottom = 64.dp
)
assertEquals(10.dp, resolved.calculateLeftPadding(LayoutDirection.Ltr))
assertEquals(12.dp, resolved.calculateRightPadding(LayoutDirection.Ltr))
assertEquals(30.dp, resolved.calculateTopPadding())
assertEquals(64.dp, resolved.calculateBottomPadding())
}
@Test
fun `additional padding preserves logical start and end in rtl`() {
val resolved = PaddingValues(start = 10.dp, top = 18.dp, end = 12.dp, bottom = 20.dp)
.withAdditionalPadding(
layoutDirection = LayoutDirection.Rtl,
horizontal = 10.dp,
vertical = 8.dp
)
assertEquals(22.dp, resolved.calculateLeftPadding(LayoutDirection.Rtl))
assertEquals(20.dp, resolved.calculateRightPadding(LayoutDirection.Rtl))
assertEquals(26.dp, resolved.calculateTopPadding())
assertEquals(28.dp, resolved.calculateBottomPadding())
}
@Test
fun `vertical clearance preserves logical start and end in rtl`() {
val resolved = PaddingValues(start = 10.dp, top = 20.dp, end = 12.dp, bottom = 20.dp)
.withVerticalClearance(
layoutDirection = LayoutDirection.Rtl,
top = 30.dp,
bottom = 64.dp
)
assertEquals(12.dp, resolved.calculateLeftPadding(LayoutDirection.Rtl))
assertEquals(10.dp, resolved.calculateRightPadding(LayoutDirection.Rtl))
assertEquals(30.dp, resolved.calculateTopPadding())
assertEquals(64.dp, resolved.calculateBottomPadding())
}
}

View File

@ -1,51 +0,0 @@
package com.bitchat.watch.ui
import com.bitchat.watch.ui.theme.ChatVisualTokens
import kotlin.math.pow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class WearDisplayGeometryTest {
@Test
fun `header band corners stay inside the circle at supported text sizes`() {
for (diameter in listOf(192f, 228f, 240f)) {
for (scale in listOf(0.94f, 1f, 1.24f, 1.3f)) {
for (titleSize in listOf(12f, 13f, 14f)) {
val lineHeight = titleSize * 1.3f * scale
val top = (maxOf(48f, lineHeight) - lineHeight) / 2f
val width = roundBandWidth(diameter, diameter, top, top + lineHeight)
val radius = diameter / 2f
for (y in listOf(top, top + lineHeight)) {
assertTrue(
(width / 2).pow(2) + (y - radius).pow(2) <= radius.pow(2) + 0.01f
)
}
}
}
}
}
@Test
fun `out of display bands have no usable width`() {
assertEquals(0f, roundBandWidth(192f, 192f, -1f, 20f))
assertEquals(0f, roundBandWidth(192f, 192f, 180f, 193f))
}
@Test
fun `image and recording safe square fits all four corners`() {
for (diameter in listOf(192f, 228f, 240f)) {
val side = roundContentSide(diameter, diameter)
assertTrue(side > 0)
assertTrue(2 * (side / 2).pow(2) < (diameter / 2).pow(2))
}
}
@Test
fun `essential shared text is at least twelve sp`() {
assertTrue(ChatVisualTokens.SystemActionStyle.fontSize.value >= 12f)
assertTrue(ChatVisualTokens.MessageBodyStyle.fontSize.value >= 12f)
assertTrue(ChatVisualTokens.SenderStyle.fontSize.value >= 12f)
assertTrue(ChatVisualTokens.TimestampStyle.fontSize.value >= 10f)
}
}

View File

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