mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
Compare commits
No commits in common. "main" and "v2.0.0" have entirely different histories.
@ -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.*
|
|
||||||
@ -30,8 +30,8 @@ android {
|
|||||||
applicationId = "com.bitchat.droid"
|
applicationId = "com.bitchat.droid"
|
||||||
minSdk = libs.versions.minSdk.get().toInt()
|
minSdk = libs.versions.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||||
versionCode = 38
|
versionCode = 37
|
||||||
versionName = "2.0.1"
|
versionName = "2.0.0"
|
||||||
buildConfigField(
|
buildConfigField(
|
||||||
"String",
|
"String",
|
||||||
"GITHUB_RELEASE_CERT_SHA256",
|
"GITHUB_RELEASE_CERT_SHA256",
|
||||||
@ -111,13 +111,6 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
composeCompiler {
|
|
||||||
// Kotlin 2.4.10's optional Compose group-key mapping depends on unspecified
|
|
||||||
// class-file iteration order. Keep the normal R8 mapping, but omit that
|
|
||||||
// augmentation until its producer is deterministic across clean builds.
|
|
||||||
includeComposeMappingFile.set(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
|
|||||||
@ -1,400 +1,414 @@
|
|||||||
Relay URL,Latitude,Longitude
|
Relay URL,Latitude,Longitude
|
||||||
myvoiceourstory.org,37.3598,-121.981
|
|
||||||
nostr.linky.fit,50.1109,8.68213
|
|
||||||
public.obelisk.ar,43.6532,-79.3832
|
|
||||||
relay.i9.eti.br,43.6532,-79.3832
|
|
||||||
yabu.me,35.6092,139.73
|
|
||||||
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
|
|
||||||
relay.nostu.be,40.4167,-3.70329
|
|
||||||
relay.favillakey.io,-27.4705,153.026
|
|
||||||
nostr.mikoshi.de,50.1109,8.68213
|
|
||||||
relay.agilesolutionlabs.com,43.6532,-79.3832
|
|
||||||
nostr.sovereignservices.xyz,43.6532,-79.3832
|
|
||||||
relay.qstr.app,50.1109,8.68213
|
|
||||||
articles.layer3.news:443,37.3387,-121.885
|
|
||||||
relay.nostrops.com,32.71,-96.6745
|
|
||||||
relay.mrmave.work,43.6532,-79.3832
|
|
||||||
nostr.islandarea.net:443,35.4669,-97.6473
|
|
||||||
nostr.azzamo.net:443,52.2633,21.0283
|
|
||||||
ec2.f7z.io,60.1699,24.9384
|
|
||||||
relay.veganostr.com,60.1699,24.9384
|
|
||||||
nostr.dlcdevkit.com,40.0992,-83.1141
|
|
||||||
relay.44billion.net,43.6532,-79.3832
|
|
||||||
bendernostur.duckdns.org:8443,50.1109,8.68213
|
|
||||||
nostras.app,38.7946,-106.535
|
|
||||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
|
||||||
nostr.stakey.net,52.3676,4.90414
|
|
||||||
support.flotilla.social,32.9483,-96.7299
|
|
||||||
relay.ordoplay.com,50.1109,8.68213
|
|
||||||
nostr.stakey.net:443,52.3676,4.90414
|
|
||||||
relay.nostrmap.net,60.1699,24.9384
|
|
||||||
relayone.geektank.ai,39.0997,-94.5786
|
|
||||||
relay.novospes.com,43.6532,-79.3832
|
|
||||||
relay.dyne.org,49.0291,8.35705
|
|
||||||
nostrrelay.taylorperron.com,45.5029,-73.5723
|
|
||||||
nostrbtc.com,43.6532,-79.3832
|
|
||||||
relay.lanavault.space,60.1699,24.9384
|
|
||||||
relay.bullishbounty.com:443,43.6532,-79.3832
|
|
||||||
relay.layer.systems:443,49.0291,8.35695
|
|
||||||
vault.iris.to:443,43.6532,-79.3832
|
|
||||||
relay.getsafebox.app:443,43.6532,-79.3832
|
|
||||||
relay.wellorder.net,45.5201,-122.99
|
|
||||||
relay.scuba323.com,40.8218,-74.45
|
|
||||||
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
|
||||||
temp.iris.to,43.6532,-79.3832
|
|
||||||
relay.paulstephenborile.com,49.4543,11.0746
|
|
||||||
relay.cyberguy.fyi,52.6907,4.8181
|
|
||||||
nostr.wecsats.io:443,43.6532,-79.3832
|
|
||||||
bread.nostrsms.com,40.8218,-74.45
|
|
||||||
relay.plebeian.market,50.1109,8.68213
|
|
||||||
relay.laantungir.net,-19.4692,-42.5315
|
|
||||||
nostr.overmind.lol,43.6532,-79.3832
|
|
||||||
nip85.nosfabrica.com,39.0997,-94.5786
|
|
||||||
relay.conduit.market,38.7946,-106.535
|
|
||||||
relay.sigit.io,50.4754,12.3683
|
|
||||||
relay.dreamith.to,43.6532,-79.3832
|
|
||||||
relay.decentralia.fr,48.122,11.589
|
|
||||||
nexus.libernet.app,43.6532,-79.3832
|
|
||||||
relay.nmail.li,50.9871,2.12554
|
|
||||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
|
||||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
|
||||||
nostr-relay.corb.net,39.6478,-104.988
|
|
||||||
relay.agentry.com,42.8864,-78.8784
|
|
||||||
budabit.nostr1.com,40.7057,-74.0136
|
|
||||||
relay.cypherflow.ai,48.8575,2.35138
|
|
||||||
purplerelay.com,43.6532,-79.3832
|
|
||||||
nostr.islandarea.net,35.4669,-97.6473
|
|
||||||
relay.edufeed.org,49.4521,11.0767
|
|
||||||
relay.bnos.space,43.6532,-79.3832
|
|
||||||
relay.kizuna-miki.com,35.694,139.754
|
|
||||||
nostr.snowbla.de,50.4754,12.3683
|
|
||||||
bitchat.nostr1.com,40.7057,-74.0136
|
|
||||||
relay.beamhop.com,43.6532,-79.3832
|
|
||||||
relay.wavefunc.live,41.8781,-87.6298
|
|
||||||
relayrs.notoshi.win,43.6532,-79.3832
|
|
||||||
relay.nostrhub.fr,48.1045,11.6004
|
|
||||||
relay01.lnfi.network,35.6764,139.65
|
|
||||||
relay.bitos.space,43.6532,-79.3832
|
|
||||||
relay.noeudlibre.fr,50.6924,3.20113
|
|
||||||
relay.orly.dev,32.7767,-96.797
|
|
||||||
relay.paulstephenborile.com:443,49.4543,11.0746
|
|
||||||
relay.atsocy.com,43.6532,-79.3832
|
|
||||||
relay.keykeeper.world,40.7824,-74.0711
|
|
||||||
nostrmxn.lulus.com.mx,59.4016,17.9455
|
|
||||||
maxq.descendant.io,43.6532,-79.3832
|
|
||||||
nostr.spicyz.io:443,43.6532,-79.3832
|
|
||||||
nostr.chaima.info:443,50.1109,8.68213
|
|
||||||
relay.opmaat.org,60.1699,24.9384
|
|
||||||
nostr.notribe.net,40.8302,-74.1299
|
|
||||||
nostr-01.yakihonne.com:443,1.32123,103.695
|
|
||||||
offchain.pub,39.1585,-94.5728
|
|
||||||
relay.agora.social,50.7383,15.0648
|
|
||||||
relay.tdw.lol,41.8781,-87.6298
|
|
||||||
nostr.nodesmap.com,59.3327,18.0656
|
|
||||||
relay.getvia.xyz,60.1699,24.9384
|
|
||||||
public.crostr.com,43.6532,-79.3832
|
|
||||||
relay.agorist.space,52.3734,4.89406
|
|
||||||
relay.lacrypta.ar,43.6532,-79.3832
|
|
||||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
|
||||||
relay.nuts.cash,52.3676,4.90414
|
|
||||||
relay.otrta.me,50.1109,8.68213
|
|
||||||
relay.satmaxt.xyz,43.6532,-79.3832
|
|
||||||
relay.earthly.city,34.1749,-118.54
|
|
||||||
relay.wisp.talk:443,49.4543,11.0746
|
|
||||||
mostro-p2p.tech,50.1109,8.68213
|
|
||||||
nostr.thalheim.io,60.1699,24.9384
|
|
||||||
relay.s-w.art,43.6532,-79.3832
|
|
||||||
nostr.chaima.info,50.1109,8.68213
|
|
||||||
articles.layer3.news,37.3387,-121.885
|
|
||||||
nostr2.girino.org:443,43.6532,-79.3832
|
|
||||||
relay.mostr.pub:443,43.6532,-79.3832
|
|
||||||
relay.ditto.pub:443,43.6532,-79.3832
|
|
||||||
relay.manneken.brussels,49.4543,11.0746
|
|
||||||
relay.kaleidoswap.com,50.8476,4.35717
|
|
||||||
nostrelay.circum.space,52.2245,8.826
|
|
||||||
relay.beginningend.com,35.2227,-97.4786
|
|
||||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
|
||||||
relay.fundstr.me,42.3601,-71.0589
|
|
||||||
relay.jmoose.rocks:443,37.4419,-122.143
|
|
||||||
relay.nostr.dev.br,48.1046,11.6002
|
|
||||||
relay.minibolt.info,43.6532,-79.3832
|
|
||||||
nostr.unkn0wn.world,46.8499,9.53287
|
|
||||||
vault.iris.to,43.6532,-79.3832
|
|
||||||
relay.guggero.org,46.5971,9.59652
|
|
||||||
relay.getsafebox.app,43.6532,-79.3832
|
|
||||||
relay.bitmacro.cloud,43.6532,-79.3832
|
|
||||||
relay.wavlake.com,41.2619,-95.8608
|
|
||||||
nostr.hekster.org:443,37.3986,-121.964
|
|
||||||
relay.lightning.pub,39.0438,-77.4874
|
|
||||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
|
||||||
soloco.nl,43.6532,-79.3832
|
|
||||||
nostr.hifish.org,47.4009,8.57814
|
|
||||||
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
|
|
||||||
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
|
||||||
relay.wavefunc.live:443,41.8781,-87.6298
|
|
||||||
porchlight.social,43.6532,-79.3832
|
|
||||||
relay.zone667.com,60.1699,24.9384
|
|
||||||
nostr.data.haus:443,50.4754,12.3683
|
|
||||||
relay.nexterz.com,43.6532,-79.3832
|
|
||||||
relay.sigit.io:443,50.4754,12.3683
|
|
||||||
nostr.girino.org,43.6532,-79.3832
|
|
||||||
nostr.liberty.fans,36.8767,-89.5879
|
|
||||||
wheat.happytavern.co,43.6532,-79.3832
|
|
||||||
relay.0xchat.com,43.6532,-79.3832
|
|
||||||
inbox.scuba323.com,40.8218,-74.45
|
|
||||||
nostr.vulpem.com,49.4543,11.0746
|
|
||||||
relay.damustr.com,43.6532,-79.3832
|
|
||||||
no.str.cr:443,8.96171,-83.5246
|
|
||||||
relay.vrtmrz.net:443,43.6532,-79.3832
|
|
||||||
vm-1734.lnvps.cloud,53.3498,-6.26031
|
|
||||||
testnet.samt.st,43.6532,-79.3832
|
|
||||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
|
||||||
nostr.sathoarder.com:443,48.5734,7.75211
|
|
||||||
relay.cosmicbolt.net,37.3986,-121.964
|
|
||||||
relay.staging.plebeian.market,51.5072,-0.127586
|
|
||||||
relayone.soundhsa.com,39.0997,-94.5786
|
|
||||||
relay.olas.app:443,60.1699,24.9384
|
|
||||||
relay.illuminodes.com,43.6532,-79.3832
|
|
||||||
relay.chorus.community:443,48.5333,10.7
|
|
||||||
relay.ohstr.com:443,43.6532,-79.3832
|
|
||||||
fanfares.nostr1.com:443,40.7057,-74.0136
|
|
||||||
nostr.planix.org,43.6532,-79.3832
|
|
||||||
schnorr.me,43.6532,-79.3832
|
|
||||||
relay.mostro.network,40.8302,-74.1299
|
|
||||||
nostr-verified.wellorder.net,45.5201,-122.99
|
|
||||||
ynostr.yael.at,60.1699,24.9384
|
|
||||||
nostr.wild-vibes.ts.net,48.8566,2.35222
|
|
||||||
relay.mypathtofire.de,42.8864,-78.8784
|
|
||||||
relay.klabo.world,47.674,-122.122
|
|
||||||
nostr.tagomago.me,42.3601,-71.0589
|
|
||||||
relay.veganostr.com:443,60.1699,24.9384
|
|
||||||
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
|
|
||||||
relay.loveisbitcoin.com,43.6532,-79.3832
|
|
||||||
bridge.tagomago.me,42.3601,-71.0589
|
|
||||||
relay-rpi.edufeed.org:443,49.4521,11.0767
|
|
||||||
relay.kilombino.com,43.6532,-79.3832
|
|
||||||
relay.sharegap.net,43.6532,-79.3832
|
|
||||||
nostr.yutakobayashi.com,43.6532,-79.3832
|
|
||||||
relay-na1.metanomalist.com,43.6532,-79.3832
|
|
||||||
public.crostr.com:443,43.6532,-79.3832
|
|
||||||
nostrride.io,37.3986,-121.964
|
|
||||||
nostr.bgbitcoin.club:443,50.4754,12.3683
|
|
||||||
nostr.christiansass.de,50.1109,8.68213
|
|
||||||
nos.lol:443,50.4754,12.3683
|
|
||||||
relay.pyramid.li,47.4093,8.46503
|
|
||||||
relay.mappingbitcoin.com,43.6532,-79.3832
|
|
||||||
nostr-relay.zimage.com,34.282,-118.439
|
|
||||||
offchain.pub:443,39.1585,-94.5728
|
|
||||||
nostr.myshosholoza.co.za:443,52.3676,4.90414
|
|
||||||
0x-nostr-relay.fly.dev,38.7946,-106.535
|
|
||||||
relay1.privkey.io,25.7617,-80.1918
|
|
||||||
chat-relay.zap-work.com:443,43.6532,-79.3832
|
|
||||||
relay.staging.plebeian.market:443,51.5072,-0.127586
|
|
||||||
nostr.robosats.org,64.1476,-21.9392
|
|
||||||
relay.endfiat.money:443,59.3327,18.0656
|
|
||||||
nostr.agh.ccwu.cc,43.6532,-79.3832
|
|
||||||
nostr.overmind.lol:443,43.6532,-79.3832
|
|
||||||
nostr.tac.lol,47.4748,-122.273
|
|
||||||
relay.ru.ac.th,13.7607,100.627
|
|
||||||
nostr-relay.xbytez.io,50.6924,3.20113
|
|
||||||
relay.islandbitcoin.com,12.8498,77.6545
|
|
||||||
relay5.bitransfer.org,43.6532,-79.3832
|
|
||||||
prl.plus,55.7628,37.5983
|
|
||||||
chat-relay.zap-work.com,43.6532,-79.3832
|
|
||||||
relay.satlantis.io,39.0438,-77.4874
|
|
||||||
nostr.bitcoiner.social:443,40.7608,-111.891
|
|
||||||
nostr.ac,38.958,-77.3592
|
|
||||||
relay.solomonstr.com,43.6532,-79.3832
|
|
||||||
strfry.apps3.slidestr.net,40.4167,-3.70329
|
|
||||||
relay.nostr.com,50.1109,8.68213
|
|
||||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
|
||||||
nostr.bond,50.1109,8.68213
|
|
||||||
relay.wavlake.com:443,41.2619,-95.8608
|
|
||||||
nos.lol,50.4754,12.3683
|
|
||||||
nostr.debate.report,50.1109,8.68213
|
|
||||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
|
||||||
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
|
||||||
relay.wisp.talk,49.4543,11.0746
|
|
||||||
nostr.tabordalab.com,60.1699,24.9384
|
|
||||||
relay.trotters.cc,43.6532,-79.3832
|
|
||||||
nostr.4rs.nl,49.0291,8.35696
|
|
||||||
relay.hivetalk.org,40.8302,-74.1299
|
|
||||||
spamspamspamspam.rest,43.6532,-79.3832
|
|
||||||
nostr.janx.com,43.6532,-79.3832
|
|
||||||
relay.mostr.pub,43.6532,-79.3832
|
|
||||||
relay.lightning.pub:443,39.0438,-77.4874
|
|
||||||
relay.samt.st,40.8302,-74.1299
|
|
||||||
bruh.samt.st,43.6532,-79.3832
|
|
||||||
relay.orangesync.tech,40.7128,-74.006
|
|
||||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
|
||||||
relay.notoshi.win,13.3396,100.93
|
|
||||||
relay.nostreon.com,60.1699,24.9384
|
|
||||||
nostr.purpura.cloud,43.6532,-79.3832
|
|
||||||
basspistol.org,49.0291,8.35696
|
|
||||||
relay.nostrfeed.com,60.1699,24.9384
|
|
||||||
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
|
||||||
nostr.21crypto.ch,47.5356,8.73209
|
|
||||||
nostr.pbfs.io,50.4754,12.3683
|
|
||||||
social.amanah.eblessing.co,48.1046,11.6002
|
|
||||||
relay.cosmicbolt.net:443,37.3986,-121.964
|
|
||||||
21milionidinostr.duckdns.org,41.8967,12.4822
|
|
||||||
fanfares.nostr1.com,40.7057,-74.0136
|
|
||||||
nostr.na.social,43.6532,-79.3832
|
|
||||||
nostr.data.haus,50.4754,12.3683
|
|
||||||
strfry.shock.network:443,39.0438,-77.4874
|
|
||||||
nostr.pbfs.io:443,50.4754,12.3683
|
|
||||||
relay.shadowbip.com,50.1109,8.68213
|
|
||||||
relay.ltgnet.work:8443,51.0511,-114.075
|
|
||||||
relay02.lnfi.network,35.6764,139.65
|
|
||||||
nostr.myshosholoza.co.za,52.3676,4.90414
|
|
||||||
relay.satsmarkt.club,52.6907,4.8181
|
|
||||||
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
|
||||||
auth.nostr1.com,40.7057,-74.0136
|
|
||||||
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
|
||||||
bitcoinostr.duckdns.org,38.9504,-0.14007
|
|
||||||
relayrs.notoshi.win:443,43.6532,-79.3832
|
|
||||||
nostr.whitenode45.ddns.net,40.55,-74.4758
|
|
||||||
relay.mccormick.cx:443,52.3563,4.95714
|
|
||||||
nostr.2b9t.xyz:443,34.0549,-118.243
|
|
||||||
relay.internationalright-wing.org,-22.4692,-48.9875
|
|
||||||
nostr.2b9t.xyz,34.0549,-118.243
|
|
||||||
relay.mitchelltribe.com,39.0438,-77.4874
|
|
||||||
relay.nostriot.com,41.5695,-83.9786
|
|
||||||
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
|
|
||||||
relay.sector01.com,41.4513,-81.7021
|
|
||||||
relay.nostx.io,43.6532,-79.3832
|
|
||||||
relay.fiatdenier.com,43.7787,-79.5393
|
|
||||||
relayone.soundhsa.com:443,39.0997,-94.5786
|
|
||||||
strfry.bonsai.com:443,39.0438,-77.4874
|
|
||||||
nostr.snowbla.de:443,50.4754,12.3683
|
|
||||||
relay.btcforplebs.com,43.6532,-79.3832
|
|
||||||
dev.relay.stream,43.6532,-79.3832
|
|
||||||
nostr-pub.wellorder.net,45.5201,-122.99
|
|
||||||
nostr.twinkle.lol,51.902,7.6657
|
|
||||||
nostr.carroarmato0.be,50.914,3.21378
|
|
||||||
bcast.girino.org,43.6532,-79.3832
|
|
||||||
treuzkas.branruz.com,48.8575,2.35138
|
|
||||||
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
|
||||||
relay.endfiat.money,59.3327,18.0656
|
|
||||||
relay.jmoose.rocks,37.4419,-122.143
|
|
||||||
relay.degmods.com,50.4754,12.3683
|
|
||||||
relay.ditto.pub,43.6532,-79.3832
|
|
||||||
nostrelay.circum.space:443,52.2245,8.826
|
|
||||||
relay.pocketnostr.com,40.8054,-74.0241
|
|
||||||
nostr.wecsats.io,43.6532,-79.3832
|
|
||||||
node.kommonzenze.de,49.4521,11.0767
|
|
||||||
nostr.sathoarder.com,48.5734,7.75211
|
|
||||||
chorus.mikedilger.com:444,-36.8906,174.794
|
|
||||||
cache.trustr.ing,43.6548,-79.3885
|
|
||||||
bitcoiner.social,40.7608,-111.891
|
|
||||||
relay.tdw.gg,34.0549,-118.243
|
|
||||||
relay.edufeed.org:443,49.4521,11.0767
|
|
||||||
relay.snotr.nl:49999,51.9758,4.31389
|
|
||||||
nostr.bgbitcoin.club,50.4754,12.3683
|
|
||||||
relay.bowlafterbowl.com,32.9483,-96.7299
|
|
||||||
relay-rpi.edufeed.org,49.4521,11.0767
|
|
||||||
x.kojira.io:443,43.6532,-79.3832
|
|
||||||
nostr.self-determined.de,51.4379,14.2355
|
|
||||||
schnorr.me:443,43.6532,-79.3832
|
|
||||||
nostr.computingcache.com,45.5341,-122.956
|
|
||||||
cs-relay.nostrdev.com,50.4754,12.3683
|
|
||||||
relay2.veganostr.com,60.1699,24.9384
|
|
||||||
relay.phostrich.com,40.55,-74.4758
|
|
||||||
relay.nostrian-conquest.com,41.223,-111.974
|
|
||||||
relay.flashapp.me,43.6548,-79.3885
|
|
||||||
relay.yoinekodo.jp,43.6532,-79.3832
|
|
||||||
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
|
||||||
freelay.sovbit.host,60.1699,24.9384
|
|
||||||
nostr.n7ekb.net,47.4941,-122.294
|
nostr.n7ekb.net,47.4941,-122.294
|
||||||
purplerelay.com:443,43.6532,-79.3832
|
relay.kilombino.com,43.6532,-79.3832
|
||||||
nostr.tbxnetworx.de,47.7228,9.96456
|
relay02.lnfi.network,35.6764,139.65
|
||||||
relay.bnos.space:443,43.6532,-79.3832
|
relay.liberbitworld.org,43.6532,-79.3832
|
||||||
relay.satsapp.me,50.4754,12.3683
|
articles.layer3.news,37.3387,-121.885
|
||||||
nostr-01.uid.ovh,50.9871,2.12554
|
|
||||||
tribune-panel-growing-noon.trycloudflare.com,43.6532,-79.3832
|
|
||||||
nostr.mom,50.4754,12.3683
|
|
||||||
no.str.cr,8.96171,-83.5246
|
|
||||||
nostr.spicyz.io,43.6532,-79.3832
|
|
||||||
memlay.v0l.io,53.3498,-6.26031
|
|
||||||
bucket.coracle.social,37.7775,-122.397
|
|
||||||
nostr.azzamo.net,52.2633,21.0283
|
|
||||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
|
||||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
|
||||||
conduitl2.fly.dev,38.7946,-106.535
|
|
||||||
relay.wasabiwallet.io,43.6532,-79.3832
|
|
||||||
portal-relay.pareto.space,49.0291,8.35696
|
|
||||||
strfry.ymir.cloud,43.6532,-79.3832
|
|
||||||
nostr.hoppe-relay.it.com,42.8864,-78.8784
|
|
||||||
nostr.red5d.dev,43.6532,-79.3832
|
|
||||||
strfry.shock.network,39.0438,-77.4874
|
|
||||||
relay.chorus.community,48.5333,10.7
|
|
||||||
relay.mitchelltribe.com:443,39.0438,-77.4874
|
|
||||||
relay.olas.app,60.1699,24.9384
|
|
||||||
testnet-relay.samt.st:443,40.8302,-74.1299
|
|
||||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
|
||||||
relay.gulugulu.moe,43.6532,-79.3832
|
|
||||||
relay.underorion.se,50.1109,8.68213
|
|
||||||
relay.mccormick.cx,52.3563,4.95714
|
|
||||||
top.testrelay.top,43.6532,-79.3832
|
|
||||||
nostr-02.uid.ovh,50.9871,2.12554
|
|
||||||
nostr.relay.hedwig.sh,60.1699,24.9384
|
|
||||||
nostr-01.yakihonne.com,1.32123,103.695
|
|
||||||
relay.nostr.net,43.6532,-79.3832
|
relay.nostr.net,43.6532,-79.3832
|
||||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
|
||||||
nrs-01.darkcloudarcade.com,39.0997,-94.5786
|
|
||||||
relay.trotters.cc:443,43.6532,-79.3832
|
|
||||||
strfry.bonsai.com,39.0438,-77.4874
|
|
||||||
relay.snort.social,53.3498,-6.26031
|
|
||||||
nostr.hekster.org,37.3986,-121.964
|
|
||||||
relay.mleku.dev,32.7767,-96.797
|
|
||||||
nostr-relay.xbytez.io:443,50.6924,3.20113
|
|
||||||
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
|
|
||||||
nostr.davenov.com,50.1109,8.68213
|
|
||||||
relay.chatbett.de,40.7128,-74.006
|
|
||||||
relayone.geektank.ai:443,39.0997,-94.5786
|
|
||||||
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
|
||||||
nostr.mom:443,50.4754,12.3683
|
|
||||||
nostr-relay.corb.net:443,39.6478,-104.988
|
|
||||||
relay.gulugulu.moe:443,43.6532,-79.3832
|
|
||||||
nostr.thebiglake.org,32.71,-96.6745
|
|
||||||
relay.nearhood.co.uk,51.5134,-0.0890675
|
|
||||||
relay1.nostrchat.io,60.1699,24.9384
|
|
||||||
rele.speyhard.fi,50.1109,8.68213
|
|
||||||
relay.bornheimer.app,50.1109,8.68213
|
|
||||||
nostr.fullstackcash.net,45.5201,-122.99
|
|
||||||
relay2.fiatdenier.com,50.1013,8.62643
|
|
||||||
relay.solife.me,43.6532,-79.3832
|
|
||||||
syb.lol,34.0549,-118.243
|
|
||||||
dev-relay.nostreon.com,60.1699,24.9384
|
|
||||||
relay.agorist.space:443,52.3734,4.89406
|
|
||||||
nostr.easycryptosend.it,43.6532,-79.3832
|
nostr.easycryptosend.it,43.6532,-79.3832
|
||||||
x.kojira.io,43.6532,-79.3832
|
strfry.bonsai.com,39.0438,-77.4874
|
||||||
adre.su,59.9311,30.3609
|
relay.olas.app:443,60.1699,24.9384
|
||||||
offchain.bostr.online,43.6532,-79.3832
|
relay.sharegap.net,43.6532,-79.3832
|
||||||
relay.libernet.app,43.6532,-79.3832
|
relay.ditto.pub,43.6532,-79.3832
|
||||||
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
|
nostrride.io,37.3986,-121.964
|
||||||
nostr.rtvslawenia.com,49.4543,11.0746
|
relay.favillakey.io,-27.4705,153.026
|
||||||
relay.layer.systems,49.0291,8.35695
|
nostr-relay.xbytez.io,50.6924,3.20113
|
||||||
relay.arx-ccn.com,50.4754,12.3683
|
|
||||||
staging.yabu.me,35.6092,139.73
|
|
||||||
relay.vrtmrz.net,43.6532,-79.3832
|
|
||||||
nostr.bitcoiner.social,40.7608,-111.891
|
|
||||||
nostr.88mph.life,52.1941,-2.21905
|
|
||||||
relay.pprgb.app,52.52,13.405
|
|
||||||
relay.aarpia.com,37.3986,-121.964
|
|
||||||
nostrcity-club.fly.dev,38.7946,-106.535
|
|
||||||
buzz.ac2n-share.kozow.com,45.764,4.83566
|
|
||||||
relay.fckstate.net,59.3293,18.0686
|
|
||||||
nostr.thalheim.io:443,60.1699,24.9384
|
|
||||||
nostr.carroarmato0.be:443,50.914,3.21378
|
|
||||||
nostrcity-club.fly.dev:443,38.7946,-106.535
|
|
||||||
relay.routstr.com,59.4016,17.9455
|
|
||||||
nostr-dev.wellorder.net,45.5201,-122.99
|
|
||||||
relay.directsponsor.net,42.8864,-78.8784
|
|
||||||
relay.mwaters.net,50.9871,2.12554
|
|
||||||
relay.littlebitstudios.com,43.6532,-79.3832
|
|
||||||
relay.ohstr.com,43.6532,-79.3832
|
|
||||||
testnet-relay.samt.st,40.8302,-74.1299
|
|
||||||
relay.bullishbounty.com,43.6532,-79.3832
|
|
||||||
relay.nostrmap.net:443,60.1699,24.9384
|
relay.nostrmap.net:443,60.1699,24.9384
|
||||||
relay.piazza.today,48.122,11.589
|
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
|
||||||
|
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
|
||||||
|
|||||||
|
@ -29,23 +29,9 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
|
|||||||
companion object {
|
companion object {
|
||||||
val NONE = PeerCapabilities(0)
|
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. */
|
/** Noise-encrypted private BitchatFilePacket using payload type 0x20. */
|
||||||
val PRIVATE_MEDIA = PeerCapabilities(1L shl 8)
|
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. */
|
/** Capabilities implemented by this Android build. */
|
||||||
val LOCAL_SUPPORTED = PRIVATE_MEDIA
|
val LOCAL_SUPPORTED = PRIVATE_MEDIA
|
||||||
|
|
||||||
|
|||||||
@ -3,11 +3,8 @@ package com.bitchat.android.nostr
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -21,8 +18,7 @@ class GeohashMessageHandler(
|
|||||||
private val repo: GeohashRepository,
|
private val repo: GeohashRepository,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val dataManager: com.bitchat.android.ui.DataManager,
|
private val dataManager: com.bitchat.android.ui.DataManager,
|
||||||
private val addChannelMessage: (String, BitchatMessage) -> Unit,
|
private val addChannelMessage: (String, BitchatMessage) -> Unit
|
||||||
private val signatureVerificationDispatcher: CoroutineDispatcher = Dispatchers.Default
|
|
||||||
) {
|
) {
|
||||||
companion object { private const val TAG = "GeohashMessageHandler" }
|
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
|
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)
|
val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1)
|
||||||
if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch
|
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
|
if (dedupe(event.id)) return@launch
|
||||||
|
|
||||||
// PoW validation (if enabled) - apply to chat messages primarily
|
// PoW validation (if enabled) - apply to chat messages primarily
|
||||||
|
|||||||
@ -416,11 +416,6 @@ class LocationNotesManager private constructor() {
|
|||||||
Log.v(TAG, "Ignoring non-text-note event: kind=${event.kind}")
|
Log.v(TAG, "Ignoring non-text-note event: kind=${event.kind}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event.isValidSignature()) {
|
|
||||||
Log.w(TAG, "Rejecting note ${event.id.take(8)}... with invalid signature")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for geohash tag
|
// Check for geohash tag
|
||||||
val geohashTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }
|
val geohashTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }
|
||||||
|
|||||||
@ -282,11 +282,6 @@ class NostrClient private constructor(private val context: Context) {
|
|||||||
handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit
|
handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit
|
||||||
) {
|
) {
|
||||||
try {
|
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
|
// Check Proof of Work validation for incoming geohash events
|
||||||
val powSettings = PoWPreferenceManager.getCurrentSettings()
|
val powSettings = PoWPreferenceManager.getCurrentSettings()
|
||||||
if (powSettings.enabled && powSettings.difficulty > 0) {
|
if (powSettings.enabled && powSettings.difficulty > 0) {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package com.bitchat.android.protocol
|
package com.bitchat.android.protocol
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import kotlinx.parcelize.IgnoredOnParcel
|
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
@ -35,27 +34,6 @@ object SpecialRecipients {
|
|||||||
val BROADCAST = ByteArray(8) { 0xFF.toByte() } // All 0xFF = broadcast
|
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
|
* Binary packet format - 100% backward compatible with iOS version
|
||||||
*
|
*
|
||||||
@ -83,10 +61,7 @@ data class BitchatPacket(
|
|||||||
val payload: ByteArray,
|
val payload: ByteArray,
|
||||||
var signature: ByteArray? = null, // Changed from val to var for packet signing
|
var signature: ByteArray? = null, // Changed from val to var for packet signing
|
||||||
var ttl: UByte,
|
var ttl: UByte,
|
||||||
var route: List<ByteArray>? = null, // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient
|
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
|
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -125,8 +100,7 @@ data class BitchatPacket(
|
|||||||
payload = payload,
|
payload = payload,
|
||||||
signature = null, // Remove signature for signing
|
signature = null, // Remove signature for signing
|
||||||
route = route,
|
route = route,
|
||||||
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS, // Use fixed TTL=0 for signing to ensure relay compatibility
|
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
|
|
||||||
)
|
)
|
||||||
return BinaryProtocol.encode(unsignedPacket)
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
}
|
}
|
||||||
@ -240,17 +214,7 @@ object BinaryProtocol {
|
|||||||
var originalPayloadSize: Int? = null
|
var originalPayloadSize: Int? = null
|
||||||
var isCompressed = false
|
var isCompressed = false
|
||||||
|
|
||||||
// Re-encode of a decoded packet: reuse the originator's bytes (see WirePayload).
|
if (CompressionUtil.shouldCompress(payload)) {
|
||||||
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)) {
|
|
||||||
CompressionUtil.compress(payload)?.let { compressedPayload ->
|
CompressionUtil.compress(payload)?.let { compressedPayload ->
|
||||||
originalPayloadSize = payload.size
|
originalPayloadSize = payload.size
|
||||||
payload = compressedPayload
|
payload = compressedPayload
|
||||||
@ -486,8 +450,6 @@ object BinaryProtocol {
|
|||||||
} else null
|
} else null
|
||||||
|
|
||||||
// Payload
|
// Payload
|
||||||
// Kept so the packet can be re-encoded byte-identically (see WirePayload).
|
|
||||||
var receivedCompressed: ByteArray? = null
|
|
||||||
val payload = if (isCompressed) {
|
val payload = if (isCompressed) {
|
||||||
val lengthFieldBytes = if (version >= 2u.toUByte()) 4 else 2
|
val lengthFieldBytes = if (version >= 2u.toUByte()) 4 else 2
|
||||||
if (payloadLength.toInt() < lengthFieldBytes) return null
|
if (payloadLength.toInt() < lengthFieldBytes) return null
|
||||||
@ -526,8 +488,6 @@ object BinaryProtocol {
|
|||||||
val expandedPayload = CompressionUtil.withDecompressionResources(resourceBytes) {
|
val expandedPayload = CompressionUtil.withDecompressionResources(resourceBytes) {
|
||||||
val compressedPayload = ByteArray(compressedSize)
|
val compressedPayload = ByteArray(compressedSize)
|
||||||
buffer.get(compressedPayload)
|
buffer.get(compressedPayload)
|
||||||
// Captured here so the allocation stays inside the reservation.
|
|
||||||
receivedCompressed = compressedPayload
|
|
||||||
decompress(compressedPayload, originalSize)
|
decompress(compressedPayload, originalSize)
|
||||||
} ?: return null
|
} ?: return null
|
||||||
if (expandedPayload.size != originalSize) {
|
if (expandedPayload.size != originalSize) {
|
||||||
@ -560,12 +520,7 @@ object BinaryProtocol {
|
|||||||
payload = payload,
|
payload = payload,
|
||||||
signature = signature,
|
signature = signature,
|
||||||
ttl = ttl,
|
ttl = ttl,
|
||||||
route = route,
|
route = route
|
||||||
wirePayload = WirePayload(
|
|
||||||
bytes = receivedCompressed ?: payload,
|
|
||||||
compressed = receivedCompressed != null,
|
|
||||||
forPayload = payload
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@ -317,7 +317,6 @@ class ConversationRepository internal constructor(
|
|||||||
true
|
true
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
Log.e(TAG, "Unable to synchronously clear private conversations", error)
|
Log.e(TAG, "Unable to synchronously clear private conversations", error)
|
||||||
database.destroyStorage()
|
|
||||||
_storeState.value = ConversationStoreState.Error(
|
_storeState.value = ConversationStoreState.Error(
|
||||||
error.message ?: "Unable to erase conversations"
|
error.message ?: "Unable to erase conversations"
|
||||||
)
|
)
|
||||||
@ -1098,22 +1097,6 @@ internal class ConversationDatabase(
|
|||||||
writableDatabase.rawQuery("PRAGMA incremental_vacuum", null).use { }
|
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> {
|
fun pruneToRetentionLimits(): Set<String> {
|
||||||
val db = writableDatabase
|
val db = writableDatabase
|
||||||
val orphanedMediaPaths = linkedSetOf<String>()
|
val orphanedMediaPaths = linkedSetOf<String>()
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
package com.bitchat.android.ui
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
import com.bitchat.android.geohash.ChannelID
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
@ -17,8 +16,7 @@ class ChannelManager(
|
|||||||
private val state: ChatState,
|
private val state: ChatState,
|
||||||
private val messageManager: MessageManager,
|
private val messageManager: MessageManager,
|
||||||
private val dataManager: DataManager,
|
private val dataManager: DataManager,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope
|
||||||
private val onSwitchToMeshLocation: () -> Unit = {}
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// Channel encryption and security
|
// Channel encryption and security
|
||||||
@ -111,8 +109,6 @@ class ChannelManager(
|
|||||||
fun switchToChannel(channel: String?) {
|
fun switchToChannel(channel: String?) {
|
||||||
state.setCurrentChannel(channel)
|
state.setCurrentChannel(channel)
|
||||||
state.setSelectedPrivateChatPeer(null)
|
state.setSelectedPrivateChatPeer(null)
|
||||||
state.setSelectedLocationChannel(ChannelID.Mesh)
|
|
||||||
onSwitchToMeshLocation()
|
|
||||||
|
|
||||||
// Clear unread count
|
// Clear unread count
|
||||||
channel?.let { ch ->
|
channel?.let { ch ->
|
||||||
|
|||||||
@ -140,17 +140,7 @@ class ChatViewModel(
|
|||||||
private val conversationListPreferences =
|
private val conversationListPreferences =
|
||||||
com.bitchat.android.services.ConversationListPreferences.getInstance(getApplication())
|
com.bitchat.android.services.ConversationListPreferences.getInstance(getApplication())
|
||||||
private val messageManager = MessageManager(state)
|
private val messageManager = MessageManager(state)
|
||||||
private val channelManager = ChannelManager(
|
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
|
||||||
state,
|
|
||||||
messageManager,
|
|
||||||
dataManager,
|
|
||||||
viewModelScope,
|
|
||||||
onSwitchToMeshLocation = {
|
|
||||||
com.bitchat.android.geohash.LocationChannelManager
|
|
||||||
.getInstance(getApplication())
|
|
||||||
.select(com.bitchat.android.geohash.ChannelID.Mesh)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create Noise session delegate for clean dependency injection
|
// Create Noise session delegate for clean dependency injection
|
||||||
private val noiseSessionDelegate = object : NoiseSessionDelegate {
|
private val noiseSessionDelegate = object : NoiseSessionDelegate {
|
||||||
|
|||||||
@ -942,61 +942,6 @@ class BinaryProtocolTest {
|
|||||||
assertTrue("Payload must match", payload.contentEquals(decoded.payload))
|
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
|
* v2 compression bomb is rejected
|
||||||
*
|
*
|
||||||
|
|||||||
@ -24,12 +24,11 @@ import org.robolectric.RobolectricTestRunner
|
|||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class CommandProcessorTest {
|
class CommandProcessorTest() {
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
private val testDispatcher = UnconfinedTestDispatcher()
|
||||||
private val testDispatcher = UnconfinedTestDispatcher()
|
private val testScope = TestScope(testDispatcher)
|
||||||
private val testScope = TestScope(testDispatcher)
|
|
||||||
private val chatState = ChatState(scope = testScope)
|
private val chatState = ChatState(scope = testScope)
|
||||||
private lateinit var commandProcessor: CommandProcessor
|
private lateinit var commandProcessor: CommandProcessor
|
||||||
|
|
||||||
@ -64,11 +63,11 @@ class CommandProcessorTest {
|
|||||||
val channel = "channel-1"
|
val channel = "channel-1"
|
||||||
|
|
||||||
val result = commandProcessor.processCommand(
|
val result = commandProcessor.processCommand(
|
||||||
command = "/j $channel",
|
command = "/j $channel",
|
||||||
meshService = meshService,
|
meshService = meshService,
|
||||||
myPeerID = "peer-id",
|
myPeerID = "peer-id",
|
||||||
onSendMessage = { a, b, c -> { } },
|
onSendMessage = { a, b, c -> { } },
|
||||||
viewModel = null
|
viewModel = null
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(result, true)
|
assertEquals(result, true)
|
||||||
@ -164,24 +163,6 @@ class CommandProcessorTest {
|
|||||||
assertEquals(0, chatState.getMessagesValue().size)
|
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
|
@Test
|
||||||
fun `clearSuggestions hides the command suggestion popup`() {
|
fun `clearSuggestions hides the command suggestion popup`() {
|
||||||
// Typing "/" opens the command popup.
|
// Typing "/" opens the command popup.
|
||||||
@ -206,6 +187,5 @@ class CommandProcessorTest {
|
|||||||
commandProcessor.clearSuggestions()
|
commandProcessor.clearSuggestions()
|
||||||
assertFalse(chatState.getShowMentionSuggestionsValue())
|
assertFalse(chatState.getShowMentionSuggestionsValue())
|
||||||
assertTrue(chatState.getMentionSuggestionsValue().isEmpty())
|
assertTrue(chatState.getMentionSuggestionsValue().isEmpty())
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,38 +12,9 @@ class IdentityAnnouncementTest {
|
|||||||
private val signingKey = ByteArray(32) { 0x22 }
|
private val signingKey = ByteArray(32) { 0x22 }
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `capability assignments and encoding match iOS`() {
|
fun `private media capability uses iOS little-endian bytes`() {
|
||||||
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())
|
|
||||||
assertArrayEquals(byteArrayOf(0x00, 0x01), PeerCapabilities.PRIVATE_MEDIA.encoded())
|
assertArrayEquals(byteArrayOf(0x00, 0x01), PeerCapabilities.PRIVATE_MEDIA.encoded())
|
||||||
|
assertTrue(PeerCapabilities.decode(byteArrayOf(0x00, 0x01)).contains(PeerCapabilities.PRIVATE_MEDIA))
|
||||||
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()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -100,6 +71,5 @@ class IdentityAnnouncementTest {
|
|||||||
.capabilities!!
|
.capabilities!!
|
||||||
.contains(PeerCapabilities.PRIVATE_MEDIA)
|
.contains(PeerCapabilities.PRIVATE_MEDIA)
|
||||||
)
|
)
|
||||||
assertEquals(PeerCapabilities.PRIVATE_MEDIA, PeerCapabilities.LOCAL_SUPPORTED)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -36,21 +36,17 @@ class NostrBackgroundEventProcessorTest {
|
|||||||
fun `cold start processes more events than the removed handoff queue capacity`() = runBlocking {
|
fun `cold start processes more events than the removed handoff queue capacity`() = runBlocking {
|
||||||
val application = ApplicationProvider.getApplicationContext<Application>()
|
val application = ApplicationProvider.getApplicationContext<Application>()
|
||||||
val processor = NostrBackgroundEventProcessor(application, scope)
|
val processor = NostrBackgroundEventProcessor(application, scope)
|
||||||
val identity = NostrIdentity.generate()
|
|
||||||
val expectedIds = mutableSetOf<String>()
|
|
||||||
|
|
||||||
repeat(300) { index ->
|
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(
|
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"
|
geohash = "u4pruy"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -62,7 +58,7 @@ class NostrBackgroundEventProcessorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
expectedIds,
|
(0 until 300).map { "cold-start-$it" }.toSet(),
|
||||||
AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().map { it.id }.toSet()
|
AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().map { it.id }.toSet()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
@Test
|
||||||
fun `version one plaintext database migrates without losing history`() {
|
fun `version one plaintext database migrates without losing history`() {
|
||||||
database.close()
|
database.close()
|
||||||
|
|||||||
@ -29,16 +29,9 @@ Existing TLVs (unchanged):
|
|||||||
|
|
||||||
Other optional extension:
|
Other optional extension:
|
||||||
|
|
||||||
- `0x05` CAPABILITIES: Minimal little-endian feature bitfield. Assigned bits
|
- `0x05` CAPABILITIES: Minimal little-endian feature bitfield. Bit 8 advertises
|
||||||
match iOS: 0 prekeys, 1 Wi-Fi bulk, 2 gateway, 3 private groups, 4 board,
|
authenticated Noise private media (`PRIVATE_MEDIA_V1`); its exact value is
|
||||||
5 vouch, 6 mesh diagnostics, 7 bridge, 8 authenticated Noise private media,
|
`00 01`. An empty value is valid and means no advertised capabilities.
|
||||||
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`.
|
|
||||||
|
|
||||||
New TLV (optional):
|
New TLV (optional):
|
||||||
|
|
||||||
@ -76,12 +69,6 @@ This matches the on‑wire 8‑byte `senderID`/`recipientID` encoding used in th
|
|||||||
security-sensitive bit is trusted only after the signed announcement key is
|
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
|
bound to the matching remote static key from a live Noise handshake; see
|
||||||
`PRIVATE_MEDIA_V1.md`.
|
`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:
|
- If a `0x04` TLV is present:
|
||||||
- Interpret the value as `N = length / 8` peer IDs (ignore trailing non‑aligned bytes).
|
- Interpret the value as `N = length / 8` peer IDs (ignore trailing non‑aligned bytes).
|
||||||
- Each 8‑byte chunk is decoded back to a 16‑hex‑char peer ID string (lowercase).
|
- Each 8‑byte chunk is decoded back to a 16‑hex‑char peer ID string (lowercase).
|
||||||
|
|||||||
@ -31,15 +31,10 @@ GitHub/Google Play publication checklist.
|
|||||||
|
|
||||||
The build uses a clean source tree, an isolated Gradle user home, UTC, a stable
|
The build uses a clean source tree, an isolated Gradle user home, UTC, a stable
|
||||||
locale, `SOURCE_DATE_EPOCH` from the Git commit, no Gradle build or configuration
|
locale, `SOURCE_DATE_EPOCH` from the Git commit, no Gradle build or configuration
|
||||||
cache, fresh tasks, a non-incremental in-process Kotlin compiler, and R8's
|
cache, fresh tasks, and a non-incremental in-process Kotlin compiler. Native
|
||||||
deterministic-debugging mode. The R8 mode uses one compiler thread and disables
|
builds remap source paths and release validation rejects host paths in packaged
|
||||||
randomized input shuffling. The regular R8 ProGuard map remains embedded in each
|
libraries. The container overlays a canonical `local.properties`, so an ignored
|
||||||
AAB. Kotlin 2.4.10's optional Compose group-key mapping augmentation is disabled
|
Android Studio file cannot redirect Gradle to a host-specific SDK.
|
||||||
because its duplicate-key selection depends on unspecified class-file iteration
|
|
||||||
order across clean builds. Native builds remap source paths and release
|
|
||||||
validation rejects host paths in packaged libraries. The container overlays a
|
|
||||||
canonical `local.properties`, so an ignored Android Studio file cannot redirect
|
|
||||||
Gradle to a host-specific SDK.
|
|
||||||
|
|
||||||
AGP's embedded VCS record is disabled because its Git discovery depends on the
|
AGP's embedded VCS record is disabled because its Git discovery depends on the
|
||||||
host checkout layout. The canonical `BUILDINFO.json` and GitHub provenance
|
host checkout layout. The canonical `BUILDINFO.json` and GitHub provenance
|
||||||
@ -59,10 +54,8 @@ The authoritative pins are:
|
|||||||
|
|
||||||
## Reproduce a release locally
|
## Reproduce a release locally
|
||||||
|
|
||||||
Requirements are Git, Docker with Linux/amd64 support, more than 8 GiB of memory
|
Requirements are Git, Docker with Linux/amd64 support, and enough free space for
|
||||||
available to the Docker VM (16 GiB recommended), and enough free space for the
|
the Android and Gradle images and dependencies.
|
||||||
Android and Gradle images and dependencies. R8's single-threaded deterministic
|
|
||||||
mode can exceed an 8 GiB Docker memory limit while optimizing the phone app.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/permissionlesstech/bitchat-android.git
|
git clone https://github.com/permissionlesstech/bitchat-android.git
|
||||||
|
|||||||
@ -2147,9 +2147,6 @@
|
|||||||
<artifact name="aapt2-9.3.1-15703166-osx.jar">
|
<artifact name="aapt2-9.3.1-15703166-osx.jar">
|
||||||
<sha256 value="1e35bc2ce18c3aae840be2a29659ce50d6043e907a44d98ee1cf375d044fa29c" origin="Generated by Gradle"/>
|
<sha256 value="1e35bc2ce18c3aae840be2a29659ce50d6043e907a44d98ee1cf375d044fa29c" origin="Generated by Gradle"/>
|
||||||
</artifact>
|
</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">
|
<artifact name="aapt2-9.3.1-15703166.pom">
|
||||||
<sha256 value="edb82dcbe738ca78b31612cb307bf99e5f02f25c8125048f933e792fd7778beb" origin="Generated by Gradle"/>
|
<sha256 value="edb82dcbe738ca78b31612cb307bf99e5f02f25c8125048f933e792fd7778beb" origin="Generated by Gradle"/>
|
||||||
</artifact>
|
</artifact>
|
||||||
@ -4914,9 +4911,6 @@
|
|||||||
</artifact>
|
</artifact>
|
||||||
</component>
|
</component>
|
||||||
<component group="org.junit" name="junit-bom" version="5.9.2">
|
<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">
|
<artifact name="junit-bom-5.9.2.pom">
|
||||||
<sha256 value="2ed07d65845131f5336a86476c9a4056b59d0b58b9815ab3679bb0f36f35f705" origin="Generated by Gradle"/>
|
<sha256 value="2ed07d65845131f5336a86476c9a4056b59d0b58b9815ab3679bb0f36f35f705" origin="Generated by Gradle"/>
|
||||||
</artifact>
|
</artifact>
|
||||||
|
|||||||
@ -42,9 +42,6 @@ if ! [[ "$SOURCE_DATE_EPOCH" =~ ^[0-9]+$ ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
gradle_args=(
|
gradle_args=(
|
||||||
# R8 otherwise randomizes input traversal and uses multiple compiler threads.
|
|
||||||
# The optimized DEX can still match while its embedded ProGuard map differs.
|
|
||||||
-Dcom.android.tools.r8.deterministicdebugging=1
|
|
||||||
-Pkotlin.compiler.execution.strategy=in-process
|
-Pkotlin.compiler.execution.strategy=in-process
|
||||||
-Pkotlin.incremental=false
|
-Pkotlin.incremental=false
|
||||||
--no-build-cache
|
--no-build-cache
|
||||||
|
|||||||
@ -19,8 +19,8 @@ android {
|
|||||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||||
// Wear releases use a separate high range because Play requires every artifact in
|
// Wear releases use a separate high range because Play requires every artifact in
|
||||||
// one application ID to have a unique version code across all form factors.
|
// one application ID to have a unique version code across all form factors.
|
||||||
versionCode = 1_000_000_003
|
versionCode = 1_000_000_001
|
||||||
versionName = "0.1.2"
|
versionName = "0.1.0"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
@ -65,13 +65,6 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
composeCompiler {
|
|
||||||
// Kotlin 2.4.10's optional Compose group-key mapping depends on unspecified
|
|
||||||
// class-file iteration order. Keep the normal R8 mapping, but omit that
|
|
||||||
// augmentation until its producer is deterministic across clean builds.
|
|
||||||
includeComposeMappingFile.set(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared bitchat protocol stack: compiled from :app sources in place (never moved/copied by hand).
|
// Shared bitchat protocol stack: compiled from :app sources in place (never moved/copied by hand).
|
||||||
// AGP's source directory sets no longer support include/exclude filters, so a Sync task
|
// AGP's source directory sets no longer support include/exclude filters, so a Sync task
|
||||||
// materializes a filtered mirror into build/sharedSrc and that directory is added as a source
|
// materializes a filtered mirror into build/sharedSrc and that directory is added as a source
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import androidx.compose.animation.slideInVertically
|
|||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@ -20,20 +21,13 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Rect
|
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.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.changedToUp
|
import androidx.compose.ui.input.pointer.changedToUp
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
|
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
|
||||||
@ -50,7 +44,6 @@ import com.bitchat.watch.ui.theme.ChatVisualTokens
|
|||||||
import com.bitchat.watch.ui.theme.LocalBitchatPalette
|
import com.bitchat.watch.ui.theme.LocalBitchatPalette
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlin.math.sign
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shared chat body for global chat and DM threads, following the classic messenger
|
* The shared chat body for global chat and DM threads, following the classic messenger
|
||||||
@ -89,34 +82,35 @@ fun ChatScaffold(
|
|||||||
previousCount = messages.size
|
previousCount = messages.size
|
||||||
}
|
}
|
||||||
|
|
||||||
// Follow intent is changed only by an actual user scroll away from the newest item or by
|
// One state drives both overlays: visible at the bottom or when scrolling toward it,
|
||||||
// reaching the end again. A new item temporarily makes canScrollForward true before layout;
|
// hidden when scrolling up into history. The 24px (~12dp) threshold is deliberately
|
||||||
// treating that transient range change as user intent breaks automatic following.
|
// small so the controls answer every flick immediately.
|
||||||
var followNewest by remember { mutableStateOf(true) }
|
var atNewest by remember { mutableStateOf(true) }
|
||||||
val controlsVisible = remember { mutableStateOf(true) }
|
val controlsVisible = remember { mutableStateOf(true) }
|
||||||
LaunchedEffect(columnState) {
|
LaunchedEffect(columnState) {
|
||||||
var lastPosition = -1
|
var lastPosition = -1
|
||||||
var scrollIntent = ChatScrollIntentState()
|
|
||||||
snapshotFlow {
|
snapshotFlow {
|
||||||
val first = columnState.layoutInfo.visibleItems.firstOrNull()
|
val first = columnState.layoutInfo.visibleItems.firstOrNull()
|
||||||
ChatScrollSnapshot(
|
Triple(columnState.canScrollForward, first?.index ?: 0, first?.offset ?: 0)
|
||||||
canScrollForward = columnState.canScrollForward,
|
}.collect { (canScrollForward, index, offset) ->
|
||||||
isScrollInProgress = columnState.isScrollInProgress,
|
val position = index * 100_000 + offset
|
||||||
position = (first?.index ?: 0) * 100_000 + (first?.offset ?: 0)
|
atNewest = !canScrollForward
|
||||||
)
|
if (!canScrollForward) {
|
||||||
}.collect { snapshot ->
|
controlsVisible.value = true
|
||||||
scrollIntent = updatedChatScrollIntent(
|
} else if (lastPosition >= 0) {
|
||||||
current = scrollIntent,
|
when {
|
||||||
snapshot = snapshot,
|
position > lastPosition + 24 -> controlsVisible.value = true
|
||||||
previousPosition = lastPosition
|
position < lastPosition - 24 -> controlsVisible.value = false
|
||||||
)
|
}
|
||||||
followNewest = scrollIntent.followsNewest
|
}
|
||||||
controlsVisible.value = scrollIntent.controlsVisible
|
lastPosition = position
|
||||||
lastPosition = snapshot.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) {
|
LaunchedEffect(columnState, messages.size) {
|
||||||
if (messages.isNotEmpty() && followNewest) {
|
if (messages.isNotEmpty() && followNewest) {
|
||||||
val expectedSingleMessageKey = messages.singleOrNull()?.id
|
val expectedSingleMessageKey = messages.singleOrNull()?.id
|
||||||
@ -161,50 +155,6 @@ internal data class MeasuredChatLayout(
|
|||||||
val singleVisibleItemKey: Any?
|
val singleVisibleItemKey: Any?
|
||||||
)
|
)
|
||||||
|
|
||||||
internal data class ChatScrollSnapshot(
|
|
||||||
val canScrollForward: Boolean,
|
|
||||||
val isScrollInProgress: Boolean,
|
|
||||||
val position: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
internal data class ChatScrollIntentState(
|
|
||||||
val followsNewest: Boolean = true,
|
|
||||||
val controlsVisible: Boolean = true,
|
|
||||||
val accumulatedDeltaPx: Int = 0
|
|
||||||
)
|
|
||||||
|
|
||||||
internal fun updatedChatScrollIntent(
|
|
||||||
current: ChatScrollIntentState,
|
|
||||||
snapshot: ChatScrollSnapshot,
|
|
||||||
previousPosition: Int
|
|
||||||
): ChatScrollIntentState {
|
|
||||||
if (!snapshot.canScrollForward) return ChatScrollIntentState()
|
|
||||||
if (!snapshot.isScrollInProgress || previousPosition < 0) return current
|
|
||||||
|
|
||||||
val delta = snapshot.position - previousPosition
|
|
||||||
val accumulatedDelta = when {
|
|
||||||
delta == 0 -> current.accumulatedDeltaPx
|
|
||||||
current.accumulatedDeltaPx == 0 ||
|
|
||||||
current.accumulatedDeltaPx.sign == delta.sign ->
|
|
||||||
current.accumulatedDeltaPx + delta
|
|
||||||
else -> delta
|
|
||||||
}
|
|
||||||
val movedAway = accumulatedDelta <= -CHAT_SCROLL_DIRECTION_THRESHOLD_PX
|
|
||||||
val movedTowardNewest = accumulatedDelta >= CHAT_SCROLL_DIRECTION_THRESHOLD_PX
|
|
||||||
|
|
||||||
return current.copy(
|
|
||||||
followsNewest = current.followsNewest && !movedAway,
|
|
||||||
controlsVisible = when {
|
|
||||||
movedAway -> false
|
|
||||||
movedTowardNewest -> true
|
|
||||||
else -> current.controlsVisible
|
|
||||||
},
|
|
||||||
// Keep sub-threshold movement across discrete rotary events. Once intent is clear,
|
|
||||||
// start a fresh accumulator so reversing direction gets the same threshold treatment.
|
|
||||||
accumulatedDeltaPx = if (movedAway || movedTowardNewest) 0 else accumulatedDelta
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal suspend fun scrollToNewestAfterItemsMeasured(
|
internal suspend fun scrollToNewestAfterItemsMeasured(
|
||||||
expectedItemCount: Int,
|
expectedItemCount: Int,
|
||||||
expectedSingleMessageKey: Any?,
|
expectedSingleMessageKey: Any?,
|
||||||
@ -235,7 +185,7 @@ private fun ChatBody(
|
|||||||
val palette = LocalBitchatPalette.current
|
val palette = LocalBitchatPalette.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val transformationSpec = rememberTransformationSpec()
|
val transformationSpec = rememberTransformationSpec()
|
||||||
val isScreenRound = LocalConfiguration.current.isScreenRound
|
|
||||||
// Slide-to-cancel: while recording, the finger's position is tracked globally; the
|
// Slide-to-cancel: while recording, the finger's position is tracked globally; the
|
||||||
// overlay's mic button reports its bounds and becomes the cancel target when the
|
// overlay's mic button reports its bounds and becomes the cancel target when the
|
||||||
// finger hovers it (with generous slack so the snap engages on approach).
|
// finger hovers it (with generous slack so the snap engages on approach).
|
||||||
@ -306,65 +256,16 @@ private fun ChatBody(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
ScreenScaffold(scrollState = columnState) { scaffoldPadding ->
|
ScreenScaffold(scrollState = columnState) {
|
||||||
val layoutDirection = LocalLayoutDirection.current
|
|
||||||
TransformingLazyColumn(
|
TransformingLazyColumn(
|
||||||
state = columnState,
|
state = columnState,
|
||||||
// Keep the list full-screen and geometrically unclipped. Wear's transformation
|
modifier = Modifier.fillMaxSize(),
|
||||||
// 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Arrangement.Bottom anchors short content to the bottom: the first message
|
// Arrangement.Bottom anchors short content to the bottom: the first message
|
||||||
// starts just above the action bar and new messages push history upward.
|
// starts just above the action bar and new messages push history upward.
|
||||||
// The scroll range reserves resting space for the floating controls while the
|
// The padding reserves permanent room for the floating header and action
|
||||||
// full-screen viewport preserves autoscroll and the native transformation focal
|
// bar; being constant, it never disturbs an in-flight scroll gesture.
|
||||||
// point. Rows may travel behind the overlays only after they have begun the Wear
|
|
||||||
// edge scale/fade treatment.
|
|
||||||
verticalArrangement = Arrangement.Bottom,
|
verticalArrangement = Arrangement.Bottom,
|
||||||
// Keep Wear Material's responsive horizontal inset while replacing its vertical
|
contentPadding = PaddingValues(top = 30.dp, bottom = 64.dp)
|
||||||
// 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 = CHAT_HEADER_CONTENT_CLEARANCE,
|
|
||||||
bottom = CHAT_ACTION_BAR_CLEARANCE
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
if (messages.isEmpty()) {
|
if (messages.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
@ -398,12 +299,7 @@ private fun ChatBody(
|
|||||||
|
|
||||||
// The header stays put and shrinks to its dense form instead of disappearing;
|
// 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.
|
// as an overlay its size animation never touches the list's scroll geometry.
|
||||||
Box(
|
Box(modifier = Modifier.align(Alignment.TopCenter)) {
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.TopCenter
|
|
||||||
) {
|
|
||||||
header(controlsVisible)
|
header(controlsVisible)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -437,12 +333,6 @@ private fun ChatBody(
|
|||||||
// Extra finger slack (px, ~28dp at watch density) around the cancel target so the snap
|
// Extra finger slack (px, ~28dp at watch density) around the cancel target so the snap
|
||||||
// engages as the finger approaches, not only on exact contact.
|
// engages as the finger approaches, not only on exact contact.
|
||||||
private const val CANCEL_HOVER_SLANT_PX = 56f
|
private const val CANCEL_HOVER_SLANT_PX = 56f
|
||||||
private const val CHAT_SCROLL_DIRECTION_THRESHOLD_PX = 24
|
|
||||||
private val CHAT_HEADER_CONTENT_CLEARANCE = 30.dp
|
|
||||||
private val CHAT_ACTION_BAR_CLEARANCE = 64.dp
|
|
||||||
private val CHAT_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
|
// 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
|
// 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.
|
// boundary); it leans toward the finger by up to MAGNET_PULL_PX.
|
||||||
|
|||||||
@ -67,11 +67,10 @@ fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
|
ScreenScaffold(scrollState = listState) {
|
||||||
ScalingLazyColumn(
|
ScalingLazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize()
|
||||||
contentPadding = scaffoldPadding
|
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
ListHeader {
|
ListHeader {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.bitchat.watch.ui
|
package com.bitchat.watch.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@ -14,7 +15,6 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@ -48,17 +48,11 @@ fun UserDetailScreen(
|
|||||||
val listState = rememberScalingLazyListState()
|
val listState = rememberScalingLazyListState()
|
||||||
val palette = LocalBitchatPalette.current
|
val palette = LocalBitchatPalette.current
|
||||||
|
|
||||||
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
|
ScreenScaffold(scrollState = listState) {
|
||||||
val layoutDirection = LocalLayoutDirection.current
|
|
||||||
ScalingLazyColumn(
|
ScalingLazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = scaffoldPadding
|
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
||||||
.withAdditionalPadding(
|
|
||||||
layoutDirection = layoutDirection,
|
|
||||||
horizontal = 10.dp,
|
|
||||||
vertical = 8.dp
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
ListHeader {
|
ListHeader {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.bitchat.watch.ui
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@ -13,7 +14,6 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@ -43,17 +43,11 @@ fun VerificationCodeScreen(peerID: String) {
|
|||||||
val listState = rememberScalingLazyListState()
|
val listState = rememberScalingLazyListState()
|
||||||
val palette = LocalBitchatPalette.current
|
val palette = LocalBitchatPalette.current
|
||||||
|
|
||||||
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
|
ScreenScaffold(scrollState = listState) {
|
||||||
val layoutDirection = LocalLayoutDirection.current
|
|
||||||
ScalingLazyColumn(
|
ScalingLazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = scaffoldPadding
|
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
||||||
.withAdditionalPadding(
|
|
||||||
layoutDirection = layoutDirection,
|
|
||||||
horizontal = 10.dp,
|
|
||||||
vertical = 8.dp
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
ListHeader {
|
ListHeader {
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -10,102 +10,6 @@ import org.junit.Test
|
|||||||
|
|
||||||
class ChatAutoScrollTest {
|
class ChatAutoScrollTest {
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `new scroll range from appended message keeps follow intent`() {
|
|
||||||
val updated = updatedChatScrollIntent(
|
|
||||||
current = ChatScrollIntentState(),
|
|
||||||
snapshot = ChatScrollSnapshot(
|
|
||||||
canScrollForward = true,
|
|
||||||
isScrollInProgress = false,
|
|
||||||
position = 100
|
|
||||||
),
|
|
||||||
previousPosition = 100
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(ChatScrollIntentState(), updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `user scroll away disables follow until list reaches newest again`() {
|
|
||||||
val browsingHistory = updatedChatScrollIntent(
|
|
||||||
current = ChatScrollIntentState(),
|
|
||||||
snapshot = ChatScrollSnapshot(
|
|
||||||
canScrollForward = true,
|
|
||||||
isScrollInProgress = true,
|
|
||||||
position = 60
|
|
||||||
),
|
|
||||||
previousPosition = 100
|
|
||||||
)
|
|
||||||
assertFalse(browsingHistory.followsNewest)
|
|
||||||
assertFalse(browsingHistory.controlsVisible)
|
|
||||||
|
|
||||||
val dockedAgain = updatedChatScrollIntent(
|
|
||||||
current = browsingHistory,
|
|
||||||
snapshot = ChatScrollSnapshot(
|
|
||||||
canScrollForward = false,
|
|
||||||
isScrollInProgress = false,
|
|
||||||
position = 200
|
|
||||||
),
|
|
||||||
previousPosition = 60
|
|
||||||
)
|
|
||||||
assertEquals(ChatScrollIntentState(), dockedAgain)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `slow scroll away accumulates intent across sub-threshold updates`() {
|
|
||||||
var state = ChatScrollIntentState()
|
|
||||||
var previousPosition = 100
|
|
||||||
|
|
||||||
listOf(94, 88, 82, 76).forEach { position ->
|
|
||||||
state = updatedChatScrollIntent(
|
|
||||||
current = state,
|
|
||||||
snapshot = ChatScrollSnapshot(
|
|
||||||
canScrollForward = true,
|
|
||||||
isScrollInProgress = true,
|
|
||||||
position = position
|
|
||||||
),
|
|
||||||
previousPosition = previousPosition
|
|
||||||
)
|
|
||||||
previousPosition = position
|
|
||||||
state = updatedChatScrollIntent(
|
|
||||||
current = state,
|
|
||||||
snapshot = ChatScrollSnapshot(
|
|
||||||
canScrollForward = true,
|
|
||||||
isScrollInProgress = false,
|
|
||||||
position = position
|
|
||||||
),
|
|
||||||
previousPosition = previousPosition
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertFalse(state.followsNewest)
|
|
||||||
assertFalse(state.controlsVisible)
|
|
||||||
assertEquals(0, state.accumulatedDeltaPx)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `slow scroll toward newest reveals controls without restoring follow early`() {
|
|
||||||
var state = ChatScrollIntentState(followsNewest = false, controlsVisible = false)
|
|
||||||
var previousPosition = 60
|
|
||||||
|
|
||||||
listOf(66, 72, 78, 84).forEach { position ->
|
|
||||||
state = updatedChatScrollIntent(
|
|
||||||
current = state,
|
|
||||||
snapshot = ChatScrollSnapshot(
|
|
||||||
canScrollForward = true,
|
|
||||||
isScrollInProgress = true,
|
|
||||||
position = position
|
|
||||||
),
|
|
||||||
previousPosition = previousPosition
|
|
||||||
)
|
|
||||||
previousPosition = position
|
|
||||||
}
|
|
||||||
|
|
||||||
assertFalse(state.followsNewest)
|
|
||||||
assertEquals(true, state.controlsVisible)
|
|
||||||
assertEquals(0, state.accumulatedDeltaPx)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `newest scroll waits until appended message is measured`() = runTest {
|
fun `newest scroll waits until appended message is measured`() = runTest {
|
||||||
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null))
|
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null))
|
||||||
|
|||||||
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user