Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8780486eae | |||
| 10ca35e352 | |||
| 45f169c20d | |||
| 5222c301ff | |||
| 830d6290ca | |||
| cbb09afbb1 | |||
| 59992af778 | |||
| 717aeed4ff | |||
| 2f6b542f24 | |||
| 5b237c82f2 | |||
| d2eb0d4f8c | |||
| ae127decd9 | |||
| ab0e35ddf4 | |||
| d431501b74 | |||
| 6f9b08e844 | |||
| 09ef54ef69 | |||
| bfea7201f7 | |||
| 05eeccd83b | |||
| d13b88aebe | |||
| b824f62878 | |||
| 00739ac8a0 | |||
| 94cfd72690 | |||
| e3a2378c34 | |||
| 8b452a5593 | |||
| 492db55a04 | |||
| 617dd05267 | |||
| 5246a97db2 | |||
| 933a74c900 | |||
| 9a638545dd | |||
| 69687d0acf | |||
| 4e3d7de32c | |||
| 414d39c835 | |||
| 2fd4c2e988 | |||
| e5d0e54b80 | |||
| bd51fef5cc | |||
| 79ad411d7e |
@@ -4,6 +4,185 @@ Všechny podstatné změny v aplikaci Šepot. Formát dle
|
||||
[Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
|
||||
verzování dle [ajtak standardu](#verzování).
|
||||
|
||||
## [Nevydáno]
|
||||
|
||||
## [1.2.3] – 2026-10-23
|
||||
|
||||
### Opraveno (iOS, build 27)
|
||||
- **Na iOS 27 appka hned po spuštění spadla.** Build 26 vznikl novým
|
||||
Xcode (SDK iOS 27) a ten u appek bez scénového životního cyklu (UIScene)
|
||||
start zastaví. Appka teď scény používá; Flutter běží od startu procesu,
|
||||
takže příchozí hovor na pozadí dál zazvoní i bez otevřeného okna.
|
||||
|
||||
### Bezpečnost
|
||||
- **24 slov jen po čerstvém ověření.** Zálohovací fráze se z odemčené appky
|
||||
ukáže až po zadání PINu (nebo otisku/obličeje, je-li zapnutý). Totéž platí
|
||||
pro export a obnovu zálohy, nastavení nátlakového PINu, prodloužení
|
||||
automatického zámku, vypnutí ochrany obrazovky, zapnutí biometrie
|
||||
a smazání účtu. Kdo vezme odemčený telefon, k frázi se nedostane.
|
||||
V nátlakové relaci se fráze nezobrazí nikdy. Ověření PINu podléhá stejnému
|
||||
zpomalení po chybách jako odemykání.
|
||||
- **Cizí klíč se jménem kontaktu už nesplyne s kontaktem.** Zpráva od
|
||||
neznámého klíče je „žádost o zprávu“ se jménem, které si odesílatel zvolil
|
||||
sám, a s označením „(neznámý)“. Dokud ji nepřijmete, nejde odpovědět,
|
||||
nechodí od vás potvrzení přečtení ani psaní, nezvoní hovory a notifikace
|
||||
neukazuje jméno. Když se jméno plete s existujícím kontaktem (i přes
|
||||
cyrilici, diakritiku nebo neviditelné znaky), ukáže se varování i v chatu
|
||||
skutečného kontaktu.
|
||||
- **Změna bezpečnostního kódu.** Když se pod účtem známého kontaktu objeví
|
||||
jiný klíč, chat ukáže „Bezpečnostní kód kontaktu … se změnil“, ověření
|
||||
kontaktu se zruší a odpověď neodejde na nový klíč. Nový klíč přijde jako
|
||||
samostatná žádost o zprávu.
|
||||
|
||||
### Opraveno
|
||||
- **Na iPhonu nechodily budíčky ani hovory na pozadí.** Appka relayi vůbec
|
||||
neposlala token pro zprávy (APNs) ani pro hovory (VoIP): nastavení
|
||||
notifikací na iOS skončilo chybou, ta zastavila celou registraci a appka
|
||||
ji tiše spolkla. Registrace u Apple teď proběhne i bez povolených
|
||||
notifikací, token pro hovory jde zvlášť a případné selhání ukáže seznam
|
||||
chatů.
|
||||
|
||||
## [1.2.2] – 2026-10-11
|
||||
|
||||
### Opraveno
|
||||
- **Přečtený chat se dál ukazoval jako nepřečtený.** Zpráva, která přišla do
|
||||
otevřeného chatu, zůstala po návratu do seznamu tučně s odznakem; teď se
|
||||
přečte hned, když je chat na obrazovce (a appka v popředí). Stažení přílohy
|
||||
nebo dodatečné doručení už nevrátí přečtenou zprávu mezi nepřečtené.
|
||||
- Gesto „odpověď tahem“ při zavírání chatu v ladicím buildu padalo na
|
||||
vytvoření animace nad zrušeným prvkem.
|
||||
- Na iPhonu hlásila dlaždice Push v Nastavení chybu (volala UnifiedPush,
|
||||
který existuje jen na Androidu).
|
||||
|
||||
### Změněno
|
||||
- **Nová ikona aplikace:** Š, které se ve spodní části rozpadá do teček.
|
||||
|
||||
## [1.2.1] – 2026-10-09
|
||||
|
||||
### Opraveno
|
||||
- **Hovory se nedaly přijmout.** Příchozí signalizace hovoru se zahazovala dřív,
|
||||
než se dostala k obsluze hovoru; volaný jen „zazvonil“ z budíčku a pak nic.
|
||||
- **Obrazovka hovoru po zavěšení:** avatar a jméno uskakovaly vlevo nahoru.
|
||||
- **Obnova ze 24 slov na novém telefonu** končila nekonečným čekáním — server
|
||||
odmítal už existující identitu. Nové zařízení ji teď převezme podpisem.
|
||||
- **Tichá ztráta zpráv:** úklid mizících zpráv mohl přepsat čerstvě přijatou
|
||||
zprávu; automatický zámek během zpracování zprávy ji mohl nenávratně zahodit;
|
||||
souběžné šifrování pro téhož příjemce dávalo obálky, které druhá strana
|
||||
zahodila. Všechny tři cesty opraveny.
|
||||
- **Velká fronta po delším výpadku** (stovky zpráv) se už neodpojuje v půlce
|
||||
a server ji po osmi pokusech nemaže.
|
||||
- **Nátlakový PIN** otevíral relaci se skutečnou identitou, frází i přístupem ke
|
||||
smazání účtu. Teď je izolovaná: náhodná identita, žádná síť, žádný zásah do
|
||||
klíčů; smazání v ní nechá skutečný účet být.
|
||||
- Zvonění z budíčku bez skutečné nabídky končí po 12 s; server budíčky hovoru
|
||||
omezuje na cíl, takže cizí člověk se znalostí adresy telefon nerozezvoní
|
||||
pořád dokola.
|
||||
- Dodatečné odeslání z fronty neztrácí anketu ani zmínky; budíček při
|
||||
„připojeno“ bez provozu obnoví spojení.
|
||||
|
||||
### Bezpečnost
|
||||
- Hádání PINu: po pěti chybách prodleva 30 s, dál dvojnásobná až 15 minut.
|
||||
- Nátlakový a skutečný PIN se ověřují jednou derivací — čas odemčení neprozradí,
|
||||
který byl zadán.
|
||||
- Biometrie jen biometricky (zámek telefonu appku neodemkne); zabezpečení
|
||||
obrazovky je výchozí zapnuté a po odemčení skutečně platí; na iOS klíče
|
||||
nepřecházejí do iCloud Keychain ani záloh; filtr proti dotykům přes překryvná okna.
|
||||
- Server: doplňování jednorázových klíčů a rotace podepsaných klíčů
|
||||
(`POST /v1/keys`), omezení výdeje jednorázových klíčů na adresu, chyby v logu
|
||||
bez adres push endpointů, připnutí DNS u budíčků, globální strop příloh,
|
||||
limity délek, `/v1/ready`, uvolnění spojení při vypnutí, revize a zdroj
|
||||
v `/v1/server-info`.
|
||||
|
||||
## [1.2.0] – 2026-10-01
|
||||
|
||||
### Nový vzhled
|
||||
- **Domovská obrazovka s kartami Chaty, Kontakty a Nastavení.** Velký
|
||||
nadpis, hledání v chatech i v obsahu zpráv (najde „klic“ i „klíč“),
|
||||
filtry Vše / Nepřečtené / Skupiny, připnuté chaty nahoře.
|
||||
- **Konverzace s hloubkou.** Průsvitná hlavička a pole pro psaní nad jemnou
|
||||
tapetou, zprávy od stejného člověka se skládají do bloků, ve skupině
|
||||
avatar a jméno autora v jeho barvě.
|
||||
- **Odpověď tahem doprava**, řádek „N nových zpráv“ na místě, kde jsi
|
||||
skončil, a tlačítko na nejnovější zprávy s počtem nových.
|
||||
- **Hledání v konverzaci** se zvýrazněním a přeskakováním mezi výsledky;
|
||||
klepnutí na citaci skočí na původní zprávu.
|
||||
- **Profil kontaktu a skupiny** po klepnutí na jméno nebo avatar: ověření,
|
||||
sdílené fotky, společné skupiny, mizící zprávy, ztlumení, blokování;
|
||||
u skupiny členové, přidání lidí, přejmenování a odchod.
|
||||
- **Fotky přes celou obrazovku s listováním** všemi fotkami konverzace.
|
||||
- **Každý člověk má vlastní barvu avataru** — všude stejnou, i po
|
||||
přejmenování.
|
||||
- **„Přečteno“ je poznat na první pohled** — plná pilulka místo
|
||||
modrošedých fajfek, které na korálové bublině skoro nebyly vidět.
|
||||
|
||||
### Přidáno
|
||||
- **Hovory a videohovory** 1:1, šifrované end-to-end. Tlačítka v hlavičce
|
||||
chatu; telefon zazvoní i se zavřenou aplikací (na iPhonu přes CallKit).
|
||||
Když přímé spojení nejde, hovor šifrovaně přepošle náš server.
|
||||
V chatu zůstane záznam „Hovor · 3:12“ nebo „Zmeškaný hovor“.
|
||||
- **Rozepsaná zpráva zůstane.** Když odejdeš z chatu, text v poli
|
||||
počká, a v seznamu chatů je vidět jako „Koncept: …“.
|
||||
- **Přeposlání** zprávy, fotky, souboru i hlasovky do jiného chatu nebo
|
||||
skupiny (podržet zprávu → Přeposlat).
|
||||
- **Poznámky pro sebe** v kartě Kontakty: chat sám se sebou na odkazy,
|
||||
fotky a poznámky. Zůstává jen v telefonu, šifrovaně, nic nejde na server.
|
||||
- **@zmínky ve skupinách.** Po napsání „@“ se nabídnou členové; zmíněný
|
||||
dostane upozornění, i když má skupinu ztlumenou.
|
||||
- **Rychlejší přehrávání hlasovek** — 1,5× a 2×, rychlost si Šepot pamatuje.
|
||||
- **Správci skupin.** Zakladatel je správce a může udělat správcem
|
||||
i další členy. Odebrat člena, měnit správce a přejmenovat skupinu smí
|
||||
jen správce; každou takovou změnu podepíše svým klíčem a ostatní telefony
|
||||
podpis ověří, takže ji nikdo nemůže podvrhnout ani za správce vydávat.
|
||||
U členů je odznak „Správce“, ostatní vidí akce neaktivní s vysvětlením.
|
||||
Dokud má někdo ve skupině starší verzi Šepotu, přidávat lidi smí dál
|
||||
každý, aby se skupina nerozdělila; se staršími verzemi skupiny fungují
|
||||
jako dosud. Existující skupiny se převedou samy, bez jediné zprávy.
|
||||
- **Profilová fotka.** V Nastavení klepni na svůj avatar. Fotka se ořízne,
|
||||
zmenší a zbaví metadat a tvým kontaktům dorazí šifrovaně s další
|
||||
zprávou; u nich se ukáže všude místo iniciál.
|
||||
- **Ankety** v chatu i ve skupině (plus → Anketa): jedna nebo víc
|
||||
odpovědí, pruhy výsledků a kdo jak hlasoval, hlas jde změnit i zrušit.
|
||||
Starší verze Šepotu uvidí otázku a možnosti jako text.
|
||||
- **„píše…“** v hlavičce chatu, v seznamu chatů a jako tři tečky pod
|
||||
poslední zprávou. Stejně jako potvrzení přečtení je volitelné (výchozí
|
||||
vypnuto) a oboustranné. Offline protistraně se nic neposílá do fronty ani
|
||||
ji to nebudí — server takové oznámení doručí jen živému spojení.
|
||||
- **Čas doručení a přečtení.** Pod poslední odeslanou zprávou je vidět, kdy
|
||||
dorazila a kdy ji druhá strana přečetla. Po podržení zprávy ukáže „Info
|
||||
o zprávě“ čas odeslání, doručení a přečtení — ve skupině u každého člena
|
||||
zvlášť. Čas přečtení se ukazuje jen lidem, kteří potvrzení přečtení
|
||||
sdílejí oboustranně (výchozí vypnuto).
|
||||
- **Přidání lidí do skupiny.** Do existující skupiny jde nově přidat
|
||||
kontakty z nabídky skupiny nebo ze seznamu členů.
|
||||
|
||||
### Změněno
|
||||
- **Notifikace jsou na Androidu zapnuté od začátku.** Bez nich systém
|
||||
Šepot na pozadí uspí a zprávy čekají, dokud aplikaci neotevřeš. Kdo má
|
||||
UnifiedPush distributor (např. ntfy), dostane ho; ostatním se zapne
|
||||
budíček přes Google (FCM). Google přitom vidí jen to, že má telefon
|
||||
probudit — ne obsah ani odesílatele. Přepnout či vypnout jde v Nastavení.
|
||||
- **Dvojitá fajfka ve skupině** svítí až ve chvíli, kdy zpráva dorazila
|
||||
všem členům, ne jen prvnímu.
|
||||
|
||||
### Opraveno
|
||||
- **Ztlumená skupina dál posílala upozornění.** Ztlumení se u skupinové
|
||||
zprávy kontrolovalo v chatu s autorem, ne ve skupině.
|
||||
- **Odesílané fotky nesly polohu (GPS) a údaje o telefonu.** Zpracování
|
||||
fotky před odesláním metadata EXIF nečekaně zachovávalo — v odeslané
|
||||
fotce i v jejím náhledu. Nově se před odesláním vždy odstraní.
|
||||
- **Zablokovaný kontakt se mohl sám odblokovat**, když změnil adresu pro
|
||||
doručování.
|
||||
- **„Přečteno“ se už nevrací na „doručeno“.** Když potvrzení o doručení
|
||||
dorazilo po potvrzení přečtení (typicky po výpadku spojení), zpráva se
|
||||
tvářila jako nepřečtená.
|
||||
- **Zpráva označená jako neodeslaná** se po doručení sama opraví na
|
||||
doručenou.
|
||||
- **Nastavení ukazovalo starou verzi aplikace.** Číslo verze bylo v kódu
|
||||
napsané ručně a při vydání 1.1.1 a 1.1.2 se ho zapomnělo přepsat, takže
|
||||
se všude hlásila 1.1.0 — včetně hlášení zneužití, u kterých pak nešlo
|
||||
dohledat, ze které verze přišla. Verze se nově čte z nainstalovaného
|
||||
balíčku a k hlášení se přikládá i číslo buildu.
|
||||
|
||||
## [1.1.2] – 2026-08-13
|
||||
|
||||
### Opraveno
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
|
||||
**Šepot** is an end-to-end encrypted messenger with anonymous, on-device identity. There is no phone number, e-mail, or password: an account is a keypair generated on the device, your ID is the fingerprint of your public key, and the only backup is a mnemonic phrase.
|
||||
|
||||
The design goal is **metadata minimization with a public threat model** — never "zero metadata". The relay is deliberately dumb: it cannot read message content, does not learn who sent a message, and does not log IP addresses. What it *does* see is documented honestly below.
|
||||
The design goal is **metadata minimization with a public threat model** — never "zero metadata". The relay is deliberately dumb: it cannot read message content, envelopes carry no sender, and it neither stores a sender–recipient link nor logs IP addresses. What it *does* see while a connection is open is documented honestly below.
|
||||
|
||||
> Status: pre-1.0. The Android client is the primary target; iOS builds run through TestFlight. See [`CHANGELOG.md`](CHANGELOG.md).
|
||||
> Status: 1.2.x. Android is on [Google Play](https://play.google.com/store/apps/details?id=cz.sepot.sepot); iOS is on the App Store outside the EU, with EU availability pending Apple's trader verification under the Digital Services Act. See [`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
## Features
|
||||
|
||||
- **Signal-grade cryptography** via the official Rust `libsignal`: X3DH + post-quantum PQXDH (Kyber-1024) key agreement, Double Ratchet, and sealed sender.
|
||||
- **End-to-end encryption** via the open-source Rust `libsignal` library: X3DH + post-quantum PQXDH (Kyber-1024) key agreement, Double Ratchet, and sealed sender.
|
||||
- **Anonymous identity** — no phone/e-mail/password. Recovery through a 24-word mnemonic that encodes the identity key itself (not a KDF seed).
|
||||
- **Encrypted at rest** — all local data lives in SQLCipher, unlocked by a PIN (Argon2id-derived key) or biometrics. Optional **duress PIN** opens a separate empty database.
|
||||
- **Group chats** — up to 128 members, pairwise fan-out so every member gets full forward secrecy; the relay never learns a group exists.
|
||||
- **Group chats** — up to 128 members, pairwise fan-out so every member gets full forward secrecy; groups are not a server-side object and the relay keeps no member list.
|
||||
- **Attachments** — images and files are encrypted client-side (AES-256-GCM) and uploaded as opaque blobs with a TTL; image metadata (EXIF/GPS) is stripped on send.
|
||||
- **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers).
|
||||
- **Push without Google by default** — UnifiedPush/ntfy; FCM (Android) and APNs (iOS) are opt-in. A push wake-up never carries content, sender, or count.
|
||||
- **Push with or without Google** — UnifiedPush/ntfy when a distributor is installed, otherwise FCM on Android (default since 1.2.0, can be switched off) and APNs on iOS. A push wake-up never carries content, sender, or count.
|
||||
- **Tor transport** (Android, via Orbot) — routes relay traffic over a `.onion` to hide the client IP. Off by default.
|
||||
- **Five languages** — Czech, English, German, Russian, Italian. The UI follows the system language and can be overridden in Settings.
|
||||
|
||||
@@ -29,9 +29,11 @@ The design goal is **metadata minimization with a public threat model** — neve
|
||||
SQLCipher at rest opaque attachment blobs
|
||||
```
|
||||
|
||||
Every message is encrypted end-to-end with libsignal and wrapped in a sealed-sender envelope, so the relay routes by recipient delivery address without learning the sender. Undelivered messages sit in a per-recipient queue with a fixed TTL and are hard-deleted on acknowledgement.
|
||||
Every message is encrypted end-to-end with libsignal and wrapped in a sealed-sender envelope, so the envelope itself carries no sender and the relay routes it by the recipient's delivery address. Undelivered messages sit in a per-recipient queue with a fixed TTL and are hard-deleted on acknowledgement.
|
||||
|
||||
**What the relay can see:** the recipient's delivery address, message size, and timing. **What it cannot see:** message content, the sender's identity, group membership, or (with logging disabled as shipped) client IP addresses. The full model — including where the trust boundary sits — is in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md) and the crypto facade `app/lib/core/crypto/`.
|
||||
**What the relay can see:** the recipient's delivery address, envelope size and timing, push tokens, and — while a connection is open — the client IP address. Envelopes are submitted over an authenticated WebSocket, so at runtime the relay also knows which connected device hands an envelope to which delivery address; it needs that to deliver. **What it does not see or keep:** message content, names, contacts or group membership. It stores no sender–recipient link and no IP addresses, and writes neither to its logs. Someone who took over the relay could start recording runtime metadata; they still could not read messages. See [`SECURITY.md`](SECURITY.md) and the crypto facade `app/lib/core/crypto/`.
|
||||
|
||||
**Where data lives:** messages, accounts and keys are stored only on servers in the EU (Hetzner, Germany). Message content never leaves the device unencrypted. Nothing leaves the EU except an anonymous wake-up push via Google (FCM) or Apple (APNs), which carries no message content or sender; the website runs behind Cloudflare.
|
||||
|
||||
## Repository layout
|
||||
|
||||
@@ -71,10 +73,10 @@ UI strings live in `app/lib/l10n/app_*.arb` (`cs`, `en`, `de`, `ru`, `it`) and a
|
||||
|
||||
## Security
|
||||
|
||||
Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **bezpecnost@sepot.cz** rather than opening a public issue.
|
||||
Cryptography is implemented by the open-source `libsignal` library, not by hand-rolled primitives. Šepot is not affiliated with Signal Messenger LLC; Signal is a trademark of its owner. The project runs regular internal security audits and penetration tests (August, September and October 2026); no independent third-party audit has taken place yet. If you find a vulnerability, please report it privately to **security@sepot.cz** rather than opening a public issue — see [`SECURITY.md`](SECURITY.md) and [`/.well-known/security.txt`](https://sepot.cz/.well-known/security.txt).
|
||||
|
||||
## License
|
||||
|
||||
Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](LICENSE).
|
||||
|
||||
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository, F-Droid, direct APK). That permission covers only Šepot's own code. The bundled Signal `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
|
||||
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository and the direct APK releases). That permission covers only Šepot's own code. The bundled `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report security issues privately — do not open a public issue.
|
||||
|
||||
- E-mail: **security@sepot.cz**
|
||||
- Web form and policy: <https://sepot.cz/zodpovedne-hlaseni>
|
||||
- Machine-readable contact: <https://sepot.cz/.well-known/security.txt>
|
||||
|
||||
You will get an acknowledgement within 72 hours and a status update at least
|
||||
every 14 days until the issue is resolved. We aim to ship a fix within 30 days
|
||||
for issues that affect message confidentiality, authentication, or the relay's
|
||||
metadata guarantees, and we credit reporters in the changelog unless they
|
||||
prefer otherwise.
|
||||
|
||||
## Scope
|
||||
|
||||
- the Flutter client (`app/`), including the Signal protocol integration,
|
||||
local storage (SQLCipher, PIN/biometrics, duress session) and calls;
|
||||
- the relay (`rust/sepot-relay/`): authentication, routing, push wake-ups,
|
||||
attachments, rate limiting, TURN credentials;
|
||||
- the public relay at `relay.sepot.cz` and the website `sepot.cz`.
|
||||
|
||||
Please do not run denial-of-service tests, mass registrations or scans
|
||||
against the public relay; a local instance (`deploy/relay/`) is enough to
|
||||
reproduce almost everything.
|
||||
|
||||
## What the relay can and cannot see
|
||||
|
||||
The threat model is documented in the README. In short: envelopes carry no
|
||||
sender (sealed sender) and the relay never sees content. It does see routing
|
||||
tokens, envelope sizes, timing and push tokens, and while a connection is
|
||||
open the client IP and which authenticated device submits an envelope to
|
||||
which delivery address. It stores no sender–recipient link and no IP
|
||||
addresses. Reports that demonstrate the relay learning or keeping more than
|
||||
that are the ones we care about most.
|
||||
|
||||
## Audits
|
||||
|
||||
We run regular internal security audits and penetration tests (August,
|
||||
September and October 2026); fixes are listed in `CHANGELOG.md`. No
|
||||
independent third-party audit has taken place yet.
|
||||
@@ -26,7 +26,7 @@ their app-store status is documented in detail below.
|
||||
|
||||
Šepot relies on that stated position and, as an AGPL work, keeps its
|
||||
complete corresponding source available under the AGPL through channels
|
||||
without app-store restrictions (this public repository, F-Droid, direct
|
||||
without app-store restrictions (this public repository, direct
|
||||
APK). A written clarification for the current Rust `libsignal` has been
|
||||
requested from Signal (tracking: <https://github.com/signalapp/libsignal/issues/684>).
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Nahrávání hlasových zpráv -->
|
||||
<!-- Nahrávání hlasových zpráv a hovory -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Hovory (WebRTC): kamera u videohovoru, přepínání reproduktoru
|
||||
a Bluetooth sluchátka, sledování změny sítě během hovoru. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<!-- Firebase Messaging tiše přidává AD_ID oprávnění; Šepot inzertní ID
|
||||
nepoužívá (žádné reklamy/tracking) → odebíráme, ať sedí Play deklarace. -->
|
||||
<uses-permission android:name="com.google.android.gms.permission.AD_ID"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cz.sepot.sepot
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
@@ -7,10 +8,20 @@ import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
)
|
||||
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací.
|
||||
// Výchozí stav; Dart přepínač „Zabezpečení obrazovky" ho může po
|
||||
// odemčení vypnout (default zapnuto, jako u Signalu).
|
||||
// Debug build: screenshoty povolené kvůli QA na emulátoru (adb screencap
|
||||
// by u FLAG_SECURE okna selhal); release vždy chráněný.
|
||||
val debuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
if (!debuggable) {
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
)
|
||||
}
|
||||
// Tapjacking: dotyky přes překryvné okno jiné appky (částečně
|
||||
// průhledný overlay nad PIN klávesnicí) se zahodí.
|
||||
window.decorView.filterTouchesWhenObscured = true
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 7.6 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0a0e17</color>
|
||||
<color name="ic_launcher_background">#0B0C12</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 575 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 27 KiB |
@@ -1,9 +1,9 @@
|
||||
# Launcher ikony — kandidát 1 „Š s tečkami" (nokturno-ikona-B2, ember paleta).
|
||||
# Zdroj: sepot-web/koncept/logo/nokturno-ikona-B2.png; regenerace:
|
||||
# Launcher icons: the "Š that fades into dots" mark (variant B, 2026-10).
|
||||
# Source: assets/icon/icon-1024.png (full) and maskable-fg-512.png (adaptive foreground); regenerate with:
|
||||
# dart run flutter_launcher_icons
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: assets/icon/icon-1024.png
|
||||
adaptive_icon_background: "#0a0e17"
|
||||
adaptive_icon_background: "#0B0C12"
|
||||
adaptive_icon_foreground: assets/icon/maskable-fg-512.png
|
||||
|
||||
@@ -44,6 +44,9 @@ post_install do |installer|
|
||||
# staví jen pro arm64 (Apple Silicon Mac). Bez tohoto Xcode linkuje
|
||||
# i x86_64 slice, který dylib nemá → „Undefined symbols for x86_64".
|
||||
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'x86_64'
|
||||
# Xcode se SDK 27 odmítá pody s deployment targetem pod 15.0 (archive
|
||||
# skončí chybou místo varování); appka sama minimum 15.0 má.
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
AA00000000000000000000A2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A1 /* PrivacyInfo.xcprivacy */; };
|
||||
AB10000000000000000000B1 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AB10000000000000000000C0 /* InfoPlist.strings */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
AA00000000000000000000B2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000B1 /* SceneDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
@@ -49,6 +50,7 @@
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
AA00000000000000000000B1 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -126,6 +128,7 @@
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
AA00000000000000000000B1 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
path = Runner;
|
||||
@@ -285,6 +288,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
AA00000000000000000000B2 /* SceneDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import CallKit
|
||||
import Flutter
|
||||
import PushKit
|
||||
import UIKit
|
||||
import WebRTC
|
||||
import flutter_callkit_incoming
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
|
||||
// Engine startuje s procesem, ne se scénou. VoIP push umí appku spustit na
|
||||
// pozadí, aniž by iOS připojil scénu (a tím vytvořil FlutterViewController).
|
||||
// CallKit plugin musí být zaregistrovaný dřív, než push dorazí, a Dart musí
|
||||
// běžet, aby vyzvedl nabídku hovoru z relaye. SceneDelegate tenhle engine
|
||||
// jen zobrazí.
|
||||
let flutterEngine = FlutterEngine(name: "sepot")
|
||||
// PushKit registry musí žít po celou dobu běhu appky, jinak VoIP pushe nechodí.
|
||||
private var voipRegistry: PKPushRegistry?
|
||||
private var apnsChannel: FlutterMethodChannel?
|
||||
// Token dorazí asynchronně z APNs; když si o něj Dart řekne dřív, podržíme ho.
|
||||
private var pendingToken: String?
|
||||
@@ -11,14 +23,16 @@ import UIKit
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
let controller = window?.rootViewController as! FlutterViewController
|
||||
flutterEngine.run()
|
||||
GeneratedPluginRegistrant.register(with: flutterEngine)
|
||||
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "cz.sepot.sepot/apns", binaryMessenger: controller.binaryMessenger)
|
||||
name: "cz.sepot.sepot/apns", binaryMessenger: flutterEngine.binaryMessenger)
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
switch call.method {
|
||||
case "register":
|
||||
// Nativní APNs registrace (bez Firebase) — token přijde v callbacku
|
||||
application.registerForRemoteNotifications()
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
result(nil)
|
||||
case "getToken":
|
||||
result(self?.pendingToken)
|
||||
@@ -28,14 +42,99 @@ import UIKit
|
||||
}
|
||||
apnsChannel = channel
|
||||
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
// Hovory: VoIP push z relaye → CallKit.
|
||||
let registry = PKPushRegistry(queue: DispatchQueue.main)
|
||||
registry.delegate = self
|
||||
registry.desiredPushTypes = [.voIP]
|
||||
voipRegistry = registry
|
||||
|
||||
// Zvukovou relaci u hovoru řídí CallKit; WebRTC ji smí zapnout až po
|
||||
// jeho aktivaci (didActivateAudioSession), jinak hovor z uzamčené
|
||||
// obrazovky nemá zvuk.
|
||||
RTCAudioSession.sharedInstance().useManualAudio = true
|
||||
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
// MARK: - PushKit (VoIP)
|
||||
|
||||
func pushRegistry(_ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType) {
|
||||
let token = credentials.token.map { String(format: "%02x", $0) }.joined()
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(token)
|
||||
}
|
||||
|
||||
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
|
||||
}
|
||||
|
||||
// iOS vyžaduje, aby každý VoIP push HNED skončil hovorem v CallKitu — jinak
|
||||
// appku ukončí a po opakování VoIP pushe přestane doručovat. Relay neví,
|
||||
// kdo volá (sealed sender), proto zvoní „Šepot“; Dart zvonění spáruje
|
||||
// s nabídkou podle `extra.push`, až ji po připojení dešifruje.
|
||||
func pushRegistry(
|
||||
_ registry: PKPushRegistry,
|
||||
didReceiveIncomingPushWith payload: PKPushPayload,
|
||||
for type: PKPushType,
|
||||
completion: @escaping () -> Void
|
||||
) {
|
||||
guard type == .voIP else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
let data = flutter_callkit_incoming.Data(
|
||||
id: UUID().uuidString, nameCaller: "Šepot", handle: "Šepot", type: 0)
|
||||
data.appName = "Šepot"
|
||||
data.duration = 45000
|
||||
data.extra = ["push": true]
|
||||
data.handleType = "generic"
|
||||
data.supportsVideo = true
|
||||
data.maximumCallGroups = 1
|
||||
data.maximumCallsPerCallGroup = 1
|
||||
data.supportsHolding = false
|
||||
data.supportsDTMF = false
|
||||
data.includesCallsInRecents = false
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming(data, fromPushKit: true) {
|
||||
completion()
|
||||
}
|
||||
// Běží-li Dart, ať se hned připojí a vyzvedne nabídku z fronty.
|
||||
apnsChannel?.invokeMethod("onWake", arguments: nil)
|
||||
}
|
||||
|
||||
// MARK: - CallKit
|
||||
// Rozhodnutí (přijmout/odmítnout) řeší Dart přes události pluginu;
|
||||
// tady jen potvrdit akci CallKitu, jinak by ji systém po čase zrušil.
|
||||
|
||||
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
func onDecline(_ call: Call, _ action: CXEndCallAction) {
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
func onEnd(_ call: Call, _ action: CXEndCallAction) {
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
func onTimeOut(_ call: Call) {}
|
||||
|
||||
func didActivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
|
||||
RTCAudioSession.sharedInstance().isAudioEnabled = true
|
||||
}
|
||||
|
||||
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession)
|
||||
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
||||
}
|
||||
|
||||
func providerDidReset() {}
|
||||
|
||||
// APNs vydal device token → předáme Dartu (ten ho pošle relayi).
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Foundation.Data
|
||||
) {
|
||||
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
||||
pendingToken = token
|
||||
|
||||
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 782 B After Width: | Height: | Size: 560 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 817 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
@@ -5,9 +5,11 @@
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
<string>audio</string>
|
||||
<string>voip</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>The camera is used to scan contacts' QR codes and to take photos for attachments.</string>
|
||||
<string>The camera is used to scan contacts' QR codes, take photos for attachments and for video calls.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>The photo library is used to pick photos to send in a conversation.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
@@ -41,13 +43,30 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<true/>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
@@ -66,6 +85,6 @@
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Šepot needs the microphone to record voice messages.</string>
|
||||
<string>Šepot needs the microphone for voice messages and calls.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
// SDK iOS 27 bez scénového životního cyklu appku nespustí (TN3187). Scéna jen
|
||||
// ukáže engine, který AppDelegate spustil při startu procesu — viz komentář
|
||||
// u `AppDelegate.flutterEngine`.
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
override func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
guard let windowScene = scene as? UIWindowScene,
|
||||
let appDelegate = UIApplication.shared.delegate as? AppDelegate
|
||||
else { return }
|
||||
|
||||
let window = UIWindow(windowScene: windowScene)
|
||||
window.rootViewController = FlutterViewController(
|
||||
engine: appDelegate.flutterEngine, nibName: nil, bundle: nil)
|
||||
self.window = window
|
||||
window.makeKeyAndVisible()
|
||||
|
||||
// Super podle okna scény přihlásí engine k událostem scény pro pluginy.
|
||||
// Musí běžet dřív, než okno dostane i delegát aplikace: našel-li by ho
|
||||
// tam, Flutter by ho bral jako starý způsob startu a přesunul do nového okna.
|
||||
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||
|
||||
// Pluginy, které okno hledají přes delegáta aplikace (local_auth_darwin
|
||||
// u dialogu „biometrie není nastavená“), by ho jinak nenašly.
|
||||
appDelegate.window = window
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Šepot — texty oprávnění iOS (čeština) */
|
||||
"NSCameraUsageDescription" = "Kamera slouží ke skenování QR kódů kontaktů a focení příloh.";
|
||||
"NSCameraUsageDescription" = "Kamera slouží ke skenování QR kódů kontaktů, focení příloh a videohovorům.";
|
||||
"NSPhotoLibraryUsageDescription" = "Galerie slouží k výběru fotek pro odeslání v konverzaci.";
|
||||
"NSFaceIDUsageDescription" = "Face ID odemyká aplikaci místo PINu.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot potřebuje mikrofon pro nahrávání hlasových zpráv.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot potřebuje mikrofon pro hlasové zprávy a hovory.";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Šepot — iOS-Berechtigungstexte (Deutsch) */
|
||||
"NSCameraUsageDescription" = "Die Kamera dient zum Scannen der QR-Codes deiner Kontakte und zum Aufnehmen von Fotos für Anhänge.";
|
||||
"NSCameraUsageDescription" = "Die Kamera dient zum Scannen der QR-Codes deiner Kontakte, zum Aufnehmen von Fotos für Anhänge und für Videoanrufe.";
|
||||
"NSPhotoLibraryUsageDescription" = "Die Fotomediathek dient zum Auswählen von Fotos, die du in einer Unterhaltung sendest.";
|
||||
"NSFaceIDUsageDescription" = "Face ID entsperrt die App anstelle der PIN.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot benötigt das Mikrofon zum Aufnehmen von Sprachnachrichten.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot benötigt das Mikrofon für Sprachnachrichten und Anrufe.";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Šepot — iOS permission strings (English) */
|
||||
"NSCameraUsageDescription" = "The camera is used to scan contacts' QR codes and to take photos for attachments.";
|
||||
"NSCameraUsageDescription" = "The camera is used to scan contacts' QR codes, take photos for attachments and for video calls.";
|
||||
"NSPhotoLibraryUsageDescription" = "The photo library is used to pick photos to send in a conversation.";
|
||||
"NSFaceIDUsageDescription" = "Face ID unlocks the app instead of your PIN.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot needs the microphone to record voice messages.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot needs the microphone for voice messages and calls.";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Šepot — testi delle autorizzazioni iOS (italiano) */
|
||||
"NSCameraUsageDescription" = "La fotocamera serve a scansionare i codici QR dei contatti e a scattare foto per gli allegati.";
|
||||
"NSCameraUsageDescription" = "La fotocamera serve a scansionare i codici QR dei contatti, a scattare foto per gli allegati e per le videochiamate.";
|
||||
"NSPhotoLibraryUsageDescription" = "La libreria foto serve a scegliere le foto da inviare in una conversazione.";
|
||||
"NSFaceIDUsageDescription" = "Face ID sblocca l'app al posto del PIN.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot ha bisogno del microfono per registrare i messaggi vocali.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot ha bisogno del microfono per i messaggi vocali e le chiamate.";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Šepot — тексты разрешений iOS (русский) */
|
||||
"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов и съёмки фотографий для вложений.";
|
||||
"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов, съёмки фотографий для вложений и видеозвонков.";
|
||||
"NSPhotoLibraryUsageDescription" = "Галерея используется для выбора фотографий, которые вы отправляете в чате.";
|
||||
"NSFaceIDUsageDescription" = "Face ID разблокирует приложение вместо PIN-кода.";
|
||||
"NSMicrophoneUsageDescription" = "Для записи голосовых сообщений приложению Šepot нужен доступ к микрофону.";
|
||||
"NSMicrophoneUsageDescription" = "Приложению Šepot нужен доступ к микрофону для голосовых сообщений и звонков.";
|
||||
|
||||
@@ -5,7 +5,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/core/calls/call_controller.dart';
|
||||
import 'package:sepot/features/auth/change_pin_screen.dart';
|
||||
import 'package:sepot/features/calls/call_screen.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/features/onboarding/onboarding_screen.dart';
|
||||
import 'package:sepot/features/onboarding/restore_screen.dart';
|
||||
@@ -17,6 +19,8 @@ import 'package:sepot/features/conversation/conversation_screen.dart';
|
||||
import 'package:sepot/features/contacts/contact_verify_screen.dart';
|
||||
import 'package:sepot/features/contacts/contacts_screen.dart';
|
||||
import 'package:sepot/features/groups/create_group_screen.dart';
|
||||
import 'package:sepot/features/profile/contact_profile_screen.dart';
|
||||
import 'package:sepot/features/profile/group_profile_screen.dart';
|
||||
import 'package:sepot/features/settings/settings_screen.dart';
|
||||
import 'package:sepot/features/safety/report_screen.dart';
|
||||
import 'package:sepot/features/qr_pairing/qr_share_screen.dart';
|
||||
@@ -62,6 +66,21 @@ final _router = GoRouter(
|
||||
path: '/conversation/:contactId',
|
||||
builder: (context, state) => ConversationScreen(
|
||||
contactId: state.pathParameters['contactId']!,
|
||||
startSearch: state.uri.queryParameters['search'] == '1',
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/contact/:contactId',
|
||||
builder: (context, state) => ContactProfileScreen(
|
||||
contactId: state.pathParameters['contactId']!,
|
||||
fromChat: state.uri.queryParameters['from'] == 'chat',
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/group/:groupId',
|
||||
builder: (context, state) => GroupProfileScreen(
|
||||
groupId: state.pathParameters['groupId']!,
|
||||
fromChat: state.uri.queryParameters['from'] == 'chat',
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
@@ -72,6 +91,11 @@ final _router = GoRouter(
|
||||
path: '/create-group',
|
||||
builder: (context, state) => const CreateGroupScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/group/:groupId/add',
|
||||
builder: (context, state) =>
|
||||
CreateGroupScreen(addToGroupId: state.pathParameters['groupId']),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/verify/:contactId',
|
||||
builder: (context, state) => ContactVerifyScreen(
|
||||
@@ -101,6 +125,10 @@ final _router = GoRouter(
|
||||
path: '/change-pin',
|
||||
builder: (context, state) => const ChangePinScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/call',
|
||||
builder: (context, state) => const CallScreen(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -116,6 +144,16 @@ class SepotApp extends ConsumerWidget {
|
||||
// null = systémový jazyk; než se volba načte z keystore, jede systém.
|
||||
final locale = ref.watch(localeProvider).valueOrNull;
|
||||
|
||||
// Příchozí i odchozí hovor otevře obrazovku hovoru odkudkoli.
|
||||
ref.listen(callProvider.select((s) => s.phase), (previous, phase) {
|
||||
final starting =
|
||||
phase == CallPhase.incoming || phase == CallPhase.outgoing;
|
||||
final path = _router.routerDelegate.currentConfiguration.uri.path;
|
||||
if (starting && previous != phase && path != '/call') {
|
||||
_router.push('/call');
|
||||
}
|
||||
});
|
||||
|
||||
return _AutoLockGate(
|
||||
child: MaterialApp.router(
|
||||
onGenerateTitle: (context) => context.l10n.appTitle,
|
||||
@@ -251,7 +289,9 @@ class _LoadingScreenState extends ConsumerState<_LoadingScreen> {
|
||||
final storage = ref.read(storageProvider);
|
||||
final legacyPin = await storage.hasLegacyPin();
|
||||
await storage.unlockWithoutPin();
|
||||
if (mounted) context.go(legacyPin ? '/pin-setup' : '/chats');
|
||||
if (!mounted) return;
|
||||
ref.invalidate(screenSecurityProvider);
|
||||
context.go(legacyPin ? '/pin-setup' : '/chats');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/// Verze aplikace čtená z nainstalovaného balíčku.
|
||||
///
|
||||
/// Proč ne konstanta: verze se dřív psala ručně do `AppConstants` a musela se
|
||||
/// hlídat proti `version:` v pubspec.yaml. Nehlídala se — produkční 1.1.2+22
|
||||
/// posílala v hlášeních zneužití „1.1.0" a podle hlášení tak nešlo dohledat,
|
||||
/// ze kterého buildu přišlo. `package_info_plus` čte `versionName` (Android)
|
||||
/// / `CFBundleShortVersionString` (iOS), které Flutter generuje přímo
|
||||
/// z pubspecu — rozejít se to už nemůže a ruční editace odpadá.
|
||||
library;
|
||||
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
class AppInfo {
|
||||
AppInfo._();
|
||||
|
||||
/// Zástupný text, dokud verzi nezná (nebo když ji nešlo přečíst). Znak, ne
|
||||
/// slovo — appka běží v pěti jazycích a české „neznámá" by v nich čouhalo.
|
||||
static const String unknownVersion = '—';
|
||||
|
||||
static PackageInfo? _info;
|
||||
|
||||
/// Načte verzi z balíčku. Volat jednou při startu, před prvním `runApp`.
|
||||
static Future<void> init() async {
|
||||
_info = await PackageInfo.fromPlatform();
|
||||
}
|
||||
|
||||
/// Verze pro uživatele: `1.1.2`.
|
||||
static String get version => _info?.version ?? unknownVersion;
|
||||
|
||||
/// Verze pro diagnostiku: `1.1.2+22`. Build číslo je jediné, čím se hlášení
|
||||
/// zneužití přiřadí konkrétnímu buildu — do UI nepatří, do hlášení ano.
|
||||
/// `null`, dokud verzi nezná: hlášení pak pole vynechá, ať server dostane
|
||||
/// prázdno místo vymyšlené hodnoty.
|
||||
static String? get versionWithBuild {
|
||||
final info = _info;
|
||||
if (info == null) return null;
|
||||
// iOS bez vyplněného CFBundleVersion vrací v buildNumber rovnou verzi
|
||||
// (dokumentované chování balíčku) — nelepit „1.1.2+1.1.2".
|
||||
if (info.buildNumber.isEmpty || info.buildNumber == info.version) {
|
||||
return info.version;
|
||||
}
|
||||
return '${info.version}+${info.buildNumber}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' hide MessageType;
|
||||
import 'package:sepot/core/calls/call_ringer.dart';
|
||||
import 'package:sepot/models/call_log.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/push_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Fáze hovoru.
|
||||
enum CallPhase { idle, outgoing, incoming, connecting, active, ended }
|
||||
|
||||
class CallState {
|
||||
const CallState({
|
||||
this.phase = CallPhase.idle,
|
||||
this.callId,
|
||||
this.contactId,
|
||||
this.contactName = '',
|
||||
this.video = false,
|
||||
this.outgoing = false,
|
||||
this.connectedAt,
|
||||
this.muted = false,
|
||||
this.speaker = false,
|
||||
this.cameraOff = false,
|
||||
this.outcome,
|
||||
});
|
||||
|
||||
final CallPhase phase;
|
||||
final String? callId;
|
||||
final String? contactId;
|
||||
final String contactName;
|
||||
final bool video;
|
||||
final bool outgoing;
|
||||
final DateTime? connectedAt;
|
||||
final bool muted;
|
||||
final bool speaker;
|
||||
final bool cameraOff;
|
||||
|
||||
/// Proč hovor skončil (jen ve fázi [CallPhase.ended]).
|
||||
final CallOutcome? outcome;
|
||||
|
||||
bool get busy => phase != CallPhase.idle && phase != CallPhase.ended;
|
||||
|
||||
CallState copyWith({
|
||||
CallPhase? phase,
|
||||
DateTime? connectedAt,
|
||||
bool? muted,
|
||||
bool? speaker,
|
||||
bool? cameraOff,
|
||||
CallOutcome? outcome,
|
||||
}) =>
|
||||
CallState(
|
||||
phase: phase ?? this.phase,
|
||||
callId: callId,
|
||||
contactId: contactId,
|
||||
contactName: contactName,
|
||||
video: video,
|
||||
outgoing: outgoing,
|
||||
connectedAt: connectedAt ?? this.connectedAt,
|
||||
muted: muted ?? this.muted,
|
||||
speaker: speaker ?? this.speaker,
|
||||
cameraOff: cameraOff ?? this.cameraOff,
|
||||
outcome: outcome ?? this.outcome,
|
||||
);
|
||||
}
|
||||
|
||||
/// Řídí jeden hovor 1:1: WebRTC spojení, signalizaci přes E2E kanál
|
||||
/// Šepotu a záznam výsledku do chatu. Druhý souběžný hovor se odmítne
|
||||
/// jako `busy`.
|
||||
class CallController extends Notifier<CallState> {
|
||||
/// Nabídka starší než tohle už nezvoní — jen se zapíše jako zmeškaná.
|
||||
/// Stejná mez jako expirace VoIP pushe na relayi.
|
||||
static const ringTimeout = Duration(seconds: 45);
|
||||
|
||||
/// ICE bez trickle: nabídka jde až po sběru kandidátů, nejdéle po této
|
||||
/// době (jinak by pomalé rozhraní zdrželo zvonění).
|
||||
static const _iceGatherTimeout = Duration(seconds: 3);
|
||||
|
||||
/// Jak dlouho je vidět obrazovka „Hovor skončil“, než se zavře.
|
||||
static const endedLinger = Duration(seconds: 2);
|
||||
|
||||
RTCPeerConnection? _pc;
|
||||
MediaStream? _localStream;
|
||||
String? _pendingOfferSdp;
|
||||
Contact? _peer;
|
||||
Timer? _ringTimer;
|
||||
|
||||
/// Id systémového zvonění (CallKit / Android) k běžícímu příchozímu
|
||||
/// hovoru; null = zvoní jen obrazovka appky.
|
||||
String? _ringId;
|
||||
|
||||
final localRenderer = RTCVideoRenderer();
|
||||
final remoteRenderer = RTCVideoRenderer();
|
||||
bool _renderersReady = false;
|
||||
|
||||
@override
|
||||
CallState build() {
|
||||
CallRinger.listen(
|
||||
onAccept: _onRingAccept,
|
||||
onDecline: _onRingDecline,
|
||||
onEnded: _onRingEnded,
|
||||
onVoipToken: () => ref.read(pushServiceProvider).refreshVoipToken(),
|
||||
);
|
||||
ref.onDispose(() {
|
||||
unawaited(_cleanup());
|
||||
localRenderer.dispose();
|
||||
remoteRenderer.dispose();
|
||||
});
|
||||
return const CallState();
|
||||
}
|
||||
|
||||
// ── Odchozí ────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> startCall(Contact contact, {required bool video}) async {
|
||||
if (state.busy) return;
|
||||
_peer = contact;
|
||||
state = CallState(
|
||||
phase: CallPhase.outgoing,
|
||||
callId: const Uuid().v4(),
|
||||
contactId: contact.id,
|
||||
contactName: contact.displayName,
|
||||
video: video,
|
||||
outgoing: true,
|
||||
speaker: video,
|
||||
);
|
||||
try {
|
||||
await _openMedia(video: video);
|
||||
final pc = await _createPeerConnection();
|
||||
final offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
final sdp = await _gatheredSdp(pc);
|
||||
await ref.read(messagingServiceProvider).sendCallSignal(
|
||||
contact,
|
||||
{'cid': state.callId, 'k': 'offer', 'sdp': sdp, 'vid': video},
|
||||
ring: true,
|
||||
);
|
||||
_ringTimer = Timer(ringTimeout, () {
|
||||
if (state.phase == CallPhase.outgoing) {
|
||||
unawaited(_hangup(CallOutcome.unanswered, notify: true));
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
await _finish(CallOutcome.failed);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Příchozí ───────────────────────────────────────────────────────
|
||||
|
||||
/// Obsluha příchozí signalizace z [MessagingService.onCallSignal].
|
||||
Future<void> onSignal(Contact contact, Map<String, dynamic> signal) async {
|
||||
final cid = signal['cid'];
|
||||
final kind = signal['k'];
|
||||
if (cid is! String || kind is! String) return;
|
||||
|
||||
if (kind == 'offer') {
|
||||
await _onOffer(contact, cid, signal);
|
||||
return;
|
||||
}
|
||||
// Ostatní patří jen k právě běžícímu hovoru s tímtéž člověkem.
|
||||
if (cid != state.callId || contact.id != state.contactId) return;
|
||||
switch (kind) {
|
||||
case 'answer':
|
||||
final sdp = signal['sdp'];
|
||||
if (sdp is! String || state.phase != CallPhase.outgoing) return;
|
||||
_ringTimer?.cancel();
|
||||
state = state.copyWith(phase: CallPhase.connecting);
|
||||
await _pc?.setRemoteDescription(RTCSessionDescription(sdp, 'answer'));
|
||||
case 'hangup':
|
||||
final answered = state.connectedAt != null;
|
||||
await _finish(answered
|
||||
? CallOutcome.completed
|
||||
: state.outgoing
|
||||
? CallOutcome.rejected
|
||||
: CallOutcome.missed);
|
||||
case 'reject':
|
||||
await _finish(CallOutcome.rejected);
|
||||
case 'busy':
|
||||
await _finish(CallOutcome.busy);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onOffer(
|
||||
Contact contact, String cid, Map<String, dynamic> signal) async {
|
||||
final sdp = signal['sdp'];
|
||||
if (sdp is! String) return;
|
||||
final video = signal['vid'] == true;
|
||||
final sentAt = DateTime.fromMillisecondsSinceEpoch(
|
||||
(signal['ts'] as num?)?.toInt() ?? 0);
|
||||
// Pozdě doručená nabídka (telefon byl mimo signál) — nezvonit.
|
||||
if (DateTime.now().difference(sentAt) > ringTimeout) {
|
||||
await _log(contact.id,
|
||||
CallLog(outcome: CallOutcome.missed, outgoing: false, video: video),
|
||||
at: sentAt);
|
||||
return;
|
||||
}
|
||||
if (state.busy) {
|
||||
if (cid != state.callId) {
|
||||
await ref
|
||||
.read(messagingServiceProvider)
|
||||
.sendCallSignal(contact, {'cid': cid, 'k': 'busy'});
|
||||
}
|
||||
return;
|
||||
}
|
||||
_peer = contact;
|
||||
_pendingOfferSdp = sdp;
|
||||
state = CallState(
|
||||
phase: CallPhase.incoming,
|
||||
callId: cid,
|
||||
contactId: contact.id,
|
||||
contactName: contact.displayName,
|
||||
video: video,
|
||||
speaker: video,
|
||||
);
|
||||
_ringTimer = Timer(ringTimeout, () {
|
||||
if (state.phase == CallPhase.incoming) {
|
||||
unawaited(_finish(CallOutcome.missed));
|
||||
}
|
||||
});
|
||||
await _attachSystemRing(cid, contact.displayName, video);
|
||||
}
|
||||
|
||||
/// Spáruje hovor se systémovým zvoněním. Zvonění z budíčku relaye už
|
||||
/// běží (možná ho uživatel mezitím přijal na zamčené obrazovce); jinak
|
||||
/// se zvonění spustí, jen když appka není v popředí — v popředí stačí
|
||||
/// vlastní obrazovka hovoru.
|
||||
Future<void> _attachSystemRing(String cid, String name, bool video) async {
|
||||
final pushRing = await CallRinger.pendingPushRing();
|
||||
if (pushRing != null) {
|
||||
_ringId = pushRing.id;
|
||||
if (pushRing.accepted) {
|
||||
await accept();
|
||||
return;
|
||||
}
|
||||
if (CallRinger.declinedEarly.remove(pushRing.id)) {
|
||||
await reject();
|
||||
return;
|
||||
}
|
||||
// Spárováno s nabídkou: aby grace z `ringFromPush` zvonění neukončila,
|
||||
// nahradí se jménem volajícího (bez značky push).
|
||||
await _claimPushRing(pushRing.id, name, video);
|
||||
return;
|
||||
}
|
||||
final foreground =
|
||||
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed;
|
||||
if (!foreground) {
|
||||
_ringId = cid;
|
||||
await CallRinger.ringFor(callId: cid, name: name, video: video);
|
||||
}
|
||||
}
|
||||
|
||||
/// Převezme zvonění z budíčku: jméno volajícího místo „Šepot" a bez
|
||||
/// značky push (grace v [CallRinger.ringFromPush] ho pak nechá být).
|
||||
Future<void> _claimPushRing(String pushId, String name, bool video) async {
|
||||
if (await CallRinger.isAccepted(pushId)) return; // uživatel už zvedl
|
||||
await CallRinger.end(pushId);
|
||||
_ringId = state.callId;
|
||||
await CallRinger.ringFor(callId: state.callId!, name: name, video: video);
|
||||
}
|
||||
|
||||
void _onRingAccept(String id) {
|
||||
if (id == _ringId && state.phase == CallPhase.incoming) {
|
||||
unawaited(accept());
|
||||
}
|
||||
}
|
||||
|
||||
void _onRingDecline(String id) {
|
||||
if (id == _ringId && state.phase == CallPhase.incoming) {
|
||||
unawaited(reject());
|
||||
} else if (!state.busy) {
|
||||
CallRinger.declinedEarly.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Zavěšení ze systémového rozhraní (zamčená obrazovka, sluchátka).
|
||||
void _onRingEnded(String id) {
|
||||
if (id == _ringId && state.busy) unawaited(hangUp());
|
||||
}
|
||||
|
||||
Future<void> accept() async {
|
||||
final peer = _peer;
|
||||
final offer = _pendingOfferSdp;
|
||||
if (state.phase != CallPhase.incoming || peer == null || offer == null) {
|
||||
return;
|
||||
}
|
||||
_ringTimer?.cancel();
|
||||
state = state.copyWith(phase: CallPhase.connecting);
|
||||
try {
|
||||
await _openMedia(video: state.video);
|
||||
final pc = await _createPeerConnection();
|
||||
await pc.setRemoteDescription(RTCSessionDescription(offer, 'offer'));
|
||||
final answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
final sdp = await _gatheredSdp(pc);
|
||||
await ref.read(messagingServiceProvider).sendCallSignal(
|
||||
peer, {'cid': state.callId, 'k': 'answer', 'sdp': sdp});
|
||||
} catch (_) {
|
||||
await _hangup(CallOutcome.failed, notify: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reject() async {
|
||||
if (state.phase != CallPhase.incoming) return;
|
||||
await _hangup(CallOutcome.rejected, notify: true, kind: 'reject');
|
||||
}
|
||||
|
||||
/// Zavěšení v jakékoli fázi (vytáčení, spojování, hovor).
|
||||
Future<void> hangUp() async {
|
||||
if (!state.busy) return;
|
||||
final outcome = state.connectedAt != null
|
||||
? CallOutcome.completed
|
||||
: state.outgoing
|
||||
? CallOutcome.unanswered
|
||||
: CallOutcome.rejected;
|
||||
await _hangup(outcome, notify: true);
|
||||
}
|
||||
|
||||
// ── Ovládání během hovoru ──────────────────────────────────────────
|
||||
|
||||
void toggleMute() {
|
||||
final muted = !state.muted;
|
||||
for (final t in _localStream?.getAudioTracks() ?? const []) {
|
||||
t.enabled = !muted;
|
||||
}
|
||||
state = state.copyWith(muted: muted);
|
||||
}
|
||||
|
||||
Future<void> toggleSpeaker() async {
|
||||
final speaker = !state.speaker;
|
||||
await Helper.setSpeakerphoneOn(speaker);
|
||||
state = state.copyWith(speaker: speaker);
|
||||
}
|
||||
|
||||
void toggleCamera() {
|
||||
final off = !state.cameraOff;
|
||||
for (final t in _localStream?.getVideoTracks() ?? const []) {
|
||||
t.enabled = !off;
|
||||
}
|
||||
state = state.copyWith(cameraOff: off);
|
||||
}
|
||||
|
||||
Future<void> switchCamera() async {
|
||||
final track = _localStream?.getVideoTracks().firstOrNull;
|
||||
if (track != null) await Helper.switchCamera(track);
|
||||
}
|
||||
|
||||
// ── WebRTC ─────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _openMedia({required bool video}) async {
|
||||
if (!_renderersReady) {
|
||||
await localRenderer.initialize();
|
||||
await remoteRenderer.initialize();
|
||||
_renderersReady = true;
|
||||
}
|
||||
final stream = await navigator.mediaDevices.getUserMedia({
|
||||
'audio': true,
|
||||
'video': video ? {'facingMode': 'user'} : false,
|
||||
});
|
||||
_localStream = stream;
|
||||
localRenderer.srcObject = video ? stream : null;
|
||||
await Helper.setSpeakerphoneOn(video);
|
||||
}
|
||||
|
||||
Future<RTCPeerConnection> _createPeerConnection() async {
|
||||
final pc = await createPeerConnection({
|
||||
'iceServers': await _iceServers(),
|
||||
'sdpSemantics': 'unified-plan',
|
||||
});
|
||||
final local = _localStream;
|
||||
for (final track in local?.getTracks() ?? const <MediaStreamTrack>[]) {
|
||||
await pc.addTrack(track, local!);
|
||||
}
|
||||
pc.onTrack = (event) {
|
||||
if (event.streams.isNotEmpty) {
|
||||
remoteRenderer.srcObject = event.streams.first;
|
||||
}
|
||||
};
|
||||
pc.onConnectionState = (s) {
|
||||
switch (s) {
|
||||
case RTCPeerConnectionState.RTCPeerConnectionStateConnected:
|
||||
if (state.connectedAt == null) {
|
||||
state = state.copyWith(
|
||||
phase: CallPhase.active, connectedAt: DateTime.now());
|
||||
final ring = _ringId;
|
||||
if (ring != null) unawaited(CallRinger.connected(ring));
|
||||
}
|
||||
case RTCPeerConnectionState.RTCPeerConnectionStateFailed:
|
||||
unawaited(_hangup(
|
||||
state.connectedAt != null
|
||||
? CallOutcome.completed
|
||||
: CallOutcome.failed,
|
||||
notify: true));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
_pc = pc;
|
||||
return pc;
|
||||
}
|
||||
|
||||
/// STUN bez údajů: coturn na témž hostiteli odpovídá na STUN bez
|
||||
/// přihlášení, takže i když relay TURN údaje nevydá (503, timeout),
|
||||
/// jde aspoň přímé spojení přes NAT. Dřív byl seznam prázdný = jen host
|
||||
/// kandidáti = hovor jen v jedné LAN.
|
||||
static const _stunFallback = {
|
||||
'urls': ['stun:turn.sepot.cz:3478'],
|
||||
};
|
||||
|
||||
/// STUN i TURN běží na stejném serveru; bez údajů z relaye aspoň STUN
|
||||
/// (přímé spojení, když obě strany nejsou za symetrickým NATem).
|
||||
Future<List<Map<String, dynamic>>> _iceServers() async {
|
||||
final turn = await ref.read(messagingServiceProvider).turnCredentials();
|
||||
if (turn == null || turn.urls.isEmpty) return const [_stunFallback];
|
||||
return [
|
||||
{
|
||||
'urls': turn.urls,
|
||||
'username': turn.username,
|
||||
'credential': turn.credential,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/// SDP po sběru ICE kandidátů (non-trickle).
|
||||
Future<String> _gatheredSdp(RTCPeerConnection pc) async {
|
||||
final done = Completer<void>();
|
||||
pc.onIceGatheringState = (s) {
|
||||
if (s == RTCIceGatheringState.RTCIceGatheringStateComplete &&
|
||||
!done.isCompleted) {
|
||||
done.complete();
|
||||
}
|
||||
};
|
||||
if (pc.iceGatheringState ==
|
||||
RTCIceGatheringState.RTCIceGatheringStateComplete) {
|
||||
done.complete();
|
||||
}
|
||||
await done.future.timeout(_iceGatherTimeout, onTimeout: () {});
|
||||
final local = await pc.getLocalDescription();
|
||||
final sdp = local?.sdp;
|
||||
if (sdp == null) throw StateError('no local description');
|
||||
return sdp;
|
||||
}
|
||||
|
||||
// ── Konec hovoru ───────────────────────────────────────────────────
|
||||
|
||||
Future<void> _hangup(CallOutcome outcome,
|
||||
{required bool notify, String kind = 'hangup'}) async {
|
||||
final peer = _peer;
|
||||
final cid = state.callId;
|
||||
if (notify && peer != null && cid != null) {
|
||||
try {
|
||||
await ref
|
||||
.read(messagingServiceProvider)
|
||||
.sendCallSignal(peer, {'cid': cid, 'k': kind});
|
||||
} catch (_) {
|
||||
// Protistrana zavěšení nedostala — její hovor skončí sám, až
|
||||
// spojení vypadne (ICE failed) nebo vyprší zvonění.
|
||||
}
|
||||
}
|
||||
await _finish(outcome);
|
||||
}
|
||||
|
||||
Future<void> _finish(CallOutcome outcome) async {
|
||||
if (state.phase == CallPhase.idle || state.phase == CallPhase.ended) {
|
||||
return;
|
||||
}
|
||||
final connectedAt = state.connectedAt;
|
||||
final contactId = state.contactId;
|
||||
final log = CallLog(
|
||||
outcome: outcome,
|
||||
outgoing: state.outgoing,
|
||||
video: state.video,
|
||||
durationSeconds: connectedAt == null
|
||||
? 0
|
||||
: DateTime.now().difference(connectedAt).inSeconds,
|
||||
);
|
||||
state = state.copyWith(phase: CallPhase.ended, outcome: outcome);
|
||||
await _cleanup();
|
||||
if (contactId != null) await _log(contactId, log);
|
||||
Timer(endedLinger, () {
|
||||
if (state.phase == CallPhase.ended) state = const CallState();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _cleanup() async {
|
||||
_ringTimer?.cancel();
|
||||
_ringTimer = null;
|
||||
final ring = _ringId;
|
||||
_ringId = null;
|
||||
// Konec zvonění vyvolá událost „ukončeno“ — to už je stav mimo hovor,
|
||||
// takže ji _onRingEnded ignoruje.
|
||||
if (ring != null) unawaited(CallRinger.end(ring));
|
||||
_pendingOfferSdp = null;
|
||||
_peer = null;
|
||||
for (final t in _localStream?.getTracks() ?? const <MediaStreamTrack>[]) {
|
||||
await t.stop();
|
||||
}
|
||||
await _localStream?.dispose();
|
||||
_localStream = null;
|
||||
await _pc?.close();
|
||||
_pc = null;
|
||||
if (_renderersReady) {
|
||||
localRenderer.srcObject = null;
|
||||
remoteRenderer.srcObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Řádek „Hovor · 3:12“ do chatu. Jen lokálně — nic se neposílá.
|
||||
Future<void> _log(String contactId, CallLog log, {DateTime? at}) async {
|
||||
final myId = ref.read(identityProvider).valueOrNull?.id;
|
||||
if (myId == null) return;
|
||||
final message = Message(
|
||||
id: const Uuid().v4(),
|
||||
senderId: log.outgoing ? myId : contactId,
|
||||
recipientId: log.outgoing ? contactId : myId,
|
||||
type: MessageType.text,
|
||||
timestamp: at ?? DateTime.now(),
|
||||
// Záznam hovoru není nepřečtená zpráva — kromě zmeškaného.
|
||||
isRead: log.outcome != CallOutcome.missed,
|
||||
isDelivered: true,
|
||||
callLog: log,
|
||||
);
|
||||
await ref.read(storageProvider).addMessage(contactId, message);
|
||||
ref.invalidate(conversationsProvider);
|
||||
ref.invalidate(conversationProvider(contactId));
|
||||
}
|
||||
}
|
||||
|
||||
final callProvider =
|
||||
NotifierProvider<CallController, CallState>(CallController.new);
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Systémové zvonění (CallKit na iOS, celoobrazovkové na Androidu) pro
|
||||
/// hovory, které přijdou, když appka není v popředí.
|
||||
///
|
||||
/// Budíček z relaye nenese, kdo volá — relay to neví. Zvonění z budíčku
|
||||
/// proto ukáže „Šepot“ a označí se `extra.push`; až appka dešifruje
|
||||
/// nabídku, [CallController] ho spáruje s hovorem podle toho.
|
||||
class CallRinger {
|
||||
CallRinger._();
|
||||
|
||||
static const _appName = 'Šepot';
|
||||
|
||||
/// Zvonění vzniklé z budíčku, ke kterému ještě nedorazila nabídka.
|
||||
static const pushMarker = 'push';
|
||||
|
||||
/// Jak dlouho zvoní — stejné jako expirace budíčku na relayi.
|
||||
static const ringMillis = 45000;
|
||||
|
||||
/// Zvonění z budíčku, ke kterému do té doby nedorazila nabídka, se
|
||||
/// ukončí samo. Relay budíček hovoru nijak neověřuje (sealed sender),
|
||||
/// takže kdokoli se znalostí adresy by jinak nechal telefon zvonit
|
||||
/// 45 s na každou obálku s hintem CALL. Nabídka od
|
||||
/// skutečného volajícího dorazí do pár sekund po připojení.
|
||||
static const pushRingGrace = Duration(seconds: 12);
|
||||
|
||||
static StreamSubscription<CallEvent?>? _sub;
|
||||
|
||||
/// Id zvonění, která uživatel odmítl dřív, než dorazila nabídka.
|
||||
/// (Přijetí si pamatuje samo zvonění — `isAccepted` v [pendingPushRing].)
|
||||
static final declinedEarly = <String>{};
|
||||
|
||||
static void listen({
|
||||
required void Function(String id) onAccept,
|
||||
required void Function(String id) onDecline,
|
||||
required void Function(String id) onEnded,
|
||||
required void Function() onVoipToken,
|
||||
}) {
|
||||
_sub ??= FlutterCallkitIncoming.onEvent.listen((event) {
|
||||
switch (event) {
|
||||
case CallEventActionCallAccept(:final callKitParams):
|
||||
onAccept(callKitParams.id);
|
||||
case CallEventActionCallDecline(:final callKitParams):
|
||||
onDecline(callKitParams.id);
|
||||
case CallEventActionCallEnded(:final callKitParams):
|
||||
onEnded(callKitParams.id);
|
||||
case CallEventActionDidUpdateDevicePushTokenVoip():
|
||||
onVoipToken();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static Future<String?> voipToken() async {
|
||||
final token = await FlutterCallkitIncoming.getDevicePushTokenVoIP();
|
||||
return token != null && token.isNotEmpty ? token : null;
|
||||
}
|
||||
|
||||
/// Zvonění pro dešifrovanou nabídku (appka běží na pozadí).
|
||||
static Future<void> ringFor({
|
||||
required String callId,
|
||||
required String name,
|
||||
required bool video,
|
||||
}) =>
|
||||
FlutterCallkitIncoming.showCallkitIncoming(
|
||||
_params(id: callId, name: name, video: video));
|
||||
|
||||
/// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme.
|
||||
/// Vrací až po [pushRingGrace]: pokud se zvonění do té doby nespárovalo
|
||||
/// s nabídkou (přijetí nebo ukončení ho z aktivních hovorů odebere, nebo
|
||||
/// ho [CallController] převezme), tiše se ukončí. Background isolate FCM
|
||||
/// handleru tím zůstane naživu přesně tak dlouho, jak je potřeba.
|
||||
static Future<void> ringFromPush() async {
|
||||
final id = const Uuid().v4();
|
||||
await FlutterCallkitIncoming.showCallkitIncoming(_params(
|
||||
id: id, name: _appName, video: false, extra: {pushMarker: true}));
|
||||
await Future<void>.delayed(pushRingGrace);
|
||||
await endIfUnmatched(id);
|
||||
}
|
||||
|
||||
/// iOS: zvonění z VoIP pushe zakládá nativní AppDelegate (CallKit musí
|
||||
/// zazvonit hned), takže grace z [ringFromPush] tam neběží. Po startu
|
||||
/// Dartu se proto každé nepřevzaté zvonění z budíčku ohlídá odsud.
|
||||
static Future<void> expireStalePushRings() async {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final call in calls) {
|
||||
if (call.extra?[pushMarker] == true && !call.isAccepted) {
|
||||
final id = call.id;
|
||||
unawaited(Future<void>.delayed(pushRingGrace, () => endIfUnmatched(id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ukončí zvonění z budíčku, které nikdo nepřevzal (bez záznamu
|
||||
/// o zmeškaném hovoru — nebylo od koho).
|
||||
static Future<void> endIfUnmatched(String id) async {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final call in calls) {
|
||||
if (call.id == id && call.extra?[pushMarker] == true && !call.isAccepted) {
|
||||
await FlutterCallkitIncoming.endCall(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zvonění z budíčku, které čeká na nabídku (nejnovější), a jestli ho
|
||||
/// uživatel už přijal — typicky na zamčené obrazovce, než appka naběhla.
|
||||
static Future<({String id, bool accepted})?> pendingPushRing() async {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final call in calls.reversed) {
|
||||
if (call.extra?[pushMarker] == true) {
|
||||
return (id: call.id, accepted: call.isAccepted);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<bool> isAccepted(String id) async {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final call in calls) {
|
||||
if (call.id == id) return call.isAccepted;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<void> connected(String id) =>
|
||||
FlutterCallkitIncoming.setCallConnected(id);
|
||||
|
||||
static Future<void> end(String id) => FlutterCallkitIncoming.endCall(id);
|
||||
|
||||
/// Texty zvonění. Zvonění z budíčku běží v izolátu na pozadí bez
|
||||
/// lokalizace appky, proto vlastní malý slovník podle jazyka systému.
|
||||
static const _texts = {
|
||||
'cs': ('Přijmout', 'Odmítnout', 'Příchozí hovory'),
|
||||
'de': ('Annehmen', 'Ablehnen', 'Eingehende Anrufe'),
|
||||
'it': ('Accetta', 'Rifiuta', 'Chiamate in arrivo'),
|
||||
'ru': ('Принять', 'Отклонить', 'Входящие звонки'),
|
||||
'en': ('Accept', 'Decline', 'Incoming calls'),
|
||||
};
|
||||
|
||||
static (String, String, String) get _localTexts =>
|
||||
_texts[PlatformDispatcher.instance.locale.languageCode] ??
|
||||
_texts['en']!;
|
||||
|
||||
static CallKitParams _params({
|
||||
required String id,
|
||||
required String name,
|
||||
required bool video,
|
||||
Map<String, dynamic>? extra,
|
||||
}) =>
|
||||
CallKitParams(
|
||||
id: id,
|
||||
nameCaller: name,
|
||||
appName: _appName,
|
||||
handle: _appName,
|
||||
type: video ? 1 : 0,
|
||||
duration: ringMillis,
|
||||
extra: extra ?? const {},
|
||||
missedCallNotification: const NotificationParams(
|
||||
showNotification: false,
|
||||
),
|
||||
android: AndroidParams(
|
||||
isCustomNotification: true,
|
||||
isShowLogo: false,
|
||||
ringtonePath: 'system_ringtone_default',
|
||||
backgroundColor: '#0B0C12',
|
||||
actionColor: '#7FAE8C',
|
||||
textColor: '#F0EEE8',
|
||||
incomingCallNotificationChannelName: _localTexts.$3,
|
||||
textAccept: _localTexts.$1,
|
||||
textDecline: _localTexts.$2,
|
||||
isShowFullLockedScreen: true,
|
||||
),
|
||||
ios: const IOSParams(
|
||||
handleType: 'generic',
|
||||
supportsVideo: true,
|
||||
maximumCallGroups: 1,
|
||||
maximumCallsPerCallGroup: 1,
|
||||
supportsDTMF: false,
|
||||
supportsHolding: false,
|
||||
supportsGrouping: false,
|
||||
supportsUngrouping: false,
|
||||
ringtonePath: 'system_ringtone_default',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,12 @@ class AppConstants {
|
||||
AppConstants._();
|
||||
|
||||
static const String appName = 'Šepot';
|
||||
/// Musí sedět s `version:` v pubspec.yaml (bez build čísla).
|
||||
static const String appVersion = '1.1.0';
|
||||
static const String appAuthor = 'ajtak.it';
|
||||
|
||||
/// Id konverzace „Poznámky pro sebe“. Nekoliduje s fingerprintem (hex)
|
||||
/// ani s gid skupiny (UUID); podle něj se pozná, že nic nejde do sítě.
|
||||
static const String notesConversationId = 'notes';
|
||||
|
||||
// Relay server (REST + WS na jednom portu). Přepsatelné při buildu:
|
||||
// flutter build --dart-define=SEPOT_RELAY=http://<dev-relay-host>:8470
|
||||
static const String relayUrl = String.fromEnvironment(
|
||||
|
||||
@@ -53,16 +53,20 @@ class DekWrap {
|
||||
int memoryKiB = defaultMemoryKiB,
|
||||
int iterations = defaultIterations,
|
||||
int parallelism = defaultParallelism,
|
||||
Uint8List? salt,
|
||||
}) async {
|
||||
if (dek.length != 32) {
|
||||
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
|
||||
}
|
||||
_enforceFloor(memoryKiB, iterations);
|
||||
|
||||
final salt = _randomBytes(16);
|
||||
// Volitelná sdílená sůl: nátlakový blob se stejnou solí a parametry
|
||||
// jako skutečný odemyká jedinou derivací (viz unwrapFirstMatching) —
|
||||
// bez ní by decoy PIN trval 2× déle a šel poznat stopkami.
|
||||
final useSalt = salt ?? _randomBytes(16);
|
||||
final unlockKey = await _deriveUnlockKey(
|
||||
pin: pin,
|
||||
salt: salt,
|
||||
salt: useSalt,
|
||||
memoryKiB: memoryKiB,
|
||||
iterations: iterations,
|
||||
parallelism: parallelism,
|
||||
@@ -80,13 +84,72 @@ class DekWrap {
|
||||
'$memoryKiB',
|
||||
'$iterations',
|
||||
'$parallelism',
|
||||
base64Encode(salt),
|
||||
base64Encode(useSalt),
|
||||
base64Encode(box.nonce),
|
||||
base64Encode(box.cipherText),
|
||||
base64Encode(box.mac.bytes),
|
||||
].join(':');
|
||||
}
|
||||
|
||||
/// Sůl použitá v zabaleném blobu (pro sdílení s nátlakovým blobem).
|
||||
static Uint8List saltOf(String wrapped) {
|
||||
final parts = wrapped.split(':');
|
||||
if (parts.length != 8 || parts[0] != _version) {
|
||||
throw FormatException('Unknown wrapped DEK format', wrapped);
|
||||
}
|
||||
return Uint8List.fromList(base64Decode(parts[4]));
|
||||
}
|
||||
|
||||
/// Zkusí PIN proti několika blobům (skutečný + nátlakový) a vrátí index
|
||||
/// prvního, který sedí, s rozbaleným DEK. Argon2id se počítá jen jednou
|
||||
/// pro každou odlišnou kombinaci parametrů a soli, takže u blobů se
|
||||
/// sdílenou solí stojí odemčení stejně, ať sedí kterýkoli — čas
|
||||
/// odemčení neprozradí, který PIN byl zadán. Nic nesedí →
|
||||
/// [WrongPinException].
|
||||
static Future<(int, Uint8List)> unwrapFirstMatching({
|
||||
required List<String> wrapped,
|
||||
required String pin,
|
||||
}) async {
|
||||
final parsed = <List<String>>[];
|
||||
for (final w in wrapped) {
|
||||
final parts = w.split(':');
|
||||
if (parts.length != 8 || parts[0] != _version) {
|
||||
throw FormatException('Unknown wrapped DEK format', w);
|
||||
}
|
||||
_enforceFloor(int.parse(parts[1]), int.parse(parts[2]));
|
||||
parsed.add(parts);
|
||||
}
|
||||
final keys = <String, Uint8List>{};
|
||||
for (final parts in parsed) {
|
||||
final group = parts.sublist(1, 5).join(':');
|
||||
keys[group] ??= await _deriveUnlockKey(
|
||||
pin: pin,
|
||||
salt: base64Decode(parts[4]),
|
||||
memoryKiB: int.parse(parts[1]),
|
||||
iterations: int.parse(parts[2]),
|
||||
parallelism: int.parse(parts[3]),
|
||||
);
|
||||
}
|
||||
final aes = AesGcm.with256bits();
|
||||
for (var i = 0; i < parsed.length; i++) {
|
||||
final parts = parsed[i];
|
||||
try {
|
||||
final dek = await aes.decrypt(
|
||||
SecretBox(
|
||||
base64Decode(parts[6]),
|
||||
nonce: base64Decode(parts[5]),
|
||||
mac: Mac(base64Decode(parts[7])),
|
||||
),
|
||||
secretKey: SecretKey(keys[parts.sublist(1, 5).join(':')]!),
|
||||
);
|
||||
return (i, Uint8List.fromList(dek));
|
||||
} on SecretBoxAuthenticationError {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw const WrongPinException();
|
||||
}
|
||||
|
||||
/// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí
|
||||
/// [WrongPinException]; na neplatném formátu [FormatException].
|
||||
static Future<Uint8List> unwrap({
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/// Admin-signed membership pro skupiny.
|
||||
///
|
||||
/// Původní skupiny nemají role: roster update přijímají od kteréhokoli člena. V2 přidává
|
||||
/// admin/member role a KAŽDOU změnu členství/role/jména podepisuje admin svým
|
||||
/// identity klíčem (XEdDSA). Ostatní klienti podpis ověří proti identity klíči
|
||||
/// admina z rosteru — server tu roli nehraje (sealed sender, hloupý relay).
|
||||
///
|
||||
/// Podpis pokrývá VÝSLEDNÝ STAV (setříděné množiny členů a adminů + jméno +
|
||||
/// epocha) zřetězený na hash předchozího stavu (`prev`) — admin autorizuje
|
||||
/// konkrétní výsledek navázaný na známou historii, ne jen delta. Tím padá
|
||||
/// nejednoznačnost i fork: kdo nesedí na `prev`, ví, že se rozešel a musí
|
||||
/// resync.
|
||||
///
|
||||
/// Kanonická serializace je záměrně délkově-prefixovaná: přesun bajtů mezi poli
|
||||
/// (fingerprint ↔ jméno apod.) MUSÍ změnit podepisované bajty, jinak by šlo
|
||||
/// zaměnit význam podpisu.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:libsignal/libsignal.dart' show PublicKey;
|
||||
import 'package:pointycastle/digests/sha256.dart';
|
||||
|
||||
/// Typ admin operace. Pořadí (index) je součást podpisu — NIKDY nepřehazovat,
|
||||
/// jen přidávat na konec (jinak se rozbijí staré podpisy).
|
||||
///
|
||||
/// `sync` není změna: správce jím podepisuje svůj aktuální stav jako odpověď
|
||||
/// na žádost o resync (mezera v epoše / rozchod historie u příjemce).
|
||||
enum GroupAdminAction { add, remove, promote, demote, rename, sync }
|
||||
|
||||
/// Doménové separátory — jiný kontext = jiné podepisované bajty.
|
||||
const String _opDomain = 'sepot-group-adm-v1';
|
||||
const String _stateDomain = 'sepot-group-state-v1';
|
||||
|
||||
/// Jedna admin operace = autorizovaný přechod skupiny do výsledného stavu.
|
||||
class GroupAdminOp {
|
||||
GroupAdminOp({
|
||||
required this.gid,
|
||||
required this.epoch,
|
||||
required this.action,
|
||||
required this.actor,
|
||||
required List<String> members,
|
||||
required List<String> admins,
|
||||
required this.name,
|
||||
required this.prev,
|
||||
}) : members = _sortedUnique(members),
|
||||
admins = _sortedUnique(admins);
|
||||
|
||||
/// UUID skupiny (`gid` na drátu).
|
||||
final String gid;
|
||||
|
||||
/// Epocha, kterou tato operace ustavuje (monotónní, prev + 1).
|
||||
final int epoch;
|
||||
|
||||
final GroupAdminAction action;
|
||||
|
||||
/// Fingerprint podepisujícího admina. MUSÍ se rovnat sealed-sender
|
||||
/// fingerprintu odesílatele (anti-spoof).
|
||||
final String actor;
|
||||
|
||||
/// Výsledný roster (setříděné fingerprinty, včetně nezměněných členů).
|
||||
final List<String> members;
|
||||
|
||||
/// Výsledná množina adminů (setříděné fingerprinty, podmnožina `members`).
|
||||
final List<String> admins;
|
||||
|
||||
/// Výsledné jméno skupiny.
|
||||
final String name;
|
||||
|
||||
/// Hash předchozího stavu (`stateHash` epochy `epoch-1`); prázdné pro
|
||||
/// založení (epoch 0).
|
||||
final Uint8List prev;
|
||||
|
||||
/// Podepisované bajty — kanonické, délkově-prefixované, deterministické.
|
||||
Uint8List canonicalBytes() {
|
||||
final b = BytesBuilder(copy: false);
|
||||
b.add(_lpStr(_opDomain));
|
||||
b.add(_lpStr(gid));
|
||||
b.add(_u64be(epoch));
|
||||
b.addByte(action.index);
|
||||
b.add(_lpStr(actor));
|
||||
b.add(_lpList(members));
|
||||
b.add(_lpList(admins));
|
||||
b.add(_lpStr(name));
|
||||
b.add(_lp(prev));
|
||||
return b.toBytes();
|
||||
}
|
||||
|
||||
/// Hash tohoto výsledného stavu — vstupuje jako `prev` do NÁSLEDUJÍCÍ
|
||||
/// operace (řetěz proti forku).
|
||||
Uint8List stateHash() => hashState(
|
||||
gid: gid,
|
||||
epoch: epoch,
|
||||
members: members,
|
||||
admins: admins,
|
||||
name: name,
|
||||
);
|
||||
|
||||
/// XEdDSA podpis operace daným signerem (typicky `SepotCrypto.sign`).
|
||||
Uint8List signWith(Uint8List Function(List<int> message) signer) =>
|
||||
signer(canonicalBytes());
|
||||
|
||||
/// Ověří podpis proti identity klíči admina (33 B serializovaný pub klíč).
|
||||
/// Volající NAVÍC musí ověřit, že `actor` je v aktuální množině adminů a že
|
||||
/// `actor` == sealed-sender fingerprint — to zde nevidíme.
|
||||
bool verify(Uint8List signature, Uint8List actorIdentityKey) {
|
||||
final pk = PublicKey.deserialize(bytes: actorIdentityKey.toList());
|
||||
return pk.verify(
|
||||
message: canonicalBytes().toList(),
|
||||
signature: signature.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash stavu skupiny (setříděné množiny) — sdílený mezi op.stateHash a
|
||||
/// výpočtem `prev` u příjemce.
|
||||
Uint8List hashState({
|
||||
required String gid,
|
||||
required int epoch,
|
||||
required List<String> members,
|
||||
required List<String> admins,
|
||||
required String name,
|
||||
}) {
|
||||
final b = BytesBuilder(copy: false);
|
||||
b.add(_lpStr(_stateDomain));
|
||||
b.add(_lpStr(gid));
|
||||
b.add(_u64be(epoch));
|
||||
b.add(_lpList(_sortedUnique(members)));
|
||||
b.add(_lpList(_sortedUnique(admins)));
|
||||
b.add(_lpStr(name));
|
||||
return Uint8List.fromList(SHA256Digest().process(b.toBytes()));
|
||||
}
|
||||
|
||||
// ── Kanonické kódování ────────────────────────────────────────────────────
|
||||
|
||||
List<String> _sortedUnique(List<String> xs) {
|
||||
final s = xs.toSet().toList()..sort();
|
||||
return s;
|
||||
}
|
||||
|
||||
Uint8List _lp(List<int> bytes) {
|
||||
final b = BytesBuilder(copy: false);
|
||||
b.add(_u32be(bytes.length));
|
||||
b.add(bytes);
|
||||
return b.toBytes();
|
||||
}
|
||||
|
||||
Uint8List _lpStr(String s) => _lp(utf8.encode(s));
|
||||
|
||||
Uint8List _lpList(List<String> items) {
|
||||
final b = BytesBuilder(copy: false);
|
||||
b.add(_u32be(items.length));
|
||||
for (final item in items) {
|
||||
b.add(_lpStr(item));
|
||||
}
|
||||
return b.toBytes();
|
||||
}
|
||||
|
||||
Uint8List _u32be(int n) {
|
||||
final d = ByteData(4)..setUint32(0, n, Endian.big);
|
||||
return d.buffer.asUint8List();
|
||||
}
|
||||
|
||||
Uint8List _u64be(int n) {
|
||||
final d = ByteData(8)..setUint64(0, n, Endian.big);
|
||||
return d.buffer.asUint8List();
|
||||
}
|
||||
@@ -81,6 +81,32 @@ class SepotSealedMessage {
|
||||
}
|
||||
|
||||
/// Jeden one-time prekey k publikaci na relay.
|
||||
/// Nový signed prekey k nahrání na relay (rotace).
|
||||
class SepotSignedPreKey {
|
||||
SepotSignedPreKey({
|
||||
required this.id,
|
||||
required this.publicKey,
|
||||
required this.signature,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final Uint8List publicKey;
|
||||
final Uint8List signature;
|
||||
}
|
||||
|
||||
/// Nový last-resort Kyber prekey k nahrání na relay.
|
||||
class SepotKyberPreKey {
|
||||
SepotKyberPreKey({
|
||||
required this.id,
|
||||
required this.publicKey,
|
||||
required this.signature,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final Uint8List publicKey;
|
||||
final Uint8List signature;
|
||||
}
|
||||
|
||||
class SepotOneTimePreKey {
|
||||
SepotOneTimePreKey({required this.id, required this.publicKey});
|
||||
|
||||
@@ -305,6 +331,18 @@ class SepotCrypto {
|
||||
Future<bool> hasSession(ProtocolAddress remote) =>
|
||||
_stores.session.containsSession(remote);
|
||||
|
||||
/// Identity klíč, pod kterým naposledy vznikla relace s [remote]
|
||||
/// (zapisuje ho libsignal při X3DH i při příjmu PreKey zprávy).
|
||||
/// Null = zatím žádný.
|
||||
Future<Uint8List?> sessionIdentity(ProtocolAddress remote) async {
|
||||
final key = await _stores.identity.getIdentity(remote);
|
||||
return key == null ? null : Uint8List.fromList(key.serialize().toList());
|
||||
}
|
||||
|
||||
/// Zahodí relaci s [remote]; další zpráva založí novou z bundle.
|
||||
Future<void> dropSession(ProtocolAddress remote) =>
|
||||
_stores.session.deleteSession(remote);
|
||||
|
||||
/// X3DH/PQXDH: zpracuje bundle protistrany a založí session.
|
||||
Future<void> processRemoteBundle(
|
||||
ProtocolAddress remote,
|
||||
@@ -420,4 +458,64 @@ class SepotCrypto {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Nový signed prekey (rotace). Starý zůstává ve store — relace založené
|
||||
/// z bundle těsně před rotací ho ještě potřebují.
|
||||
Future<SepotSignedPreKey> generateSignedPreKey({
|
||||
required int id,
|
||||
required int timestampMs,
|
||||
}) async {
|
||||
final identityPrivate = PrivateKey.deserialize(
|
||||
bytes: _identity.privateKey.toList(),
|
||||
);
|
||||
final private = PrivateKey.generate();
|
||||
final public = private.getPublicKey();
|
||||
final signature = identityPrivate.sign(
|
||||
message: public.serialize().toList(),
|
||||
);
|
||||
await _stores.signedPreKey.storeSignedPreKey(
|
||||
id,
|
||||
SignedPreKeyRecord(
|
||||
id: id,
|
||||
publicKey: public,
|
||||
privateKey: private,
|
||||
signature: signature.toList(),
|
||||
timestamp: BigInt.from(timestampMs),
|
||||
),
|
||||
);
|
||||
return SepotSignedPreKey(
|
||||
id: id,
|
||||
publicKey: Uint8List.fromList(public.serialize().toList()),
|
||||
signature: Uint8List.fromList(signature.toList()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Nový last-resort Kyber prekey (rotace PQXDH kotvy).
|
||||
Future<SepotKyberPreKey> generateKyberPreKey({
|
||||
required int id,
|
||||
required int timestampMs,
|
||||
}) async {
|
||||
final identityPrivate = PrivateKey.deserialize(
|
||||
bytes: _identity.privateKey.toList(),
|
||||
);
|
||||
final keyPair = KyberKeyPair.generate();
|
||||
final public = keyPair.getPublicKey();
|
||||
final signature = identityPrivate.sign(
|
||||
message: public.serialize().toList(),
|
||||
);
|
||||
await _stores.kyberPreKey.storeKyberPreKey(
|
||||
id,
|
||||
KyberPreKeyRecord.create(
|
||||
id: id,
|
||||
keyPair: keyPair,
|
||||
signature: signature.toList(),
|
||||
timestamp: BigInt.from(timestampMs),
|
||||
),
|
||||
);
|
||||
return SepotKyberPreKey(
|
||||
id: id,
|
||||
publicKey: Uint8List.fromList(public.serialize().toList()),
|
||||
signature: Uint8List.fromList(signature.toList()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,30 @@ class AttachmentTooLargeError extends SepotUserError {
|
||||
String toString() => 'attachment too large (max $maxBytes bytes)';
|
||||
}
|
||||
|
||||
/// Příloha pro přeposlání už v telefonu není (smazaná, mizící, nestažená).
|
||||
class AttachmentUnavailableError extends SepotUserError {
|
||||
const AttachmentUnavailableError();
|
||||
|
||||
@override
|
||||
String toString() => 'attachment not available locally';
|
||||
}
|
||||
|
||||
/// Změnu skupiny smí udělat jen správce.
|
||||
class NotGroupAdminError extends SepotUserError {
|
||||
const NotGroupAdminError();
|
||||
|
||||
@override
|
||||
String toString() => 'only a group admin can do this';
|
||||
}
|
||||
|
||||
/// Poslední správce se nesmí odebrat ani zbavit role.
|
||||
class LastGroupAdminError extends SepotUserError {
|
||||
const LastGroupAdminError();
|
||||
|
||||
@override
|
||||
String toString() => 'the last group admin cannot step down';
|
||||
}
|
||||
|
||||
/// Skupina bez jediného dalšího člena nedává smysl.
|
||||
class GroupNeedsMemberError extends SepotUserError {
|
||||
const GroupNeedsMemberError();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
|
||||
/// Jméno kontaktu pro UI. U žádosti o zprávu s viditelnou značkou, že si
|
||||
/// jméno zvolil odesílatel sám: „Máma (neznámý)“ se nesmí na pohled
|
||||
/// shodovat s přijatou „Mámou“ v seznamu, hlavičce ani ve skupině.
|
||||
String contactName(AppLocalizations l10n, Contact contact) {
|
||||
if (contact.displayName.isEmpty) return l10n.commonUnknownContact;
|
||||
return contact.accepted
|
||||
? contact.displayName
|
||||
: l10n.contactRequestName(contact.displayName);
|
||||
}
|
||||
@@ -15,7 +15,10 @@ String describeError(AppLocalizations l10n, Object error) => switch (error) {
|
||||
ContactLinkIncompleteError() => l10n.errorContactLinkIncomplete,
|
||||
AttachmentTooLargeError(:final maxMb) =>
|
||||
l10n.errorAttachmentTooLarge(maxMb),
|
||||
AttachmentUnavailableError() => l10n.errorAttachmentUnavailable,
|
||||
GroupNeedsMemberError() => l10n.errorGroupNeedsMember,
|
||||
NotGroupAdminError() => l10n.errorGroupNotAdmin,
|
||||
LastGroupAdminError() => l10n.errorGroupLastAdmin,
|
||||
GroupTooLargeError(:final maxMembers) =>
|
||||
l10n.errorGroupMaxMembers(maxMembers),
|
||||
MemberNoDeliveryAddressError(:final displayName) =>
|
||||
|
||||
@@ -63,9 +63,18 @@ class RelayClient {
|
||||
StreamController<RelayConnectionState>.broadcast();
|
||||
final _deliverController = StreamController<pb.Envelope>.broadcast();
|
||||
final _receiptController = StreamController<pb.Receipt>.broadcast();
|
||||
final _turnController = StreamController<pb.TurnCredentials>.broadcast();
|
||||
final _prekeysLowController = StreamController<int>.broadcast();
|
||||
|
||||
var _state = RelayConnectionState.disconnected;
|
||||
|
||||
/// Kdy naposled přišel aplikační rámec od relaye.
|
||||
DateTime _lastFrameAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
static const _staleAfter = Duration(seconds: 20);
|
||||
|
||||
/// Relay hlásí docházející one-time prekeys (zbývající počet).
|
||||
Stream<int> get prekeysLow => _prekeysLowController.stream;
|
||||
|
||||
RelayConnectionState get state => _state;
|
||||
Stream<RelayConnectionState> get stateStream => _stateController.stream;
|
||||
|
||||
@@ -99,9 +108,18 @@ class RelayClient {
|
||||
required List<SepotOneTimePreKey> oneTimePreKeys,
|
||||
required Uint8List deliveryAddress,
|
||||
}) async {
|
||||
// Důkaz držení identity klíče: obnova ze 24 slov na novém telefonu
|
||||
// narazí na relayi na už existující identitu (409) a bez tohohle
|
||||
// podpisu by ji nikdy nepřevzala. Starší relay hlavičku
|
||||
// ignoruje.
|
||||
final proofTs = DateTime.now().millisecondsSinceEpoch;
|
||||
final proof = _crypto.sign(utf8.encode('POST /v1/register\n$proofTs'));
|
||||
final resp = await _transport.httpClient().post(
|
||||
Uri.parse('$baseUrl/v1/register'),
|
||||
headers: {'content-type': 'application/json'},
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-sepot-identity-proof': '$proofTs.${base64Encode(proof)}',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'identity_key': base64Encode(_crypto.identityPublicKey),
|
||||
'device_id': 1,
|
||||
@@ -143,6 +161,49 @@ class RelayClient {
|
||||
_crypto.bindAccount(accountId);
|
||||
}
|
||||
|
||||
/// POST /v1/keys — doplnění one-time prekeys a rotace signed / Kyber
|
||||
/// prekey. Vrací počet OTP na relayi po nahrání.
|
||||
Future<int> uploadKeys({
|
||||
SepotSignedPreKey? signedPreKey,
|
||||
SepotKyberPreKey? kyberPreKey,
|
||||
List<SepotOneTimePreKey> oneTimePreKeys = const [],
|
||||
}) async {
|
||||
final deviceUuid = _requireDevice();
|
||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||
final signature = _crypto.sign(utf8.encode('POST /v1/keys\n$ts'));
|
||||
final resp = await _transport.httpClient().post(
|
||||
Uri.parse('$baseUrl/v1/keys'),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-sepot-auth': '$deviceUuid.$ts.${base64Encode(signature)}',
|
||||
},
|
||||
body: jsonEncode({
|
||||
if (signedPreKey != null)
|
||||
'signed_prekey': {
|
||||
'key_id': signedPreKey.id,
|
||||
'public_key': base64Encode(signedPreKey.publicKey),
|
||||
'signature': base64Encode(signedPreKey.signature),
|
||||
},
|
||||
if (kyberPreKey != null)
|
||||
'kyber_prekey': {
|
||||
'key_id': kyberPreKey.id,
|
||||
'public_key': base64Encode(kyberPreKey.publicKey),
|
||||
'signature': base64Encode(kyberPreKey.signature),
|
||||
'is_last_resort': true,
|
||||
},
|
||||
'one_time_prekeys': [
|
||||
for (final otp in oneTimePreKeys)
|
||||
{'key_id': otp.id, 'public_key': base64Encode(otp.publicKey)},
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (resp.statusCode != 200) {
|
||||
throw RelayException('prekey upload failed', resp.statusCode, resp.body);
|
||||
}
|
||||
final body = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
return (body['one_time_prekeys'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
|
||||
/// GET /v1/keys/{addr} — bundle protistrany (spotřebuje 1 OTP).
|
||||
Future<SepotPreKeyBundle> fetchBundle(Uint8List deliveryAddress) async {
|
||||
final hexAddr = deliveryAddress
|
||||
@@ -279,6 +340,20 @@ class RelayClient {
|
||||
_setState(RelayConnectionState.disconnected);
|
||||
}
|
||||
|
||||
/// Budíček od relaye znamená, že nás nevidí jako živé. Spojení, které
|
||||
/// hlásí „připojeno", ale [_staleAfter] nic nepřijalo, je po změně sítě
|
||||
/// nejspíš zombie — zavřít a navázat hned, ne až po ping timeoutu
|
||||
/// (pojistka proti zombie spojení na straně appky). Jinak jen [start].
|
||||
void kick() {
|
||||
if (_state == RelayConnectionState.connected &&
|
||||
DateTime.now().difference(_lastFrameAt) > _staleAfter) {
|
||||
_reconnectDelayS = 1;
|
||||
_onDisconnected();
|
||||
return;
|
||||
}
|
||||
start();
|
||||
}
|
||||
|
||||
Future<void> _teardownChannel() async {
|
||||
await _wsSub?.cancel();
|
||||
_wsSub = null;
|
||||
@@ -312,6 +387,7 @@ class RelayClient {
|
||||
_wsSub = channel.stream.listen(
|
||||
(data) {
|
||||
final frame = pb.ServerFrame.fromBuffer(data as List<int>);
|
||||
_lastFrameAt = DateTime.now();
|
||||
if (!authenticated) {
|
||||
if (frame.hasChallenge()) {
|
||||
final signature = _crypto.sign([
|
||||
@@ -340,6 +416,14 @@ class RelayClient {
|
||||
if (!_receiptController.isClosed) {
|
||||
_receiptController.add(frame.receipt);
|
||||
}
|
||||
} else if (frame.hasTurnCredentials()) {
|
||||
if (!_turnController.isClosed) {
|
||||
_turnController.add(frame.turnCredentials);
|
||||
}
|
||||
} else if (frame.hasPrekeysLow()) {
|
||||
if (!_prekeysLowController.isClosed) {
|
||||
_prekeysLowController.add(frame.prekeysLow.remaining);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: _onDisconnected,
|
||||
@@ -373,27 +457,46 @@ class RelayClient {
|
||||
}
|
||||
|
||||
/// Odešle sealed-sender obálku. Vrací Receipt (STORED_OFFLINE /
|
||||
/// DELIVERED_ONLINE) spárovaný přes msg_uuid.
|
||||
/// DELIVERED_ONLINE, u EPHEMERAL i DROPPED) spárovaný přes msg_uuid.
|
||||
Future<pb.Receipt> sendEnvelope({
|
||||
required Uint8List dstAddress,
|
||||
required Uint8List payload,
|
||||
pb.ContentHint hint = pb.ContentHint.CONTENT_HINT_DEFAULT,
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) async {
|
||||
final msgUuid = randomBytes(16);
|
||||
final receiptFuture = _receiptController.stream
|
||||
.firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid))
|
||||
.timeout(timeout);
|
||||
_send(pb.ClientFrame(
|
||||
envelope: pb.Envelope(
|
||||
dstAddress: dstAddress,
|
||||
msgUuid: msgUuid,
|
||||
contentHint: pb.ContentHint.CONTENT_HINT_DEFAULT,
|
||||
payload: payload,
|
||||
),
|
||||
));
|
||||
try {
|
||||
_send(pb.ClientFrame(
|
||||
envelope: pb.Envelope(
|
||||
dstAddress: dstAddress,
|
||||
msgUuid: msgUuid,
|
||||
contentHint: hint,
|
||||
payload: payload,
|
||||
),
|
||||
));
|
||||
} catch (_) {
|
||||
// Odeslání selhalo (kanál zavřený) — čekající future by za 10 s
|
||||
// hodil neodchycený TimeoutException do zvonku chyb.
|
||||
receiptFuture.ignore();
|
||||
rethrow;
|
||||
}
|
||||
return receiptFuture;
|
||||
}
|
||||
|
||||
/// Dočasné údaje k TURN serveru pro hovor. Relay je vydá jen přihlášenému
|
||||
/// zařízení; bez TURN na relayi (503) doběhne timeout a hovor zkusí jen
|
||||
/// přímé spojení přes STUN.
|
||||
Future<pb.TurnCredentials> requestTurn({
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) {
|
||||
final future = _turnController.stream.first.timeout(timeout);
|
||||
_send(pb.ClientFrame(turnRequest: pb.TurnRequest()));
|
||||
return future;
|
||||
}
|
||||
|
||||
/// Potvrdí zpracování obálky — relay ji smaže z fronty. Volat až PO
|
||||
/// uložení zprávy na disk.
|
||||
void ack(List<int> msgUuid) {
|
||||
@@ -437,6 +540,8 @@ class RelayClient {
|
||||
await stop();
|
||||
_transport.close();
|
||||
await _stateController.close();
|
||||
await _turnController.close();
|
||||
await _prekeysLowController.close();
|
||||
await _deliverController.close();
|
||||
await _receiptController.close();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'dart:io' show Platform;
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:sepot/core/calls/call_ringer.dart';
|
||||
import 'package:sepot/core/push/apns_ios.dart';
|
||||
import 'package:sepot/core/push/push_notifier.dart';
|
||||
import 'package:sepot/core/push/push_service.dart';
|
||||
@@ -28,8 +29,14 @@ import 'package:unifiedpush/unifiedpush.dart';
|
||||
/// FCM budíček se zabitou appkou — plugin spouští handler ve vlastním
|
||||
/// background isolate. Stejný model jako UnifiedPush killed-state:
|
||||
/// DB je zamčená, nic nejde dešifrovat → jen generická notifikace.
|
||||
/// Budíček hovoru (`k=call`) místo ní rovnou zazvoní; kdo
|
||||
/// volá, appka zjistí až po odemčení z nabídky ve frontě.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> sepotFcmBackgroundWake(RemoteMessage message) async {
|
||||
if (message.data['k'] == 'call') {
|
||||
await CallRinger.ringFromPush();
|
||||
return;
|
||||
}
|
||||
await Firebase.initializeApp();
|
||||
await PushNotifier.showWakeNotification();
|
||||
}
|
||||
@@ -52,8 +59,11 @@ class PushBootstrap {
|
||||
|
||||
/// Režim pushů žije v OS keystore (NE v SQLCipher DB) — musí být
|
||||
/// čitelný před unlockem, aby main() věděl, zda registrovat FCM.
|
||||
static Future<String> readMode() async =>
|
||||
await _store.read(key: _modeKey) ?? 'off';
|
||||
static Future<String> readMode() async => await readStoredMode() ?? 'off';
|
||||
|
||||
/// Uložený režim, nebo null, když ho uživatel ani appka ještě nevybrali
|
||||
/// — to odlišuje čerstvou instalaci od vědomého „vypnuto".
|
||||
static Future<String?> readStoredMode() => _store.read(key: _modeKey);
|
||||
|
||||
static Future<void> writeMode(String mode) =>
|
||||
_store.write(key: _modeKey, value: mode);
|
||||
@@ -64,7 +74,7 @@ class PushBootstrap {
|
||||
static Future<bool> registrationFailed() async =>
|
||||
await _store.read(key: _registrationFailedKey) == '1';
|
||||
|
||||
static Future<void> _setRegistrationFailed(bool failed) => failed
|
||||
static Future<void> setRegistrationFailed(bool failed) => failed
|
||||
? _store.write(key: _registrationFailedKey, value: '1')
|
||||
: _store.delete(key: _registrationFailedKey);
|
||||
|
||||
@@ -77,11 +87,13 @@ class PushBootstrap {
|
||||
if (Platform.isIOS) {
|
||||
ApnsBridge.onWake = _wake;
|
||||
ApnsBridge.ensureHandler();
|
||||
// Zvonění z VoIP pushe bez nabídky nesmí zvonit celých 45 s.
|
||||
unawaited(CallRinger.expireStalePushRings());
|
||||
} else {
|
||||
await UnifiedPush.initialize(
|
||||
onNewEndpoint: _onNewEndpoint,
|
||||
onRegistrationFailed: (_, _) =>
|
||||
unawaited(_setRegistrationFailed(true)),
|
||||
unawaited(setRegistrationFailed(true)),
|
||||
onUnregistered: _onUnregistered,
|
||||
onMessage: _onMessage,
|
||||
);
|
||||
@@ -126,7 +138,7 @@ class PushBootstrap {
|
||||
}
|
||||
|
||||
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
|
||||
unawaited(_setRegistrationFailed(false)); // registrace se povedla
|
||||
unawaited(setRegistrationFailed(false)); // registrace se povedla
|
||||
final s = _service;
|
||||
if (s != null) {
|
||||
s.handleNewEndpoint(endpoint.url);
|
||||
|
||||
@@ -27,6 +27,14 @@ class PushNotifier {
|
||||
await _plugin.initialize(
|
||||
InitializationSettings(
|
||||
android: const AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
// Bez iOS nastavení plugin hodí ArgumentError a s ním padala celá
|
||||
// APNs registrace — iPhony relayi neposlaly token vůbec. O oprávnění
|
||||
// se žádá až v [requestPermission], ne při první notifikaci.
|
||||
iOS: const DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
),
|
||||
linux: LinuxInitializationSettings(
|
||||
defaultActionName: l10n.pushActionOpen,
|
||||
),
|
||||
@@ -48,17 +56,23 @@ class PushNotifier {
|
||||
priority: Priority.high,
|
||||
category: AndroidNotificationCategory.message,
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(),
|
||||
linux: const LinuxNotificationDetails(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Android 13+ runtime permission — volat při zapnutí pushe v nastavení.
|
||||
/// Oprávnění k viditelným notifikacím (Android 13+, iOS) — volat při
|
||||
/// zapnutí pushe. Tichý budíček na iOS ho nepotřebuje.
|
||||
static Future<void> requestPermission() async {
|
||||
await _ensureInit();
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true);
|
||||
}
|
||||
|
||||
/// Notifikace o přijaté zprávě, když appka běží, ale není v popředí.
|
||||
@@ -75,6 +89,19 @@ class PushNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
/// Zpráva od neznámého odesílatele (žádost o zprávu): bez jména, které
|
||||
/// si zvolil sám.
|
||||
static Future<void> showMessageRequestNotification() async {
|
||||
await _ensureInit();
|
||||
final l10n = l10nFor(await LocaleNotifier.readStored());
|
||||
await _plugin.show(
|
||||
_notificationId,
|
||||
l10n.commonAppName,
|
||||
l10n.pushMessageRequest,
|
||||
_details(l10n),
|
||||
);
|
||||
}
|
||||
|
||||
/// Generická notifikace bez obsahu. Klepnutí otevře appku (unlock →
|
||||
/// WS připojení → fronta se vyleje normální cestou).
|
||||
static Future<void> showWakeNotification() async {
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
/// killed-state startu `--unifiedpush-bg` bez UI); tahle třída je
|
||||
/// most bootstrap ↔ messaging + API pro nastavení.
|
||||
///
|
||||
/// FCM „fast mode" (opt-in) je druhá cesta (viz [setModeFcm]); default je
|
||||
/// degooglovaný UnifiedPush.
|
||||
/// Výchozí režim na Androidu vybírá [restoreMode] při prvním startu: s
|
||||
/// UnifiedPush distributorem UnifiedPush, jinak FCM. Bez budíčku Android
|
||||
/// appku na pozadí uspí a zprávy čekají, než ji člověk otevře — to je pro
|
||||
/// messenger horší než token u Googlu (obsah ani odesílatel budíčkem nejde).
|
||||
/// Uživatel může kdykoli přepnout v Nastavení.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
@@ -24,6 +27,8 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
|
||||
import 'package:sepot/core/calls/call_ringer.dart';
|
||||
import 'package:sepot/core/push/apns_ios.dart';
|
||||
import 'package:sepot/core/push/push_bootstrap.dart';
|
||||
import 'package:sepot/core/push/push_notifier.dart';
|
||||
@@ -31,10 +36,27 @@ import 'package:sepot/core/services/messaging_service.dart';
|
||||
import 'package:sepot/proto/sepot.pb.dart' as pb;
|
||||
import 'package:unifiedpush/unifiedpush.dart';
|
||||
|
||||
/// Nativní strana iOS pushů. Oddělená od [PushService], aby šlo pořadí
|
||||
/// kroků a skládání registrace ověřit testem bez iPhonu.
|
||||
class IosPushPlatform {
|
||||
const IosPushPlatform();
|
||||
|
||||
bool get isIOS => Platform.isIOS;
|
||||
Future<void> registerApns() => ApnsBridge.register();
|
||||
Future<String?> apnsToken() => ApnsBridge.currentToken();
|
||||
Future<String?> voipToken() => CallRinger.voipToken();
|
||||
Future<void> requestPermission() => PushNotifier.requestPermission();
|
||||
Future<void> writeMode(String mode) => PushBootstrap.writeMode(mode);
|
||||
Future<void> setRegistrationFailed(bool failed) =>
|
||||
PushBootstrap.setRegistrationFailed(failed);
|
||||
}
|
||||
|
||||
class PushService {
|
||||
PushService(this._messaging);
|
||||
PushService(this._messaging, {IosPushPlatform ios = const IosPushPlatform()})
|
||||
: _ios = ios;
|
||||
|
||||
final MessagingService _messaging;
|
||||
final IosPushPlatform _ios;
|
||||
StreamSubscription<String>? _fcmTokenSub;
|
||||
StreamSubscription<RemoteMessage>? _fcmMessageSub;
|
||||
|
||||
@@ -48,7 +70,15 @@ class PushService {
|
||||
/// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.)
|
||||
bool get canWake => _messaging.isStarted;
|
||||
|
||||
void wake() => _messaging.onPushWake();
|
||||
/// Budíček: běžící messaging připojí WS; po auto-locku (zamčená DB) aspoň
|
||||
/// generická notifikace bez obsahu, ať se uživatel o zprávě dozví.
|
||||
void wake() {
|
||||
if (canWake) {
|
||||
_messaging.onPushWake();
|
||||
} else {
|
||||
unawaited(PushNotifier.showWakeNotification());
|
||||
}
|
||||
}
|
||||
|
||||
void handleNewEndpoint(String url) {
|
||||
_messaging.registerPush(_registration(
|
||||
@@ -80,22 +110,68 @@ class PushService {
|
||||
}
|
||||
}
|
||||
|
||||
void _registerApnsToken(String token) {
|
||||
_messaging.registerPush(_registration(
|
||||
pb.RegisterPush_Platform.PLATFORM_APNS,
|
||||
Uint8List.fromList(utf8.encode(token)),
|
||||
));
|
||||
String? _apnsToken;
|
||||
|
||||
/// Registrace iOS pushů pro relay: APNs token pro zprávy + VoIP token pro
|
||||
/// hovory (relay drží obojí u zařízení). VoIP token jde i sám, s prázdným
|
||||
/// endpointem — PushKit ho vydá nezávisle na APNs a hovory nesmí čekat,
|
||||
/// až Apple vydá i token pro zprávy. Null = zatím není co poslat.
|
||||
@visibleForTesting
|
||||
static pb.RegisterPush? apnsRegistration({
|
||||
String? apnsToken,
|
||||
String? voipToken,
|
||||
}) {
|
||||
if (apnsToken == null && voipToken == null) return null;
|
||||
final reg = pb.RegisterPush(
|
||||
platform: pb.RegisterPush_Platform.PLATFORM_APNS,
|
||||
endpoint: apnsToken == null
|
||||
? Uint8List(0)
|
||||
: Uint8List.fromList(utf8.encode(apnsToken)),
|
||||
);
|
||||
if (voipToken != null) reg.voipToken = utf8.encode(voipToken);
|
||||
return reg;
|
||||
}
|
||||
|
||||
Future<void> _sendApnsRegistration() async {
|
||||
final reg = apnsRegistration(
|
||||
apnsToken: _apnsToken,
|
||||
voipToken: await _ios.voipToken(),
|
||||
);
|
||||
if (reg != null) _messaging.registerPush(reg);
|
||||
}
|
||||
|
||||
Future<void> _registerApnsToken(String token) async {
|
||||
_apnsToken = token;
|
||||
await _sendApnsRegistration();
|
||||
}
|
||||
|
||||
/// PushKit vydal (nový) VoIP token — poslat ho relayi, i když APNs token
|
||||
/// pro zprávy ještě nedorazil.
|
||||
Future<void> refreshVoipToken() => _sendApnsRegistration();
|
||||
|
||||
/// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
|
||||
/// asynchronně a sám se pošle relayi. Bez Googlu.
|
||||
///
|
||||
/// Registrace u Apple jde PŘED žádostí o oprávnění: tichý budíček
|
||||
/// oprávnění nepotřebuje, takže odmítnutý (nebo selhaný) dialog nesmí
|
||||
/// zastavit doručování zpráv.
|
||||
Future<void> enableApns() async {
|
||||
await init();
|
||||
await PushNotifier.requestPermission();
|
||||
await ApnsBridge.register();
|
||||
final existing = await ApnsBridge.currentToken();
|
||||
if (existing != null) _registerApnsToken(existing);
|
||||
await PushBootstrap.writeMode('apns');
|
||||
await _ios.registerApns();
|
||||
await _ios.writeMode('apns');
|
||||
final existing = await _ios.apnsToken();
|
||||
if (existing != null) {
|
||||
await _registerApnsToken(existing);
|
||||
} else {
|
||||
// VoIP token mohl přijít dřív než APNs — hovory ať zvoní hned.
|
||||
await _sendApnsRegistration();
|
||||
}
|
||||
try {
|
||||
await _ios.requestPermission();
|
||||
} catch (e, s) {
|
||||
// Bez oprávnění chodí budíčky dál, jen bez viditelné notifikace.
|
||||
debugPrint('Oprávnění k notifikacím selhalo: $e\n$s');
|
||||
}
|
||||
}
|
||||
|
||||
/// Zapne push: použije uloženého/výchozího distributora a zaregistruje se.
|
||||
@@ -112,10 +188,14 @@ class PushService {
|
||||
}
|
||||
|
||||
/// Seznam nainstalovaných distributorů (pro výběr v nastavení).
|
||||
Future<List<String>> distributors() => UnifiedPush.getDistributors();
|
||||
/// UnifiedPush existuje jen na Androidu; jinde by volání pluginu vyhodilo
|
||||
/// UnimplementedError (iOS: dlaždice Push v Nastavení).
|
||||
Future<List<String>> distributors() async =>
|
||||
Platform.isAndroid ? UnifiedPush.getDistributors() : const [];
|
||||
|
||||
/// Aktuálně uložený distributor (null = push vypnutý / nevybraný).
|
||||
Future<String?> currentDistributor() => UnifiedPush.getDistributor();
|
||||
Future<String?> currentDistributor() async =>
|
||||
Platform.isAndroid ? UnifiedPush.getDistributor() : null;
|
||||
|
||||
/// Vybere konkrétního distributora a zaregistruje se u něj.
|
||||
Future<void> useDistributor(String distributor) async {
|
||||
@@ -132,7 +212,7 @@ class PushService {
|
||||
}
|
||||
|
||||
Future<bool> get isEnabled async =>
|
||||
(await UnifiedPush.getDistributor()) != null;
|
||||
Platform.isAndroid && (await UnifiedPush.getDistributor()) != null;
|
||||
|
||||
// ── Režimy (off / up / fcm) ─────────────────────────────────────────
|
||||
|
||||
@@ -141,6 +221,7 @@ class PushService {
|
||||
Future<String> currentMode() async {
|
||||
final mode = await PushBootstrap.readMode();
|
||||
if (mode != 'off') return mode;
|
||||
if (!Platform.isAndroid) return mode;
|
||||
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
|
||||
}
|
||||
|
||||
@@ -166,8 +247,8 @@ class PushService {
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// FCM fast mode — OPT-IN: registrační token zařízení jde Googlu
|
||||
/// (obsah budíčků nikdy, ťuk je konstantní `{"v":1}`).
|
||||
/// FCM: registrační token zařízení jde Googlu (obsah budíčků nikdy,
|
||||
/// ťuk je konstantní `{"v":1}`).
|
||||
Future<void> setModeFcm() async {
|
||||
await init();
|
||||
try {
|
||||
@@ -214,14 +295,23 @@ class PushService {
|
||||
Future<void> restoreMode() async {
|
||||
// iOS: jediná smysluplná cesta je APNs (UnifiedPush je Android-only).
|
||||
// Zapínáme ho automaticky — pro iOS messenger jsou notifikace základ.
|
||||
if (Platform.isIOS) {
|
||||
if (_ios.isIOS) {
|
||||
try {
|
||||
await enableApns();
|
||||
} catch (_) {
|
||||
await _ios.setRegistrationFailed(false);
|
||||
} catch (e, s) {
|
||||
// Dřív se chyba spolkla a iPhone tiše zůstal bez budíčků. Teď ji
|
||||
// uvidí stav pushů v chat listu (PushHealth.failed).
|
||||
debugPrint('APNs registrace selhala: $e\n$s');
|
||||
await _ios.setRegistrationFailed(true);
|
||||
await init();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (await PushBootstrap.readStoredMode() == null) {
|
||||
await _chooseDefaultMode();
|
||||
return;
|
||||
}
|
||||
switch (await currentMode()) {
|
||||
case 'up':
|
||||
await enable();
|
||||
@@ -235,4 +325,23 @@ class PushService {
|
||||
await init(); // aspoň attach (killed-state notifikace)
|
||||
}
|
||||
}
|
||||
|
||||
/// První start na Androidu (nebo instalace z doby, kdy byl push
|
||||
/// opt-in a nikdo ho nezapnul). Kdo má nainstalovaný UnifiedPush
|
||||
/// distributor, vybral si degooglovanou cestu sám — dostane ji.
|
||||
/// Ostatním zapneme FCM. Telefon bez Google služeb token nedostane;
|
||||
/// pak zapíšeme „off", ať se to nezkouší při každém startu — seznam
|
||||
/// chatů na chybějící budíčky upozorní sám.
|
||||
Future<void> _chooseDefaultMode() async {
|
||||
try {
|
||||
if ((await currentMode()) == 'up' ||
|
||||
(await distributors()).isNotEmpty) {
|
||||
if (await setModeUnifiedPush()) return;
|
||||
}
|
||||
await setModeFcm();
|
||||
} catch (_) {
|
||||
await init();
|
||||
await PushBootstrap.writeMode('off');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/// Rozpoznání jmen, která se pletou s existujícím kontaktem.
|
||||
///
|
||||
/// Jméno v Šepotu si každý volí sám, takže „Máma“ od cizího klíče je
|
||||
/// nejlevnější phishing. Porovnává se „kostra“ jména: bez velikosti
|
||||
/// písmen, diakritiky, mezer, interpunkce a neviditelných znaků, s cyrilicí
|
||||
/// a řečtinou převedenou na latinku. „MAMA“, „Мáма“ (cyrilské М) i
|
||||
/// „Máma“ s neviditelnou mezerou uprostřed tak padnou na totéž jako „Máma“.
|
||||
library;
|
||||
|
||||
import 'package:sepot/core/utils/text_fold.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
|
||||
// Znaky, které na displeji vypadají jako latinka. Jen ty, co se v malých
|
||||
// písmenech opravdu pletou; ne celý Unicode confusables seznam — falešná
|
||||
// shoda tu nic nerozbije, jen přidá varování navíc.
|
||||
const _confusables = {
|
||||
'а': 'a', 'в': 'b', 'е': 'e', 'ё': 'e', 'к': 'k', 'м': 'm', 'н': 'h',
|
||||
'о': 'o', 'р': 'p', 'с': 'c', 'т': 't', 'у': 'y', 'х': 'x', 'і': 'l',
|
||||
'ї': 'l', 'ј': 'j', 'ѕ': 's', 'ԁ': 'd', 'ɡ': 'g', 'α': 'a', 'β': 'b',
|
||||
'ε': 'e', 'ι': 'l', 'κ': 'k', 'ν': 'v', 'ο': 'o', 'ρ': 'p', 'τ': 't',
|
||||
'υ': 'u', 'χ': 'x', 'ү': 'y', '0': 'o', '1': 'l', 'i': 'l', '|': 'l',
|
||||
// Latinská diakritika mimo češtinu a slovenštinu (tu skládá už fold).
|
||||
'à': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'ā': 'a', 'ą': 'a', 'ç': 'c',
|
||||
'ć': 'c', 'è': 'e', 'ê': 'e', 'ē': 'e', 'ę': 'e', 'ì': 'l', 'î': 'l',
|
||||
'ï': 'l', 'ł': 'l', 'ñ': 'n', 'ń': 'n', 'ò': 'o', 'õ': 'o', 'ø': 'o',
|
||||
'ő': 'o', 'ś': 's', 'ù': 'u', 'û': 'u', 'ű': 'u', 'ÿ': 'y', 'ź': 'z',
|
||||
'ż': 'z',
|
||||
};
|
||||
|
||||
final _invisible = RegExp(
|
||||
'[\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180E'
|
||||
'\u200B-\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0F\uFEFF]');
|
||||
final _combining = RegExp('[\u0300-\u036F]');
|
||||
final _separators = RegExp(r'[\s\p{P}\p{S}]', unicode: true);
|
||||
|
||||
/// Kostra jména pro porovnání. Prázdný výsledek = jméno nic nenese.
|
||||
String nameSkeleton(String name) {
|
||||
final folded = fold(name
|
||||
.replaceAll(_invisible, '')
|
||||
.replaceAll(_combining, '')
|
||||
.replaceAll(_separators, ''));
|
||||
final out = StringBuffer();
|
||||
for (final ch in folded.split('')) {
|
||||
out.write(_confusables[ch] ?? ch);
|
||||
}
|
||||
// „rn“ se v bezpatkovém písmu čte jako „m“.
|
||||
return out.toString().replaceAll('rn', 'm');
|
||||
}
|
||||
|
||||
/// Přijatý kontakt, se kterým se [name] plete (null = nic podobného).
|
||||
/// [exceptId] = sám odesílatel, ten se se sebou neporovnává.
|
||||
Contact? lookalikeContact(
|
||||
String name,
|
||||
Iterable<Contact> contacts, {
|
||||
String? exceptId,
|
||||
}) {
|
||||
final skeleton = nameSkeleton(name);
|
||||
if (skeleton.isEmpty) return null;
|
||||
for (final c in contacts) {
|
||||
if (!c.accepted || c.id == exceptId) continue;
|
||||
if (nameSkeleton(c.displayName) == skeleton) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:sepot/core/app_info.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/errors.dart';
|
||||
|
||||
@@ -67,12 +68,16 @@ class ReportService {
|
||||
throw const ReportEmptyDescriptionError();
|
||||
}
|
||||
|
||||
// S build číslem, ne jen „1.1.2" — jinak nejde poznat, ze kterého buildu
|
||||
// hlášení přišlo. Neznámou verzi radši vynechat než poslat vymyšlenou.
|
||||
final verze = AppInfo.versionWithBuild;
|
||||
|
||||
final body = <String, dynamic>{
|
||||
'kategorie': category.wire,
|
||||
'popis': popis,
|
||||
'zdroj': 'app',
|
||||
'platforma': Platform.operatingSystem,
|
||||
'verze': AppConstants.appVersion,
|
||||
'verze': ?verze,
|
||||
if (reportedFingerprint != null && reportedFingerprint.isNotEmpty)
|
||||
'nahlasenyOtisk': reportedFingerprint,
|
||||
if (replyTo != null && replyTo.trim().isNotEmpty)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'dart:isolate';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:sepot/core/services/image_processor.dart';
|
||||
|
||||
/// Strana profilové fotky v pixelech — víc se v appce nikde nezobrazí.
|
||||
const avatarSide = 256;
|
||||
|
||||
/// Vybranou fotku otočí podle EXIF, ořízne na středový čtverec, zmenší
|
||||
/// a znovu zakóduje do JPEG bez metadat (EXIF, GPS, model telefonu).
|
||||
/// Null = obrázek nejde přečíst.
|
||||
Future<Uint8List?> prepareAvatarJpeg(Uint8List input) => Isolate.run(() {
|
||||
final img.Image? decoded;
|
||||
try {
|
||||
decoded = img.decodeImage(input);
|
||||
} on Object {
|
||||
// Dekodéry na poškozených datech háží místo null.
|
||||
return null;
|
||||
}
|
||||
if (decoded == null) return null;
|
||||
final upright = img.bakeOrientation(decoded);
|
||||
final side = math.min(upright.width, upright.height);
|
||||
final square = img.copyCrop(
|
||||
upright,
|
||||
x: (upright.width - side) ~/ 2,
|
||||
y: (upright.height - side) ~/ 2,
|
||||
width: side,
|
||||
height: side,
|
||||
);
|
||||
final small = img.copyResize(square,
|
||||
width: avatarSide,
|
||||
height: avatarSide,
|
||||
interpolation: img.Interpolation.average);
|
||||
ImageProcessor.stripMetadata(small);
|
||||
return Uint8List.fromList(img.encodeJpg(small, quality: 85));
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/// Pravidla správců skupin oddělená od sítě a
|
||||
/// úložiště: drátová podoba `gop`, ověření příchozí operace v pořadí 1–6
|
||||
/// ze specifikace, tvorba odchozí operace s invarianty a podržení operací
|
||||
/// z budoucích epoch.
|
||||
///
|
||||
/// Ověření podpisu se předává jako funkce — produkce volá libsignal
|
||||
/// (`GroupAdminOp.verify`), testy pravidel tak běží i bez nativní knihovny.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:sepot/core/crypto/group_admin.dart';
|
||||
import 'package:sepot/core/errors.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
|
||||
/// Ověří podpis [sig] operace [op] identity klíčem [identityKey].
|
||||
typedef GroupOpSignatureCheck = bool Function(
|
||||
GroupAdminOp op, Uint8List sig, Uint8List identityKey);
|
||||
|
||||
bool _libsignalVerify(GroupAdminOp op, Uint8List sig, Uint8List ik) {
|
||||
try {
|
||||
return op.verify(sig, ik);
|
||||
} catch (_) {
|
||||
// Nedeserializovatelný klíč/podpis z drátu = neplatný podpis, ne pád
|
||||
// příjmové cesty.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Výsledek ověření příchozí operace.
|
||||
enum GopVerdict {
|
||||
/// Platná a navazuje — aplikovat.
|
||||
apply,
|
||||
|
||||
/// `by` nesedí se sealed-sender autorem (krok 1).
|
||||
spoof,
|
||||
|
||||
/// Porušené invarianty nebo cizí skupina — nikdy nepůjde aplikovat.
|
||||
invalid,
|
||||
|
||||
/// Podepsal někdo, kdo u mě není správce (krok 2).
|
||||
notAdmin,
|
||||
|
||||
/// Podpis nesedí nebo neznám identity klíč podepisujícího (krok 3).
|
||||
badSignature,
|
||||
|
||||
/// Epocha přeskakuje — chybí mi mezistav (krok 4, `>`).
|
||||
gap,
|
||||
|
||||
/// Epochu už mám nebo jsem dál (krok 4, `<=`).
|
||||
stale,
|
||||
|
||||
/// Navazuje na jiný předchozí stav než můj (krok 5).
|
||||
fork,
|
||||
}
|
||||
|
||||
/// Drátová podoba `gop`: pole tabulky ze specifikace. Samostatně jako
|
||||
/// `t: 'gop'`, v kompatibilním režimu vnořená pod klíčem `gop` v legacy
|
||||
/// `ginv`/`gmeta` (v1 klient neznámé pole ignoruje a použije stín).
|
||||
class GroupOpMessage {
|
||||
GroupOpMessage({required this.op, required this.sig, this.roster});
|
||||
|
||||
final GroupAdminOp op;
|
||||
final Uint8List sig;
|
||||
|
||||
/// Roster entry `[{f,n,ik,aid,da,v}]` — u přidání nových členů a u
|
||||
/// pozvánky, ať nový člen zná klíče a routing ostatních.
|
||||
final List<Map<String, dynamic>>? roster;
|
||||
|
||||
Map<String, dynamic> toWire() => {
|
||||
'gid': op.gid,
|
||||
'epoch': op.epoch,
|
||||
'op': op.action.name,
|
||||
'by': op.actor,
|
||||
'members': op.members,
|
||||
'admins': op.admins,
|
||||
'gname': op.name,
|
||||
'prev': base64Encode(op.prev),
|
||||
'sig': base64Encode(sig),
|
||||
'roster': ?roster,
|
||||
};
|
||||
|
||||
/// Null = poškozená nebo neznámá podoba (neznámý typ operace z novější
|
||||
/// verze se nedá ověřit, protože jeho index je součást podpisu).
|
||||
static GroupOpMessage? fromWire(Object? raw) {
|
||||
if (raw is! Map) return null;
|
||||
try {
|
||||
final m = raw.cast<String, dynamic>();
|
||||
final action = GroupAdminAction.values
|
||||
.where((a) => a.name == m['op'])
|
||||
.firstOrNull;
|
||||
final epoch = m['epoch'];
|
||||
if (action == null || epoch is! int || epoch < 0) return null;
|
||||
final rosterRaw = m['roster'];
|
||||
return GroupOpMessage(
|
||||
op: GroupAdminOp(
|
||||
gid: m['gid'] as String,
|
||||
epoch: epoch,
|
||||
action: action,
|
||||
actor: m['by'] as String,
|
||||
members: (m['members'] as List).cast<String>().toList(),
|
||||
admins: (m['admins'] as List).cast<String>().toList(),
|
||||
name: m['gname'] as String,
|
||||
prev: base64Decode(m['prev'] as String),
|
||||
),
|
||||
sig: base64Decode(m['sig'] as String),
|
||||
roster: rosterRaw is List
|
||||
? [
|
||||
for (final e in rosterRaw)
|
||||
if (e is Map) e.cast<String, dynamic>(),
|
||||
]
|
||||
: null,
|
||||
);
|
||||
} on Object {
|
||||
// Špatné typy polí (TypeError), vadné base64 (FormatException).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Invarianty stavu, které musí držet každá operace bez ohledu na autora.
|
||||
bool _stateValid(GroupAdminOp op) =>
|
||||
op.gid.isNotEmpty &&
|
||||
op.gid.length <= 64 &&
|
||||
op.name.trim().isNotEmpty &&
|
||||
op.members.length <= Group.maxMembers &&
|
||||
op.admins.isNotEmpty &&
|
||||
op.admins.every(op.members.contains);
|
||||
|
||||
/// Ověří příchozí operaci proti lokálnímu stavu [local] v pořadí kroků 1–6
|
||||
/// (očíslované v těle funkce).
|
||||
///
|
||||
/// [actorIdentityKey] = identity klíč `by` z mých kontaktů (TOFU — nikdy
|
||||
/// z téže zprávy, jinak by si útočník klíč přinesl sám).
|
||||
GopVerdict checkGroupOp({
|
||||
required Group local,
|
||||
required GroupOpMessage msg,
|
||||
required String senderFp,
|
||||
required String me,
|
||||
required Uint8List? actorIdentityKey,
|
||||
GroupOpSignatureCheck verify = _libsignalVerify,
|
||||
}) {
|
||||
final op = msg.op;
|
||||
// 1. Autor = sealed sender.
|
||||
if (op.actor != senderFp) return GopVerdict.spoof;
|
||||
if (op.gid != local.id || !_stateValid(op)) return GopVerdict.invalid;
|
||||
|
||||
// 2. Oprávnění. U odhadnutých správců (viz Group.provisional) stačí, že
|
||||
// podepisující je člen a v podepsaném stavu správcem.
|
||||
final provisional = local.isProvisionalFor(me);
|
||||
final authorized = provisional
|
||||
? local.isMember(op.actor) && op.admins.contains(op.actor)
|
||||
: local.isAdmin(op.actor);
|
||||
if (!authorized) return GopVerdict.notAdmin;
|
||||
|
||||
// 3. Podpis.
|
||||
if (actorIdentityKey == null || !verify(op, msg.sig, actorIdentityKey)) {
|
||||
return GopVerdict.badSignature;
|
||||
}
|
||||
|
||||
// 4. Epocha. Stav od správce (sync) i kotva odhadnuté skupiny nemají na
|
||||
// co navázat — rozhoduje jen to, že posouvají dopředu. Ostrá nerovnost
|
||||
// u sync brání přeskakování mezi dvěma stejně starými větvemi.
|
||||
if (op.action == GroupAdminAction.sync) {
|
||||
return op.epoch > local.epoch ? GopVerdict.apply : GopVerdict.stale;
|
||||
}
|
||||
if (provisional) {
|
||||
return op.epoch >= local.epoch ? GopVerdict.apply : GopVerdict.stale;
|
||||
}
|
||||
if (op.epoch <= local.epoch) return GopVerdict.stale;
|
||||
if (op.epoch > local.epoch + 1) return GopVerdict.gap;
|
||||
|
||||
// 5. Řetěz: `prev` musí být hash mého aktuálního stavu.
|
||||
final mine = hashState(
|
||||
gid: local.id,
|
||||
epoch: local.epoch,
|
||||
members: local.memberIds,
|
||||
admins: local.admins,
|
||||
name: local.name,
|
||||
);
|
||||
if (!_bytesEqual(op.prev, mine)) return GopVerdict.fork;
|
||||
|
||||
// 6. Aplikovat (volající přes applyGroupOp).
|
||||
return GopVerdict.apply;
|
||||
}
|
||||
|
||||
/// Ověří operaci, kterou mě někdo přidává do skupiny, kterou ještě neznám.
|
||||
/// Není s čím řetězit — důvěra je stejná jako u v1 pozvánky (kdo mě zve,
|
||||
/// je člen), navíc musí být podepsaná správcem uvedeným v rosteru.
|
||||
GopVerdict checkJoinOp({
|
||||
required GroupOpMessage msg,
|
||||
required String senderFp,
|
||||
required String me,
|
||||
required Uint8List? actorIdentityKey,
|
||||
GroupOpSignatureCheck verify = _libsignalVerify,
|
||||
}) {
|
||||
final op = msg.op;
|
||||
if (op.actor != senderFp) return GopVerdict.spoof;
|
||||
if (!_stateValid(op) || !op.members.contains(me)) return GopVerdict.invalid;
|
||||
if (!op.admins.contains(op.actor)) return GopVerdict.notAdmin;
|
||||
if (actorIdentityKey == null || !verify(op, msg.sig, actorIdentityKey)) {
|
||||
return GopVerdict.badSignature;
|
||||
}
|
||||
return GopVerdict.apply;
|
||||
}
|
||||
|
||||
/// Přepíše lokální stav výsledkem ověřené operace. Když ve výsledku
|
||||
/// nejsem, správce mě odebral — skupina se chová jako opuštěná.
|
||||
void applyGroupOp(Group group, GroupAdminOp op, String me) {
|
||||
group
|
||||
..memberIds = [...op.members]
|
||||
..admins = [...op.admins]
|
||||
..name = op.name
|
||||
..epoch = op.epoch
|
||||
..provisional = false;
|
||||
if (!op.members.contains(me)) group.left = true;
|
||||
}
|
||||
|
||||
/// Hash aktuálního stavu skupiny (vstupuje jako `prev` další operace).
|
||||
Uint8List currentStateHash(Group g) => hashState(
|
||||
gid: g.id,
|
||||
epoch: g.epoch,
|
||||
members: g.memberIds,
|
||||
admins: g.admins,
|
||||
name: g.name,
|
||||
);
|
||||
|
||||
/// Postaví odchozí operaci s kontrolou oprávnění a invariantů. Hází
|
||||
/// [NotGroupAdminError] / [LastGroupAdminError] dřív, než cokoli odejde.
|
||||
GroupAdminOp buildGroupOp({
|
||||
required Group group,
|
||||
required String me,
|
||||
required GroupAdminAction action,
|
||||
List<String>? members,
|
||||
List<String>? admins,
|
||||
String? name,
|
||||
}) {
|
||||
if (!group.isAdmin(me)) throw const NotGroupAdminError();
|
||||
final newMembers = members ?? group.memberIds;
|
||||
final newAdmins = admins ?? group.admins;
|
||||
if (newMembers.toSet().length > Group.maxMembers) {
|
||||
throw const GroupTooLargeError(Group.maxMembers);
|
||||
}
|
||||
// Poslední správce se nesmí odebrat ani zbavit role — skupina by zůstala
|
||||
// bez nikoho, kdo smí podepsat další změnu.
|
||||
if (newAdmins.isEmpty) throw const LastGroupAdminError();
|
||||
if (!newAdmins.every(newMembers.contains)) {
|
||||
throw StateError('group admins must be members');
|
||||
}
|
||||
final isSync = action == GroupAdminAction.sync;
|
||||
return GroupAdminOp(
|
||||
gid: group.id,
|
||||
epoch: isSync ? group.epoch : group.epoch + 1,
|
||||
action: action,
|
||||
actor: me,
|
||||
members: newMembers,
|
||||
admins: newAdmins,
|
||||
name: name ?? group.name,
|
||||
// Sync nic neřetězí, jen potvrzuje stav — viz checkGroupOp krok 4.
|
||||
prev: isSync ? Uint8List(0) : currentStateHash(group),
|
||||
);
|
||||
}
|
||||
|
||||
/// Podrží operaci na později. Při přetečení se zahodí ty z nejvzdálenější
|
||||
/// budoucnosti — podvržené operace s absurdní epochou tak nevytlačí
|
||||
/// skutečně chybějící mezistav.
|
||||
void holdGroupOp(Group group, PendingGroupOp pending) {
|
||||
final sig = pending.wire['sig'];
|
||||
if (group.pendingOps.any((p) => p.wire['sig'] == sig)) return;
|
||||
group.pendingOps = [...group.pendingOps, pending]
|
||||
..sort((a, b) => a.epoch.compareTo(b.epoch));
|
||||
if (group.pendingOps.length > Group.maxPendingOps) {
|
||||
group.pendingOps = group.pendingOps.sublist(0, Group.maxPendingOps);
|
||||
}
|
||||
}
|
||||
|
||||
bool _bytesEqual(List<int> a, List<int> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -43,6 +43,19 @@ class ImageProcessor {
|
||||
/// sealed-sender obálku) — držet výrazně pod tím.
|
||||
static const int thumbMaxBytes = 48 * 1024;
|
||||
|
||||
/// Jestli z obrázku umíme odstranit metadata. Formát, který knihovna
|
||||
/// nepřečte (HEIC z galerie odeslaný jako dokument, RAW…), by odešel
|
||||
/// v originále včetně GPS — UI se proto nejdřív zeptá uživatele.
|
||||
static Future<bool> canStripMetadata(Uint8List bytes) {
|
||||
return Isolate.run(() {
|
||||
try {
|
||||
return img.decodeImage(bytes) != null;
|
||||
} on Object {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Zpracuje obrázek v izolátu (dekódování megapixelů by blokovalo UI).
|
||||
/// Vrací null, když bajty nejsou dekódovatelný obrázek — volající je
|
||||
/// pak pošle jako dokument.
|
||||
@@ -51,7 +64,13 @@ class ImageProcessor {
|
||||
String? mimeHint,
|
||||
}) {
|
||||
return Isolate.run(() {
|
||||
final decoded = img.decodeImage(original);
|
||||
final img.Image? decoded;
|
||||
try {
|
||||
decoded = img.decodeImage(original);
|
||||
} on Object {
|
||||
// Dekodéry na poškozených datech háží místo null.
|
||||
return null;
|
||||
}
|
||||
if (decoded == null) return null;
|
||||
|
||||
// GIF beze změny — re-encode by zabil animaci
|
||||
@@ -77,6 +96,7 @@ class ImageProcessor {
|
||||
// Samotný alfa kanál nestačí — plně krytý RGBA PNG (screenshot,
|
||||
// canvas render z editoru fotek) je jako JPEG několikanásobně menší.
|
||||
final keepPng = mimeHint == 'image/png' && _hasTransparency(decoded);
|
||||
stripMetadata(image);
|
||||
final bytes = keepPng
|
||||
? Uint8List.fromList(img.encodePng(image))
|
||||
: Uint8List.fromList(img.encodeJpg(image, quality: jpegQuality));
|
||||
@@ -101,6 +121,15 @@ class ImageProcessor {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Zahodí EXIF (GPS, model telefonu, čas focení) a textová metadata.
|
||||
/// Knihovna je při ořezu i zmenšení kopíruje a encodeJpg/encodePng je
|
||||
/// znovu zapíše — re-encode sám o sobě metadata NEodstraní.
|
||||
static void stripMetadata(img.Image image) {
|
||||
image
|
||||
..exif = img.ExifData()
|
||||
..textData = null;
|
||||
}
|
||||
|
||||
static Uint8List _thumbnail(img.Image source) {
|
||||
var thumb = max(source.width, source.height) > thumbDimension
|
||||
? (source.width >= source.height
|
||||
@@ -114,6 +143,7 @@ class ImageProcessor {
|
||||
img.compositeImage(flat, thumb);
|
||||
thumb = flat;
|
||||
}
|
||||
stripMetadata(thumb);
|
||||
var quality = 70;
|
||||
var encoded = Uint8List.fromList(img.encodeJpg(thumb, quality: quality));
|
||||
// Náhled je jen placeholder, než dorazí ostrá fotka — kvalitu držíme
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Kdo právě píše: konverzace → otisky pisatelů.
|
||||
///
|
||||
/// Indikátor nemá „konec psaní“ — odesílatel posílá „píšu“ nejvýš jednou
|
||||
/// za pár sekund a tady každý záznam sám vyprší. Zpráva, která pak dorazí,
|
||||
/// ho smaže hned ([clear]).
|
||||
class TypingTracker {
|
||||
TypingTracker({DateTime Function()? clock}) : _now = clock ?? DateTime.now;
|
||||
|
||||
/// Jak dlouho po posledním „píšu“ indikátor svítí. Musí být delší než
|
||||
/// interval, ve kterém odesílatel „píšu“ opakuje (4 s), jinak by blikal.
|
||||
static const showFor = Duration(seconds: 6);
|
||||
|
||||
final DateTime Function() _now;
|
||||
final _expires = <String, Map<String, DateTime>>{};
|
||||
final _changes = StreamController<Map<String, Set<String>>>.broadcast();
|
||||
Timer? _sweep;
|
||||
|
||||
Stream<Map<String, Set<String>>> get changes => _changes.stream;
|
||||
|
||||
/// Aktuální pisatelé po konverzacích (bez vypršelých).
|
||||
Map<String, Set<String>> get current {
|
||||
final now = _now();
|
||||
return {
|
||||
for (final e in _expires.entries)
|
||||
if (e.value.values.any((t) => t.isAfter(now)))
|
||||
e.key: {
|
||||
for (final w in e.value.entries)
|
||||
if (w.value.isAfter(now)) w.key,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void mark(String convId, String writer) {
|
||||
(_expires[convId] ??= {})[writer] = _now().add(showFor);
|
||||
_emit();
|
||||
_sweep ??= Timer.periodic(const Duration(seconds: 1), (_) => prune());
|
||||
}
|
||||
|
||||
void clear(String convId, String writer) {
|
||||
final writers = _expires[convId];
|
||||
if (writers?.remove(writer) == null) return;
|
||||
if (writers!.isEmpty) _expires.remove(convId);
|
||||
_emit();
|
||||
}
|
||||
|
||||
/// Zahodí vypršelé záznamy; když už nikdo nepíše, zastaví časovač.
|
||||
void prune() {
|
||||
final now = _now();
|
||||
var changed = false;
|
||||
_expires.removeWhere((_, writers) {
|
||||
final before = writers.length;
|
||||
writers.removeWhere((_, t) => !t.isAfter(now));
|
||||
changed |= writers.length != before;
|
||||
return writers.isEmpty;
|
||||
});
|
||||
if (changed) _emit();
|
||||
if (_expires.isEmpty) {
|
||||
_sweep?.cancel();
|
||||
_sweep = null;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_sweep?.cancel();
|
||||
_sweep = null;
|
||||
_expires.clear();
|
||||
}
|
||||
|
||||
void _emit() {
|
||||
if (!_changes.isClosed) _changes.add(current);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,16 @@ import 'package:sepot/models/message.dart';
|
||||
/// při onboardingu, který DB rovnou otevře).
|
||||
|
||||
/// Nátlakový PIN je shodný se skutečným — nelze nastavit.
|
||||
/// Příliš mnoho špatných PINů za sebou — další pokus až za [remaining].
|
||||
class PinLockedException implements Exception {
|
||||
const PinLockedException(this.remaining);
|
||||
|
||||
final Duration remaining;
|
||||
|
||||
@override
|
||||
String toString() => 'PIN locked for ${remaining.inSeconds}s';
|
||||
}
|
||||
|
||||
class DecoyPinConflict implements Exception {
|
||||
const DecoyPinConflict();
|
||||
@override
|
||||
@@ -51,6 +61,13 @@ class SecureStorageService {
|
||||
static const _legacyPinHashKey = 'pin_hash';
|
||||
|
||||
static const _decoyWrappedDekKey = 'wrapped_dek_decoy';
|
||||
// Brzda hádání PINu: počet chyb za sebou a čas, do kdy je zámek uzamčený.
|
||||
// V keystore (ne v DB) — DB je právě to, co se hádáním otevírá.
|
||||
static const _pinFailCountKey = 'pin_fail_count';
|
||||
static const _pinLockUntilKey = 'pin_lock_until';
|
||||
static const _pinFreeAttempts = 5;
|
||||
static const _pinLockBase = Duration(seconds: 30);
|
||||
static const _pinLockMax = Duration(minutes: 15);
|
||||
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
final String? _dbPathOverride;
|
||||
@@ -60,12 +77,24 @@ class SecureStorageService {
|
||||
bool _decoyMode = false;
|
||||
|
||||
/// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB.
|
||||
/// V tomhle režimu úložiště nevydá skutečnou identitu ani frázi,
|
||||
/// nestartuje síť a nic z keystore nemění (viz jednotlivé metody).
|
||||
bool get isDecoyMode => _decoyMode;
|
||||
|
||||
/// Identita ukazovaná v nátlakové relaci: náhodná, jen v paměti.
|
||||
LocalIdentity? _decoyIdentity;
|
||||
|
||||
void _requireRealSession() {
|
||||
if (_decoyMode) throw StateError('Not available in a duress session');
|
||||
}
|
||||
|
||||
SecureStorageService({String? dbPath})
|
||||
: _secureStorage = const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
// this_device: identita a klíče nesmí putovat v iCloud Keychain
|
||||
// ani v záloze na jiný telefon — obnova jde přes 24 slov.
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device),
|
||||
),
|
||||
_dbPathOverride = dbPath;
|
||||
|
||||
@@ -98,21 +127,59 @@ class SecureStorageService {
|
||||
if (wrapped == null) {
|
||||
throw StateError('No PIN set — use unlockWithoutPin');
|
||||
}
|
||||
final locked = await pinLockRemaining();
|
||||
if (locked != null) throw PinLockedException(locked);
|
||||
|
||||
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
|
||||
try {
|
||||
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: pin);
|
||||
await _openDb(dek);
|
||||
// Oba bloby jednou derivací (sdílená sůl) — čas odemčení neprozradí,
|
||||
// který PIN padl.
|
||||
final (index, dek) = await DekWrap.unwrapFirstMatching(
|
||||
wrapped: [wrapped, ?decoy],
|
||||
pin: pin,
|
||||
);
|
||||
await _openDb(dek, decoy: index == 1);
|
||||
await _resetPinFailures();
|
||||
} on WrongPinException {
|
||||
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
|
||||
if (decoy == null) rethrow;
|
||||
// Když ani decoy nesedí, unwrap vyhodí WrongPinException dál.
|
||||
final dek = await DekWrap.unwrap(wrapped: decoy, pin: pin);
|
||||
await _openDb(dek, decoy: true);
|
||||
await _notePinFailure();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Zbývající zámek po sérii špatných PINů; null = volno.
|
||||
Future<Duration?> pinLockRemaining() async {
|
||||
final raw = await _secureStorage.read(key: _pinLockUntilKey);
|
||||
final until = raw == null ? null : int.tryParse(raw);
|
||||
if (until == null) return null;
|
||||
final remaining = DateTime.fromMillisecondsSinceEpoch(until)
|
||||
.difference(DateTime.now());
|
||||
return remaining > Duration.zero ? remaining : null;
|
||||
}
|
||||
|
||||
Future<void> _notePinFailure() async {
|
||||
final raw = await _secureStorage.read(key: _pinFailCountKey);
|
||||
final failures = (raw == null ? 0 : int.tryParse(raw) ?? 0) + 1;
|
||||
await _secureStorage.write(key: _pinFailCountKey, value: '$failures');
|
||||
if (failures < _pinFreeAttempts) return;
|
||||
// 30 s po páté chybě, pak dvojnásobně až do 15 minut: šest číslic
|
||||
// (10⁶) tak trvá roky, Argon2id sám by brzdil jen ~2 s na pokus.
|
||||
var lock = _pinLockBase * (1 << (failures - _pinFreeAttempts));
|
||||
if (lock > _pinLockMax) lock = _pinLockMax;
|
||||
await _secureStorage.write(
|
||||
key: _pinLockUntilKey,
|
||||
value: '${DateTime.now().add(lock).millisecondsSinceEpoch}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _resetPinFailures() async {
|
||||
await _secureStorage.delete(key: _pinFailCountKey);
|
||||
await _secureStorage.delete(key: _pinLockUntilKey);
|
||||
}
|
||||
|
||||
/// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže.
|
||||
/// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře.
|
||||
Future<void> enablePin(String pin) async {
|
||||
_requireRealSession();
|
||||
final dek = _dek ?? await _getOrCreateRawDek();
|
||||
final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
|
||||
await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
|
||||
@@ -124,20 +191,34 @@ class SecureStorageService {
|
||||
|
||||
/// Vypne PIN: DEK se vrátí raw do OS keystore.
|
||||
Future<void> disablePin() async {
|
||||
_requireRealSession();
|
||||
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
||||
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
|
||||
await _secureStorage.delete(key: _wrappedDekKey);
|
||||
}
|
||||
|
||||
/// Ověří PIN, aniž by cokoli změnil (unwrap a zahození DEK).
|
||||
/// Stojí jednu Argon2id derivaci (~2 s) — to je zároveň rate limit.
|
||||
/// Ověří skutečný PIN, aniž by cokoli změnil (unwrap a zahození DEK).
|
||||
/// Tímhle se potvrzují citlivé akce v odemčené appce (fráze, záloha,
|
||||
/// smazání účtu, změna PINu).
|
||||
///
|
||||
/// Sdílí brzdu se zámkovou obrazovkou: kdo dostane do ruky odemčený
|
||||
/// telefon, nesmí tu hádat PIN rychleji než na zámku. Zamčeno =
|
||||
/// [PinLockedException].
|
||||
///
|
||||
/// V nátlakové relaci vždy false: i kdyby někdo znal skutečný PIN, k frázi
|
||||
/// skutečné identity se odtud dostat nesmí (otevře si appku normálně).
|
||||
Future<bool> verifyPin(String pin) async {
|
||||
if (_decoyMode) return false;
|
||||
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (wrapped == null) return false;
|
||||
final locked = await pinLockRemaining();
|
||||
if (locked != null) throw PinLockedException(locked);
|
||||
try {
|
||||
await DekWrap.unwrap(wrapped: wrapped, pin: pin);
|
||||
await _resetPinFailures();
|
||||
return true;
|
||||
} on WrongPinException {
|
||||
await _notePinFailure();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -151,6 +232,7 @@ class SecureStorageService {
|
||||
required String oldPin,
|
||||
required String newPin,
|
||||
}) async {
|
||||
_requireRealSession();
|
||||
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (wrapped == null) throw StateError('No PIN set');
|
||||
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin);
|
||||
@@ -165,7 +247,11 @@ class SecureStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
final rewrapped = await DekWrap.wrap(dek: dek, pin: newPin);
|
||||
// Sůl zůstává: nátlakový blob ji sdílí (jedna derivace pro oba PINy)
|
||||
// a bez jeho PINu by se přebalit nedal. Jedna sůl pro dva PINy téhož
|
||||
// uživatele nic neoslabuje — sůl brání předpočítání napříč uživateli.
|
||||
final rewrapped = await DekWrap.wrap(
|
||||
dek: dek, pin: newPin, salt: DekWrap.saltOf(wrapped));
|
||||
await _secureStorage.write(key: _wrappedDekKey, value: rewrapped);
|
||||
}
|
||||
|
||||
@@ -176,6 +262,7 @@ class SecureStorageService {
|
||||
/// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy
|
||||
/// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit.
|
||||
Future<void> enableDecoyPin(String decoyPin) async {
|
||||
_requireRealSession();
|
||||
final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (realWrapped == null) {
|
||||
throw StateError('Set a regular PIN first');
|
||||
@@ -193,7 +280,9 @@ class SecureStorageService {
|
||||
// Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB
|
||||
final decoyPath = await _dbPath(decoy: true);
|
||||
CryptoDb.open(path: decoyPath, dek: decoyDek).close();
|
||||
final wrapped = await DekWrap.wrap(dek: decoyDek, pin: decoyPin);
|
||||
// Stejná sůl jako skutečný blob → jedna Argon2id derivace pro oba PINy.
|
||||
final wrapped = await DekWrap.wrap(
|
||||
dek: decoyDek, pin: decoyPin, salt: DekWrap.saltOf(realWrapped));
|
||||
await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped);
|
||||
}
|
||||
|
||||
@@ -203,11 +292,20 @@ class SecureStorageService {
|
||||
|
||||
/// Zruší nátlakový PIN a smaže decoy DB.
|
||||
Future<void> disableDecoyPin() async {
|
||||
_requireRealSession();
|
||||
await _secureStorage.delete(key: _decoyWrappedDekKey);
|
||||
try {
|
||||
final f = File(await _dbPath(decoy: true));
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
} catch (_) {}
|
||||
await _deleteDbFiles(await _dbPath(decoy: true));
|
||||
}
|
||||
|
||||
Future<void> _deleteDbFiles(String path) async {
|
||||
for (final suffix in ['', '-wal', '-shm']) {
|
||||
try {
|
||||
final f = File('$path$suffix');
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
} catch (_) {
|
||||
// best-effort úklid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme
|
||||
@@ -222,6 +320,8 @@ class SecureStorageService {
|
||||
_db?.close();
|
||||
_db = null;
|
||||
_dek = null;
|
||||
_decoyMode = false;
|
||||
_decoyIdentity = null;
|
||||
}
|
||||
|
||||
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
|
||||
@@ -293,6 +393,11 @@ class SecureStorageService {
|
||||
|
||||
/// Načti lokální identitu
|
||||
Future<LocalIdentity?> loadIdentity() async {
|
||||
if (_decoyMode) {
|
||||
// Nátlaková relace ukazuje náhodnou identitu jen z paměti — skutečný
|
||||
// klíč z keystore se do ní nikdy nedostane (nátlakový PIN musí být izolovaný).
|
||||
return _decoyIdentity ??= await LocalIdentity.create('');
|
||||
}
|
||||
final json = await _secureStorage.read(key: _identityKey);
|
||||
if (json == null) return null;
|
||||
try {
|
||||
@@ -320,6 +425,7 @@ class SecureStorageService {
|
||||
}
|
||||
|
||||
Future<List<String>?> loadSeedPhrase() async {
|
||||
if (_decoyMode) return null;
|
||||
final json = await _secureStorage.read(key: _seedPhraseKey);
|
||||
if (json == null) return null;
|
||||
return (jsonDecode(json) as List).cast<String>();
|
||||
@@ -331,6 +437,7 @@ class SecureStorageService {
|
||||
/// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings).
|
||||
/// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním.
|
||||
Future<void> enableBiometrics() async {
|
||||
_requireRealSession();
|
||||
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
||||
await _secureStorage.write(
|
||||
key: _biometricDekKey,
|
||||
@@ -342,6 +449,7 @@ class SecureStorageService {
|
||||
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
|
||||
/// PIN-only model: DEK existuje jen za Argon2id).
|
||||
Future<void> disableBiometrics() async {
|
||||
_requireRealSession();
|
||||
await _secureStorage.delete(key: _biometricDekKey);
|
||||
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
|
||||
}
|
||||
@@ -475,24 +583,29 @@ class SecureStorageService {
|
||||
if (!isUnlocked) return false;
|
||||
var changed = false;
|
||||
final now = DateTime.now();
|
||||
for (final conv in await loadConversations()) {
|
||||
final expired = conv.messages
|
||||
.where((m) =>
|
||||
m.disappearAfterSeconds != null &&
|
||||
m.timestamp
|
||||
.add(Duration(seconds: m.disappearAfterSeconds!))
|
||||
.isBefore(now))
|
||||
.toList();
|
||||
bool isExpired(Message m) =>
|
||||
m.disappearAfterSeconds != null &&
|
||||
m.timestamp
|
||||
.add(Duration(seconds: m.disappearAfterSeconds!))
|
||||
.isBefore(now);
|
||||
for (final snapshot in await loadConversations()) {
|
||||
if (!snapshot.messages.any(isExpired)) continue;
|
||||
// Konverzace se před zápisem načte znovu: mezi snímkem a zápisem
|
||||
// běží event loop (mazání blobů, příjem zpráv) a starý snímek by
|
||||
// přepsal zprávu přijatou mezitím — už ACKnutou, tedy navždy pryč.
|
||||
final conv = await loadConversation(snapshot.contactId);
|
||||
if (conv == null) continue;
|
||||
final expired = conv.messages.where(isExpired).toList();
|
||||
if (expired.isEmpty) continue;
|
||||
changed = true;
|
||||
conv.messages.removeWhere(isExpired);
|
||||
await saveConversation(conv);
|
||||
for (final m in expired) {
|
||||
final blobId = m.attachment?.blobId;
|
||||
if (blobId != null) {
|
||||
await AttachmentStore().delete(blobId);
|
||||
}
|
||||
conv.messages.remove(m);
|
||||
}
|
||||
await saveConversation(conv);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
@@ -512,12 +625,19 @@ class SecureStorageService {
|
||||
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
|
||||
/// i lokální bloby příloh.
|
||||
Future<void> deleteAll() async {
|
||||
final path = await _dbPath();
|
||||
lock();
|
||||
for (final suffix in ['', '-wal', '-shm']) {
|
||||
final f = File('$path$suffix');
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
if (_decoyMode) {
|
||||
// Nátlaková relace: „smazat" smí jen prázdnou decoy DB — skutečná
|
||||
// data a keystore zůstávají (dřív to smazalo reálný účet).
|
||||
final decoyPath = await _dbPath(decoy: true);
|
||||
lock();
|
||||
await _deleteDbFiles(decoyPath);
|
||||
return;
|
||||
}
|
||||
final path = await _dbPath();
|
||||
final decoyPath = await _dbPath(decoy: true);
|
||||
lock();
|
||||
await _deleteDbFiles(path);
|
||||
await _deleteDbFiles(decoyPath);
|
||||
await AttachmentStore().deleteAll();
|
||||
await _secureStorage.deleteAll();
|
||||
}
|
||||
|
||||