Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d431501b74 | |||
| 6f9b08e844 | |||
| 09ef54ef69 | |||
| bfea7201f7 | |||
| 05eeccd83b | |||
| d13b88aebe | |||
| b824f62878 | |||
| 00739ac8a0 | |||
| 94cfd72690 | |||
| e3a2378c34 | |||
| 8b452a5593 | |||
| 492db55a04 | |||
| 617dd05267 | |||
| 5246a97db2 | |||
| 933a74c900 | |||
| 9a638545dd | |||
| 69687d0acf | |||
| 4e3d7de32c | |||
| 414d39c835 | |||
| 2fd4c2e988 | |||
| e5d0e54b80 | |||
| bd51fef5cc | |||
| 79ad411d7e | |||
| f903e21e3a | |||
| 923a6a01bd | |||
| 9985b79aa6 | |||
| b82f50f888 | |||
| 4f2cbdbdc4 |
@@ -4,6 +4,172 @@ Všechny podstatné změny v aplikaci Šepot. Formát dle
|
|||||||
[Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
|
[Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
|
||||||
verzování dle [ajtak standardu](#verzování).
|
verzování dle [ajtak standardu](#verzování).
|
||||||
|
|
||||||
|
## [Nevydáno]
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- **Zprávy už nečekají na otevření aplikace.** Když mobilní síť tiše
|
||||||
|
zahodila nečinné spojení, Šepot o tom nevěděl: tvářil se připojeně, ale
|
||||||
|
odeslaná zpráva skončila chybou a příchozí nedorazily — a všechny se pak
|
||||||
|
vysypaly naráz po restartu aplikace. Spojení se teď hlídá pravidelným
|
||||||
|
pingem na obou stranách, takže výpadek se pozná a obnoví v řádu sekund.
|
||||||
|
- **Neodeslaná zpráva se pošle sama.** Dosud zůstala označená jako
|
||||||
|
neodeslaná (u příloh dokonce natrvalo na „odesílá se") a poslat znovu ji
|
||||||
|
musel člověk ručně. Nově se po obnovení spojení odešle sama; opakované
|
||||||
|
doručení téže zprávy druhá strana pozná a nezobrazí ji dvakrát.
|
||||||
|
- **Zprávy, které nešly doručit, se přestaly hromadit.** Když se cestou
|
||||||
|
ztratilo potvrzení o doručení, obálka na serveru zůstala a znovu a znovu
|
||||||
|
se posílala, aniž by ji šlo přečíst. Server teď takové zprávy po několika
|
||||||
|
marných pokusech zahodí, místo aby jimi zdržoval doručování ostatních.
|
||||||
|
|
||||||
|
### Přidáno
|
||||||
|
- **Upozornění, když nechodí notifikace.** Bez nich Android aplikaci na
|
||||||
|
pozadí uspí a zprávy uvidíš, až ji otevřeš. Šepot to teď řekne rovnou v
|
||||||
|
seznamu chatů a nabídne nastavení; hlášku jde odmítnout. Selhání
|
||||||
|
registrace u distributora se už nezamlčí.
|
||||||
|
|
||||||
## [1.1.1] – 2026-07-18
|
## [1.1.1] – 2026-07-18
|
||||||
|
|
||||||
### Opraveno
|
### Opraveno
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
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, does not learn who sent a message, and does not log IP addresses. What it *does* see 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 (production 1.1.2, 1.2.0 in internal testing); iOS builds run through TestFlight. See [`CHANGELOG.md`](CHANGELOG.md).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ The design goal is **metadata minimization with a public threat model** — neve
|
|||||||
- **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; the relay never learns a group exists.
|
||||||
- **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.
|
- **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).
|
- **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.
|
- **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.
|
- **Five languages** — Czech, English, German, Russian, Italian. The UI follows the system language and can be overridden in Settings.
|
||||||
|
|
||||||
@@ -71,10 +71,10 @@ UI strings live in `app/lib/l10n/app_*.arb` (`cs`, `en`, `de`, `ru`, `it`) and a
|
|||||||
|
|
||||||
## Security
|
## 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 Signal's `libsignal`, not by hand-rolled primitives. 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
|
## License
|
||||||
|
|
||||||
Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](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 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).
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 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: the relay sees routing tokens,
|
||||||
|
message sizes, timing and push tokens, never content or sender identity.
|
||||||
|
Reports that demonstrate the relay learning more than that are the ones we
|
||||||
|
care about most.
|
||||||
@@ -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
|
Šepot relies on that stated position and, as an AGPL work, keeps its
|
||||||
complete corresponding source available under the AGPL through channels
|
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
|
APK). A written clarification for the current Rust `libsignal` has been
|
||||||
requested from Signal (tracking: <https://github.com/signalapp/libsignal/issues/684>).
|
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"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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" />
|
<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
|
<!-- 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. -->
|
nepoužívá (žádné reklamy/tracking) → odebíráme, ať sedí Play deklarace. -->
|
||||||
<uses-permission android:name="com.google.android.gms.permission.AD_ID"
|
<uses-permission android:name="com.google.android.gms.permission.AD_ID"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package cz.sepot.sepot
|
package cz.sepot.sepot
|
||||||
|
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
@@ -7,10 +8,20 @@ import io.flutter.embedding.android.FlutterFragmentActivity
|
|||||||
class MainActivity : FlutterFragmentActivity() {
|
class MainActivity : FlutterFragmentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací
|
// 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(
|
window.setFlags(
|
||||||
WindowManager.LayoutParams.FLAG_SECURE,
|
WindowManager.LayoutParams.FLAG_SECURE,
|
||||||
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#0a0e17</color>
|
<color name="ic_launcher_background">#0B0C12</color>
|
||||||
</resources>
|
</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).
|
# Launcher icons: the "Š that fades into dots" mark (variant B, 2026-10).
|
||||||
# Zdroj: sepot-web/koncept/logo/nokturno-ikona-B2.png; regenerace:
|
# Source: assets/icon/icon-1024.png (full) and maskable-fg-512.png (adaptive foreground); regenerate with:
|
||||||
# dart run flutter_launcher_icons
|
# dart run flutter_launcher_icons
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: true
|
android: true
|
||||||
ios: true
|
ios: true
|
||||||
image_path: assets/icon/icon-1024.png
|
image_path: assets/icon/icon-1024.png
|
||||||
adaptive_icon_background: "#0a0e17"
|
adaptive_icon_background: "#0B0C12"
|
||||||
adaptive_icon_foreground: assets/icon/maskable-fg-512.png
|
adaptive_icon_foreground: assets/icon/maskable-fg-512.png
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
import CallKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import PushKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import WebRTC
|
||||||
|
import flutter_callkit_incoming
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
|
||||||
|
// PushKit registry musí žít po celou dobu běhu appky, jinak VoIP pushe nechodí.
|
||||||
|
private var voipRegistry: PKPushRegistry?
|
||||||
private var apnsChannel: FlutterMethodChannel?
|
private var apnsChannel: FlutterMethodChannel?
|
||||||
// Token dorazí asynchronně z APNs; když si o něj Dart řekne dřív, podržíme ho.
|
// Token dorazí asynchronně z APNs; když si o něj Dart řekne dřív, podržíme ho.
|
||||||
private var pendingToken: String?
|
private var pendingToken: String?
|
||||||
@@ -29,13 +35,100 @@ import UIKit
|
|||||||
apnsChannel = channel
|
apnsChannel = channel
|
||||||
|
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
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)
|
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).
|
// APNs vydal device token → předáme Dartu (ten ho pošle relayi).
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Foundation.Data
|
||||||
) {
|
) {
|
||||||
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
||||||
pendingToken = token
|
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>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
|
<string>audio</string>
|
||||||
|
<string>voip</string>
|
||||||
</array>
|
</array>
|
||||||
<key>NSCameraUsageDescription</key>
|
<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>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>The photo library is used to pick photos to send in a conversation.</string>
|
<string>The photo library is used to pick photos to send in a conversation.</string>
|
||||||
<key>NSFaceIDUsageDescription</key>
|
<key>NSFaceIDUsageDescription</key>
|
||||||
@@ -41,7 +43,7 @@
|
|||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<true/>
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
@@ -66,6 +68,6 @@
|
|||||||
<key>UIStatusBarHidden</key>
|
<key>UIStatusBarHidden</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Šepot — texty oprávnění iOS (čeština) */
|
/* Š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.";
|
"NSPhotoLibraryUsageDescription" = "Galerie slouží k výběru fotek pro odeslání v konverzaci.";
|
||||||
"NSFaceIDUsageDescription" = "Face ID odemyká aplikaci místo PINu.";
|
"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) */
|
/* Š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.";
|
"NSPhotoLibraryUsageDescription" = "Die Fotomediathek dient zum Auswählen von Fotos, die du in einer Unterhaltung sendest.";
|
||||||
"NSFaceIDUsageDescription" = "Face ID entsperrt die App anstelle der PIN.";
|
"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) */
|
/* Š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.";
|
"NSPhotoLibraryUsageDescription" = "The photo library is used to pick photos to send in a conversation.";
|
||||||
"NSFaceIDUsageDescription" = "Face ID unlocks the app instead of your PIN.";
|
"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) */
|
/* Š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.";
|
"NSPhotoLibraryUsageDescription" = "La libreria foto serve a scegliere le foto da inviare in una conversazione.";
|
||||||
"NSFaceIDUsageDescription" = "Face ID sblocca l'app al posto del PIN.";
|
"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 (русский) */
|
/* Šepot — тексты разрешений iOS (русский) */
|
||||||
"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов и съёмки фотографий для вложений.";
|
"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов, съёмки фотографий для вложений и видеозвонков.";
|
||||||
"NSPhotoLibraryUsageDescription" = "Галерея используется для выбора фотографий, которые вы отправляете в чате.";
|
"NSPhotoLibraryUsageDescription" = "Галерея используется для выбора фотографий, которые вы отправляете в чате.";
|
||||||
"NSFaceIDUsageDescription" = "Face ID разблокирует приложение вместо PIN-кода.";
|
"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:go_router/go_router.dart';
|
||||||
import 'package:sepot/core/l10n/l10n.dart';
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
import 'package:sepot/core/theme/app_theme.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/auth/change_pin_screen.dart';
|
||||||
|
import 'package:sepot/features/calls/call_screen.dart';
|
||||||
import 'package:sepot/providers/messaging_provider.dart';
|
import 'package:sepot/providers/messaging_provider.dart';
|
||||||
import 'package:sepot/features/onboarding/onboarding_screen.dart';
|
import 'package:sepot/features/onboarding/onboarding_screen.dart';
|
||||||
import 'package:sepot/features/onboarding/restore_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/contact_verify_screen.dart';
|
||||||
import 'package:sepot/features/contacts/contacts_screen.dart';
|
import 'package:sepot/features/contacts/contacts_screen.dart';
|
||||||
import 'package:sepot/features/groups/create_group_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/settings/settings_screen.dart';
|
||||||
import 'package:sepot/features/safety/report_screen.dart';
|
import 'package:sepot/features/safety/report_screen.dart';
|
||||||
import 'package:sepot/features/qr_pairing/qr_share_screen.dart';
|
import 'package:sepot/features/qr_pairing/qr_share_screen.dart';
|
||||||
@@ -62,6 +66,21 @@ final _router = GoRouter(
|
|||||||
path: '/conversation/:contactId',
|
path: '/conversation/:contactId',
|
||||||
builder: (context, state) => ConversationScreen(
|
builder: (context, state) => ConversationScreen(
|
||||||
contactId: state.pathParameters['contactId']!,
|
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(
|
GoRoute(
|
||||||
@@ -72,6 +91,11 @@ final _router = GoRouter(
|
|||||||
path: '/create-group',
|
path: '/create-group',
|
||||||
builder: (context, state) => const CreateGroupScreen(),
|
builder: (context, state) => const CreateGroupScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/group/:groupId/add',
|
||||||
|
builder: (context, state) =>
|
||||||
|
CreateGroupScreen(addToGroupId: state.pathParameters['groupId']),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/verify/:contactId',
|
path: '/verify/:contactId',
|
||||||
builder: (context, state) => ContactVerifyScreen(
|
builder: (context, state) => ContactVerifyScreen(
|
||||||
@@ -101,6 +125,10 @@ final _router = GoRouter(
|
|||||||
path: '/change-pin',
|
path: '/change-pin',
|
||||||
builder: (context, state) => const ChangePinScreen(),
|
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.
|
// null = systémový jazyk; než se volba načte z keystore, jede systém.
|
||||||
final locale = ref.watch(localeProvider).valueOrNull;
|
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(
|
return _AutoLockGate(
|
||||||
child: MaterialApp.router(
|
child: MaterialApp.router(
|
||||||
onGenerateTitle: (context) => context.l10n.appTitle,
|
onGenerateTitle: (context) => context.l10n.appTitle,
|
||||||
@@ -251,7 +289,9 @@ class _LoadingScreenState extends ConsumerState<_LoadingScreen> {
|
|||||||
final storage = ref.read(storageProvider);
|
final storage = ref.read(storageProvider);
|
||||||
final legacyPin = await storage.hasLegacyPin();
|
final legacyPin = await storage.hasLegacyPin();
|
||||||
await storage.unlockWithoutPin();
|
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._();
|
AppConstants._();
|
||||||
|
|
||||||
static const String appName = 'Šepot';
|
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';
|
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:
|
// Relay server (REST + WS na jednom portu). Přepsatelné při buildu:
|
||||||
// flutter build --dart-define=SEPOT_RELAY=http://<dev-relay-host>:8470
|
// flutter build --dart-define=SEPOT_RELAY=http://<dev-relay-host>:8470
|
||||||
static const String relayUrl = String.fromEnvironment(
|
static const String relayUrl = String.fromEnvironment(
|
||||||
|
|||||||
@@ -53,16 +53,20 @@ class DekWrap {
|
|||||||
int memoryKiB = defaultMemoryKiB,
|
int memoryKiB = defaultMemoryKiB,
|
||||||
int iterations = defaultIterations,
|
int iterations = defaultIterations,
|
||||||
int parallelism = defaultParallelism,
|
int parallelism = defaultParallelism,
|
||||||
|
Uint8List? salt,
|
||||||
}) async {
|
}) async {
|
||||||
if (dek.length != 32) {
|
if (dek.length != 32) {
|
||||||
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
|
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
|
||||||
}
|
}
|
||||||
_enforceFloor(memoryKiB, iterations);
|
_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(
|
final unlockKey = await _deriveUnlockKey(
|
||||||
pin: pin,
|
pin: pin,
|
||||||
salt: salt,
|
salt: useSalt,
|
||||||
memoryKiB: memoryKiB,
|
memoryKiB: memoryKiB,
|
||||||
iterations: iterations,
|
iterations: iterations,
|
||||||
parallelism: parallelism,
|
parallelism: parallelism,
|
||||||
@@ -80,13 +84,72 @@ class DekWrap {
|
|||||||
'$memoryKiB',
|
'$memoryKiB',
|
||||||
'$iterations',
|
'$iterations',
|
||||||
'$parallelism',
|
'$parallelism',
|
||||||
base64Encode(salt),
|
base64Encode(useSalt),
|
||||||
base64Encode(box.nonce),
|
base64Encode(box.nonce),
|
||||||
base64Encode(box.cipherText),
|
base64Encode(box.cipherText),
|
||||||
base64Encode(box.mac.bytes),
|
base64Encode(box.mac.bytes),
|
||||||
].join(':');
|
].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í
|
/// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí
|
||||||
/// [WrongPinException]; na neplatném formátu [FormatException].
|
/// [WrongPinException]; na neplatném formátu [FormatException].
|
||||||
static Future<Uint8List> unwrap({
|
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.
|
/// 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 {
|
class SepotOneTimePreKey {
|
||||||
SepotOneTimePreKey({required this.id, required this.publicKey});
|
SepotOneTimePreKey({required this.id, required this.publicKey});
|
||||||
|
|
||||||
@@ -420,4 +446,64 @@ class SepotCrypto {
|
|||||||
}
|
}
|
||||||
return result;
|
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)';
|
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.
|
/// Skupina bez jediného dalšího člena nedává smysl.
|
||||||
class GroupNeedsMemberError extends SepotUserError {
|
class GroupNeedsMemberError extends SepotUserError {
|
||||||
const GroupNeedsMemberError();
|
const GroupNeedsMemberError();
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ String describeError(AppLocalizations l10n, Object error) => switch (error) {
|
|||||||
ContactLinkIncompleteError() => l10n.errorContactLinkIncomplete,
|
ContactLinkIncompleteError() => l10n.errorContactLinkIncomplete,
|
||||||
AttachmentTooLargeError(:final maxMb) =>
|
AttachmentTooLargeError(:final maxMb) =>
|
||||||
l10n.errorAttachmentTooLarge(maxMb),
|
l10n.errorAttachmentTooLarge(maxMb),
|
||||||
|
AttachmentUnavailableError() => l10n.errorAttachmentUnavailable,
|
||||||
GroupNeedsMemberError() => l10n.errorGroupNeedsMember,
|
GroupNeedsMemberError() => l10n.errorGroupNeedsMember,
|
||||||
|
NotGroupAdminError() => l10n.errorGroupNotAdmin,
|
||||||
|
LastGroupAdminError() => l10n.errorGroupLastAdmin,
|
||||||
GroupTooLargeError(:final maxMembers) =>
|
GroupTooLargeError(:final maxMembers) =>
|
||||||
l10n.errorGroupMaxMembers(maxMembers),
|
l10n.errorGroupMaxMembers(maxMembers),
|
||||||
MemberNoDeliveryAddressError(:final displayName) =>
|
MemberNoDeliveryAddressError(:final displayName) =>
|
||||||
|
|||||||
@@ -63,9 +63,18 @@ class RelayClient {
|
|||||||
StreamController<RelayConnectionState>.broadcast();
|
StreamController<RelayConnectionState>.broadcast();
|
||||||
final _deliverController = StreamController<pb.Envelope>.broadcast();
|
final _deliverController = StreamController<pb.Envelope>.broadcast();
|
||||||
final _receiptController = StreamController<pb.Receipt>.broadcast();
|
final _receiptController = StreamController<pb.Receipt>.broadcast();
|
||||||
|
final _turnController = StreamController<pb.TurnCredentials>.broadcast();
|
||||||
|
final _prekeysLowController = StreamController<int>.broadcast();
|
||||||
|
|
||||||
var _state = RelayConnectionState.disconnected;
|
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;
|
RelayConnectionState get state => _state;
|
||||||
Stream<RelayConnectionState> get stateStream => _stateController.stream;
|
Stream<RelayConnectionState> get stateStream => _stateController.stream;
|
||||||
|
|
||||||
@@ -99,9 +108,18 @@ class RelayClient {
|
|||||||
required List<SepotOneTimePreKey> oneTimePreKeys,
|
required List<SepotOneTimePreKey> oneTimePreKeys,
|
||||||
required Uint8List deliveryAddress,
|
required Uint8List deliveryAddress,
|
||||||
}) async {
|
}) 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(
|
final resp = await _transport.httpClient().post(
|
||||||
Uri.parse('$baseUrl/v1/register'),
|
Uri.parse('$baseUrl/v1/register'),
|
||||||
headers: {'content-type': 'application/json'},
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-sepot-identity-proof': '$proofTs.${base64Encode(proof)}',
|
||||||
|
},
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
'identity_key': base64Encode(_crypto.identityPublicKey),
|
'identity_key': base64Encode(_crypto.identityPublicKey),
|
||||||
'device_id': 1,
|
'device_id': 1,
|
||||||
@@ -143,6 +161,49 @@ class RelayClient {
|
|||||||
_crypto.bindAccount(accountId);
|
_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).
|
/// GET /v1/keys/{addr} — bundle protistrany (spotřebuje 1 OTP).
|
||||||
Future<SepotPreKeyBundle> fetchBundle(Uint8List deliveryAddress) async {
|
Future<SepotPreKeyBundle> fetchBundle(Uint8List deliveryAddress) async {
|
||||||
final hexAddr = deliveryAddress
|
final hexAddr = deliveryAddress
|
||||||
@@ -279,6 +340,20 @@ class RelayClient {
|
|||||||
_setState(RelayConnectionState.disconnected);
|
_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 {
|
Future<void> _teardownChannel() async {
|
||||||
await _wsSub?.cancel();
|
await _wsSub?.cancel();
|
||||||
_wsSub = null;
|
_wsSub = null;
|
||||||
@@ -312,6 +387,7 @@ class RelayClient {
|
|||||||
_wsSub = channel.stream.listen(
|
_wsSub = channel.stream.listen(
|
||||||
(data) {
|
(data) {
|
||||||
final frame = pb.ServerFrame.fromBuffer(data as List<int>);
|
final frame = pb.ServerFrame.fromBuffer(data as List<int>);
|
||||||
|
_lastFrameAt = DateTime.now();
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
if (frame.hasChallenge()) {
|
if (frame.hasChallenge()) {
|
||||||
final signature = _crypto.sign([
|
final signature = _crypto.sign([
|
||||||
@@ -340,6 +416,14 @@ class RelayClient {
|
|||||||
if (!_receiptController.isClosed) {
|
if (!_receiptController.isClosed) {
|
||||||
_receiptController.add(frame.receipt);
|
_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,
|
onDone: _onDisconnected,
|
||||||
@@ -373,27 +457,46 @@ class RelayClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Odešle sealed-sender obálku. Vrací Receipt (STORED_OFFLINE /
|
/// 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({
|
Future<pb.Receipt> sendEnvelope({
|
||||||
required Uint8List dstAddress,
|
required Uint8List dstAddress,
|
||||||
required Uint8List payload,
|
required Uint8List payload,
|
||||||
|
pb.ContentHint hint = pb.ContentHint.CONTENT_HINT_DEFAULT,
|
||||||
Duration timeout = const Duration(seconds: 10),
|
Duration timeout = const Duration(seconds: 10),
|
||||||
}) async {
|
}) async {
|
||||||
final msgUuid = randomBytes(16);
|
final msgUuid = randomBytes(16);
|
||||||
final receiptFuture = _receiptController.stream
|
final receiptFuture = _receiptController.stream
|
||||||
.firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid))
|
.firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid))
|
||||||
.timeout(timeout);
|
.timeout(timeout);
|
||||||
|
try {
|
||||||
_send(pb.ClientFrame(
|
_send(pb.ClientFrame(
|
||||||
envelope: pb.Envelope(
|
envelope: pb.Envelope(
|
||||||
dstAddress: dstAddress,
|
dstAddress: dstAddress,
|
||||||
msgUuid: msgUuid,
|
msgUuid: msgUuid,
|
||||||
contentHint: pb.ContentHint.CONTENT_HINT_DEFAULT,
|
contentHint: hint,
|
||||||
payload: payload,
|
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;
|
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
|
/// Potvrdí zpracování obálky — relay ji smaže z fronty. Volat až PO
|
||||||
/// uložení zprávy na disk.
|
/// uložení zprávy na disk.
|
||||||
void ack(List<int> msgUuid) {
|
void ack(List<int> msgUuid) {
|
||||||
@@ -437,6 +540,8 @@ class RelayClient {
|
|||||||
await stop();
|
await stop();
|
||||||
_transport.close();
|
_transport.close();
|
||||||
await _stateController.close();
|
await _stateController.close();
|
||||||
|
await _turnController.close();
|
||||||
|
await _prekeysLowController.close();
|
||||||
await _deliverController.close();
|
await _deliverController.close();
|
||||||
await _receiptController.close();
|
await _receiptController.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ import 'package:socks5_proxy/socks_client.dart';
|
|||||||
import 'package:web_socket_channel/io.dart';
|
import 'package:web_socket_channel/io.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
|
||||||
|
/// Jak často posílat WS ping a jak dlouho čekat na pong.
|
||||||
|
///
|
||||||
|
/// Bez pingu se mrtvé spojení tváří jako živé: mobilní NAT zahodí nečinné TCP
|
||||||
|
/// tiše, appka dál hlásí „připojeno" a píše do prázdna — zprávy pak odejdou až
|
||||||
|
/// po restartu appky, všechny naráz. `dart:io` při nastaveném intervalu ping
|
||||||
|
/// posílá a když pong nedorazí včas, spojení zavře → [RelayClient] se
|
||||||
|
/// reconnectne sám. Relay pinguje protisměrně po 30 s (ws.rs PING_INTERVAL).
|
||||||
|
const kWsPingInterval = Duration(seconds: 30);
|
||||||
|
|
||||||
/// Abstrakce nad tím, jak klient otevírá spojení k relayi.
|
/// Abstrakce nad tím, jak klient otevírá spojení k relayi.
|
||||||
abstract class RelayTransport {
|
abstract class RelayTransport {
|
||||||
/// Sdílený HTTP klient pro REST (registrace, prekey bundle, přílohy…).
|
/// Sdílený HTTP klient pro REST (registrace, prekey bundle, přílohy…).
|
||||||
@@ -41,7 +50,8 @@ class DirectTransport implements RelayTransport {
|
|||||||
http.Client httpClient() => _client ??= http.Client();
|
http.Client httpClient() => _client ??= http.Client();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
WebSocketChannel connectWs(Uri url) => WebSocketChannel.connect(url);
|
WebSocketChannel connectWs(Uri url) =>
|
||||||
|
IOWebSocketChannel.connect(url, pingInterval: kWsPingInterval);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void close() {
|
void close() {
|
||||||
@@ -84,7 +94,7 @@ class TorTransport implements RelayTransport {
|
|||||||
@override
|
@override
|
||||||
WebSocketChannel connectWs(Uri url) =>
|
WebSocketChannel connectWs(Uri url) =>
|
||||||
IOWebSocketChannel.connect(_withExplicitPort(url),
|
IOWebSocketChannel.connect(_withExplicitPort(url),
|
||||||
customClient: _sharedHttpClient());
|
customClient: _sharedHttpClient(), pingInterval: kWsPingInterval);
|
||||||
|
|
||||||
/// `Uri` pro `ws`/`wss` nezná výchozí port → `url.port` vrací 0 a SOCKS
|
/// `Uri` pro `ws`/`wss` nezná výchozí port → `url.port` vrací 0 a SOCKS
|
||||||
/// CONNECT by cílil na port 0 (serverError). Doplníme ho podle schématu
|
/// CONNECT by cílil na port 0 (serverError). Doplníme ho podle schématu
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import 'dart:io' show Platform;
|
|||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.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/apns_ios.dart';
|
||||||
import 'package:sepot/core/push/push_notifier.dart';
|
import 'package:sepot/core/push/push_notifier.dart';
|
||||||
import 'package:sepot/core/push/push_service.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
|
/// FCM budíček se zabitou appkou — plugin spouští handler ve vlastním
|
||||||
/// background isolate. Stejný model jako UnifiedPush killed-state:
|
/// background isolate. Stejný model jako UnifiedPush killed-state:
|
||||||
/// DB je zamčená, nic nejde dešifrovat → jen generická notifikace.
|
/// 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')
|
@pragma('vm:entry-point')
|
||||||
Future<void> sepotFcmBackgroundWake(RemoteMessage message) async {
|
Future<void> sepotFcmBackgroundWake(RemoteMessage message) async {
|
||||||
|
if (message.data['k'] == 'call') {
|
||||||
|
await CallRinger.ringFromPush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
await Firebase.initializeApp();
|
await Firebase.initializeApp();
|
||||||
await PushNotifier.showWakeNotification();
|
await PushNotifier.showWakeNotification();
|
||||||
}
|
}
|
||||||
@@ -45,18 +52,32 @@ class PushBootstrap {
|
|||||||
|
|
||||||
static const _pendingEndpointKey = 'pending_push_endpoint';
|
static const _pendingEndpointKey = 'pending_push_endpoint';
|
||||||
static const _modeKey = 'push_mode'; // 'off' | 'up' | 'fcm'
|
static const _modeKey = 'push_mode'; // 'off' | 'up' | 'fcm'
|
||||||
|
static const _registrationFailedKey = 'push_registration_failed';
|
||||||
static const _store = FlutterSecureStorage(
|
static const _store = FlutterSecureStorage(
|
||||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Režim pushů žije v OS keystore (NE v SQLCipher DB) — musí být
|
/// 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.
|
/// čitelný před unlockem, aby main() věděl, zda registrovat FCM.
|
||||||
static Future<String> readMode() async =>
|
static Future<String> readMode() async => await readStoredMode() ?? 'off';
|
||||||
await _store.read(key: _modeKey) ?? '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) =>
|
static Future<void> writeMode(String mode) =>
|
||||||
_store.write(key: _modeKey, value: mode);
|
_store.write(key: _modeKey, value: mode);
|
||||||
|
|
||||||
|
/// Odmítl distributor registraci? Selhání se dřív jen spolklo, takže
|
||||||
|
/// uživatel měl v Nastavení „push zapnutý", ale budíčky nechodily a
|
||||||
|
/// zprávy čekaly, než appku otevře. Nastavení si to přečte a řekne to.
|
||||||
|
static Future<bool> registrationFailed() async =>
|
||||||
|
await _store.read(key: _registrationFailedKey) == '1';
|
||||||
|
|
||||||
|
static Future<void> _setRegistrationFailed(bool failed) => failed
|
||||||
|
? _store.write(key: _registrationFailedKey, value: '1')
|
||||||
|
: _store.delete(key: _registrationFailedKey);
|
||||||
|
|
||||||
static Future<void> init(List<String> args) async {
|
static Future<void> init(List<String> args) async {
|
||||||
backgroundStart = args.contains('--unifiedpush-bg');
|
backgroundStart = args.contains('--unifiedpush-bg');
|
||||||
|
|
||||||
@@ -66,10 +87,13 @@ class PushBootstrap {
|
|||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
ApnsBridge.onWake = _wake;
|
ApnsBridge.onWake = _wake;
|
||||||
ApnsBridge.ensureHandler();
|
ApnsBridge.ensureHandler();
|
||||||
|
// Zvonění z VoIP pushe bez nabídky nesmí zvonit celých 45 s.
|
||||||
|
unawaited(CallRinger.expireStalePushRings());
|
||||||
} else {
|
} else {
|
||||||
await UnifiedPush.initialize(
|
await UnifiedPush.initialize(
|
||||||
onNewEndpoint: _onNewEndpoint,
|
onNewEndpoint: _onNewEndpoint,
|
||||||
onRegistrationFailed: (_, _) {},
|
onRegistrationFailed: (_, _) =>
|
||||||
|
unawaited(_setRegistrationFailed(true)),
|
||||||
onUnregistered: _onUnregistered,
|
onUnregistered: _onUnregistered,
|
||||||
onMessage: _onMessage,
|
onMessage: _onMessage,
|
||||||
);
|
);
|
||||||
@@ -114,6 +138,7 @@ class PushBootstrap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
|
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
|
||||||
|
unawaited(_setRegistrationFailed(false)); // registrace se povedla
|
||||||
final s = _service;
|
final s = _service;
|
||||||
if (s != null) {
|
if (s != null) {
|
||||||
s.handleNewEndpoint(endpoint.url);
|
s.handleNewEndpoint(endpoint.url);
|
||||||
|
|||||||
@@ -13,8 +13,11 @@
|
|||||||
/// killed-state startu `--unifiedpush-bg` bez UI); tahle třída je
|
/// killed-state startu `--unifiedpush-bg` bez UI); tahle třída je
|
||||||
/// most bootstrap ↔ messaging + API pro nastavení.
|
/// most bootstrap ↔ messaging + API pro nastavení.
|
||||||
///
|
///
|
||||||
/// FCM „fast mode" (opt-in) je druhá cesta (viz [setModeFcm]); default je
|
/// Výchozí režim na Androidu vybírá [restoreMode] při prvním startu: s
|
||||||
/// degooglovaný UnifiedPush.
|
/// 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;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
@@ -24,6 +27,7 @@ import 'dart:typed_data';
|
|||||||
|
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
import 'package:sepot/core/calls/call_ringer.dart';
|
||||||
import 'package:sepot/core/push/apns_ios.dart';
|
import 'package:sepot/core/push/apns_ios.dart';
|
||||||
import 'package:sepot/core/push/push_bootstrap.dart';
|
import 'package:sepot/core/push/push_bootstrap.dart';
|
||||||
import 'package:sepot/core/push/push_notifier.dart';
|
import 'package:sepot/core/push/push_notifier.dart';
|
||||||
@@ -48,7 +52,15 @@ class PushService {
|
|||||||
/// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.)
|
/// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.)
|
||||||
bool get canWake => _messaging.isStarted;
|
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) {
|
void handleNewEndpoint(String url) {
|
||||||
_messaging.registerPush(_registration(
|
_messaging.registerPush(_registration(
|
||||||
@@ -80,11 +92,25 @@ class PushService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _registerApnsToken(String token) {
|
String? _apnsToken;
|
||||||
_messaging.registerPush(_registration(
|
|
||||||
|
/// APNs token pro zprávy + VoIP token pro hovory v jedné registraci
|
||||||
|
/// (relay drží obojí u zařízení).
|
||||||
|
Future<void> _registerApnsToken(String token) async {
|
||||||
|
_apnsToken = token;
|
||||||
|
final voip = await CallRinger.voipToken();
|
||||||
|
final reg = _registration(
|
||||||
pb.RegisterPush_Platform.PLATFORM_APNS,
|
pb.RegisterPush_Platform.PLATFORM_APNS,
|
||||||
Uint8List.fromList(utf8.encode(token)),
|
Uint8List.fromList(utf8.encode(token)),
|
||||||
));
|
);
|
||||||
|
if (voip != null) reg.voipToken = utf8.encode(voip);
|
||||||
|
_messaging.registerPush(reg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PushKit vydal (nový) VoIP token — přeregistrovat, pokud už APNs běží.
|
||||||
|
Future<void> refreshVoipToken() async {
|
||||||
|
final token = _apnsToken;
|
||||||
|
if (token != null) await _registerApnsToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
|
/// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
|
||||||
@@ -112,10 +138,14 @@ class PushService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Seznam nainstalovaných distributorů (pro výběr v nastavení).
|
/// 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ý).
|
/// 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.
|
/// Vybere konkrétního distributora a zaregistruje se u něj.
|
||||||
Future<void> useDistributor(String distributor) async {
|
Future<void> useDistributor(String distributor) async {
|
||||||
@@ -132,7 +162,7 @@ class PushService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> get isEnabled async =>
|
Future<bool> get isEnabled async =>
|
||||||
(await UnifiedPush.getDistributor()) != null;
|
Platform.isAndroid && (await UnifiedPush.getDistributor()) != null;
|
||||||
|
|
||||||
// ── Režimy (off / up / fcm) ─────────────────────────────────────────
|
// ── Režimy (off / up / fcm) ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -141,9 +171,14 @@ class PushService {
|
|||||||
Future<String> currentMode() async {
|
Future<String> currentMode() async {
|
||||||
final mode = await PushBootstrap.readMode();
|
final mode = await PushBootstrap.readMode();
|
||||||
if (mode != 'off') return mode;
|
if (mode != 'off') return mode;
|
||||||
|
if (!Platform.isAndroid) return mode;
|
||||||
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
|
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Odmítl distributor poslední registraci? Nastavení to ukáže — jinak by
|
||||||
|
/// push vypadal zapnutý, ale budíčky by nechodily.
|
||||||
|
Future<bool> registrationFailed() => PushBootstrap.registrationFailed();
|
||||||
|
|
||||||
Future<void> setModeOff() async {
|
Future<void> setModeOff() async {
|
||||||
await _teardownFcm(deleteToken: true);
|
await _teardownFcm(deleteToken: true);
|
||||||
try {
|
try {
|
||||||
@@ -162,8 +197,8 @@ class PushService {
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// FCM fast mode — OPT-IN: registrační token zařízení jde Googlu
|
/// FCM: registrační token zařízení jde Googlu (obsah budíčků nikdy,
|
||||||
/// (obsah budíčků nikdy, ťuk je konstantní `{"v":1}`).
|
/// ťuk je konstantní `{"v":1}`).
|
||||||
Future<void> setModeFcm() async {
|
Future<void> setModeFcm() async {
|
||||||
await init();
|
await init();
|
||||||
try {
|
try {
|
||||||
@@ -218,6 +253,10 @@ class PushService {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (await PushBootstrap.readStoredMode() == null) {
|
||||||
|
await _chooseDefaultMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
switch (await currentMode()) {
|
switch (await currentMode()) {
|
||||||
case 'up':
|
case 'up':
|
||||||
await enable();
|
await enable();
|
||||||
@@ -231,4 +270,23 @@ class PushService {
|
|||||||
await init(); // aspoň attach (killed-state notifikace)
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
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/constants/app_constants.dart';
|
||||||
import 'package:sepot/core/errors.dart';
|
import 'package:sepot/core/errors.dart';
|
||||||
|
|
||||||
@@ -67,12 +68,16 @@ class ReportService {
|
|||||||
throw const ReportEmptyDescriptionError();
|
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>{
|
final body = <String, dynamic>{
|
||||||
'kategorie': category.wire,
|
'kategorie': category.wire,
|
||||||
'popis': popis,
|
'popis': popis,
|
||||||
'zdroj': 'app',
|
'zdroj': 'app',
|
||||||
'platforma': Platform.operatingSystem,
|
'platforma': Platform.operatingSystem,
|
||||||
'verze': AppConstants.appVersion,
|
'verze': ?verze,
|
||||||
if (reportedFingerprint != null && reportedFingerprint.isNotEmpty)
|
if (reportedFingerprint != null && reportedFingerprint.isNotEmpty)
|
||||||
'nahlasenyOtisk': reportedFingerprint,
|
'nahlasenyOtisk': reportedFingerprint,
|
||||||
if (replyTo != null && replyTo.trim().isNotEmpty)
|
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.
|
/// sealed-sender obálku) — držet výrazně pod tím.
|
||||||
static const int thumbMaxBytes = 48 * 1024;
|
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).
|
/// 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
|
/// Vrací null, když bajty nejsou dekódovatelný obrázek — volající je
|
||||||
/// pak pošle jako dokument.
|
/// pak pošle jako dokument.
|
||||||
@@ -51,7 +64,13 @@ class ImageProcessor {
|
|||||||
String? mimeHint,
|
String? mimeHint,
|
||||||
}) {
|
}) {
|
||||||
return Isolate.run(() {
|
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;
|
if (decoded == null) return null;
|
||||||
|
|
||||||
// GIF beze změny — re-encode by zabil animaci
|
// 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,
|
// Samotný alfa kanál nestačí — plně krytý RGBA PNG (screenshot,
|
||||||
// canvas render z editoru fotek) je jako JPEG několikanásobně menší.
|
// canvas render z editoru fotek) je jako JPEG několikanásobně menší.
|
||||||
final keepPng = mimeHint == 'image/png' && _hasTransparency(decoded);
|
final keepPng = mimeHint == 'image/png' && _hasTransparency(decoded);
|
||||||
|
stripMetadata(image);
|
||||||
final bytes = keepPng
|
final bytes = keepPng
|
||||||
? Uint8List.fromList(img.encodePng(image))
|
? Uint8List.fromList(img.encodePng(image))
|
||||||
: Uint8List.fromList(img.encodeJpg(image, quality: jpegQuality));
|
: Uint8List.fromList(img.encodeJpg(image, quality: jpegQuality));
|
||||||
@@ -101,6 +121,15 @@ class ImageProcessor {
|
|||||||
return false;
|
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) {
|
static Uint8List _thumbnail(img.Image source) {
|
||||||
var thumb = max(source.width, source.height) > thumbDimension
|
var thumb = max(source.width, source.height) > thumbDimension
|
||||||
? (source.width >= source.height
|
? (source.width >= source.height
|
||||||
@@ -114,6 +143,7 @@ class ImageProcessor {
|
|||||||
img.compositeImage(flat, thumb);
|
img.compositeImage(flat, thumb);
|
||||||
thumb = flat;
|
thumb = flat;
|
||||||
}
|
}
|
||||||
|
stripMetadata(thumb);
|
||||||
var quality = 70;
|
var quality = 70;
|
||||||
var encoded = Uint8List.fromList(img.encodeJpg(thumb, quality: quality));
|
var encoded = Uint8List.fromList(img.encodeJpg(thumb, quality: quality));
|
||||||
// Náhled je jen placeholder, než dorazí ostrá fotka — kvalitu držíme
|
// 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).
|
/// při onboardingu, který DB rovnou otevře).
|
||||||
|
|
||||||
/// Nátlakový PIN je shodný se skutečným — nelze nastavit.
|
/// 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 {
|
class DecoyPinConflict implements Exception {
|
||||||
const DecoyPinConflict();
|
const DecoyPinConflict();
|
||||||
@override
|
@override
|
||||||
@@ -51,6 +61,13 @@ class SecureStorageService {
|
|||||||
static const _legacyPinHashKey = 'pin_hash';
|
static const _legacyPinHashKey = 'pin_hash';
|
||||||
|
|
||||||
static const _decoyWrappedDekKey = 'wrapped_dek_decoy';
|
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 FlutterSecureStorage _secureStorage;
|
||||||
final String? _dbPathOverride;
|
final String? _dbPathOverride;
|
||||||
@@ -60,12 +77,24 @@ class SecureStorageService {
|
|||||||
bool _decoyMode = false;
|
bool _decoyMode = false;
|
||||||
|
|
||||||
/// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB.
|
/// 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;
|
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})
|
SecureStorageService({String? dbPath})
|
||||||
: _secureStorage = const FlutterSecureStorage(
|
: _secureStorage = const FlutterSecureStorage(
|
||||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
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;
|
_dbPathOverride = dbPath;
|
||||||
|
|
||||||
@@ -98,21 +127,59 @@ class SecureStorageService {
|
|||||||
if (wrapped == null) {
|
if (wrapped == null) {
|
||||||
throw StateError('No PIN set — use unlockWithoutPin');
|
throw StateError('No PIN set — use unlockWithoutPin');
|
||||||
}
|
}
|
||||||
try {
|
final locked = await pinLockRemaining();
|
||||||
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: pin);
|
if (locked != null) throw PinLockedException(locked);
|
||||||
await _openDb(dek);
|
|
||||||
} on WrongPinException {
|
|
||||||
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
|
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
|
||||||
if (decoy == null) rethrow;
|
try {
|
||||||
// Když ani decoy nesedí, unwrap vyhodí WrongPinException dál.
|
// Oba bloby jednou derivací (sdílená sůl) — čas odemčení neprozradí,
|
||||||
final dek = await DekWrap.unwrap(wrapped: decoy, pin: pin);
|
// který PIN padl.
|
||||||
await _openDb(dek, decoy: true);
|
final (index, dek) = await DekWrap.unwrapFirstMatching(
|
||||||
|
wrapped: [wrapped, ?decoy],
|
||||||
|
pin: pin,
|
||||||
|
);
|
||||||
|
await _openDb(dek, decoy: index == 1);
|
||||||
|
await _resetPinFailures();
|
||||||
|
} on WrongPinException {
|
||||||
|
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.
|
/// 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.
|
/// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře.
|
||||||
Future<void> enablePin(String pin) async {
|
Future<void> enablePin(String pin) async {
|
||||||
|
_requireRealSession();
|
||||||
final dek = _dek ?? await _getOrCreateRawDek();
|
final dek = _dek ?? await _getOrCreateRawDek();
|
||||||
final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
|
final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
|
||||||
await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
|
await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
|
||||||
@@ -124,6 +191,7 @@ class SecureStorageService {
|
|||||||
|
|
||||||
/// Vypne PIN: DEK se vrátí raw do OS keystore.
|
/// Vypne PIN: DEK se vrátí raw do OS keystore.
|
||||||
Future<void> disablePin() async {
|
Future<void> disablePin() async {
|
||||||
|
_requireRealSession();
|
||||||
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
||||||
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
|
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
|
||||||
await _secureStorage.delete(key: _wrappedDekKey);
|
await _secureStorage.delete(key: _wrappedDekKey);
|
||||||
@@ -151,6 +219,7 @@ class SecureStorageService {
|
|||||||
required String oldPin,
|
required String oldPin,
|
||||||
required String newPin,
|
required String newPin,
|
||||||
}) async {
|
}) async {
|
||||||
|
_requireRealSession();
|
||||||
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||||
if (wrapped == null) throw StateError('No PIN set');
|
if (wrapped == null) throw StateError('No PIN set');
|
||||||
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin);
|
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin);
|
||||||
@@ -165,7 +234,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);
|
await _secureStorage.write(key: _wrappedDekKey, value: rewrapped);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +249,7 @@ class SecureStorageService {
|
|||||||
/// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy
|
/// 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.
|
/// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit.
|
||||||
Future<void> enableDecoyPin(String decoyPin) async {
|
Future<void> enableDecoyPin(String decoyPin) async {
|
||||||
|
_requireRealSession();
|
||||||
final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
|
final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||||
if (realWrapped == null) {
|
if (realWrapped == null) {
|
||||||
throw StateError('Set a regular PIN first');
|
throw StateError('Set a regular PIN first');
|
||||||
@@ -193,7 +267,9 @@ class SecureStorageService {
|
|||||||
// Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB
|
// Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB
|
||||||
final decoyPath = await _dbPath(decoy: true);
|
final decoyPath = await _dbPath(decoy: true);
|
||||||
CryptoDb.open(path: decoyPath, dek: decoyDek).close();
|
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);
|
await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,11 +279,20 @@ class SecureStorageService {
|
|||||||
|
|
||||||
/// Zruší nátlakový PIN a smaže decoy DB.
|
/// Zruší nátlakový PIN a smaže decoy DB.
|
||||||
Future<void> disableDecoyPin() async {
|
Future<void> disableDecoyPin() async {
|
||||||
|
_requireRealSession();
|
||||||
await _secureStorage.delete(key: _decoyWrappedDekKey);
|
await _secureStorage.delete(key: _decoyWrappedDekKey);
|
||||||
|
await _deleteDbFiles(await _dbPath(decoy: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteDbFiles(String path) async {
|
||||||
|
for (final suffix in ['', '-wal', '-shm']) {
|
||||||
try {
|
try {
|
||||||
final f = File(await _dbPath(decoy: true));
|
final f = File('$path$suffix');
|
||||||
if (f.existsSync()) f.deleteSync();
|
if (f.existsSync()) f.deleteSync();
|
||||||
} catch (_) {}
|
} catch (_) {
|
||||||
|
// best-effort úklid
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme
|
/// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme
|
||||||
@@ -222,6 +307,8 @@ class SecureStorageService {
|
|||||||
_db?.close();
|
_db?.close();
|
||||||
_db = null;
|
_db = null;
|
||||||
_dek = null;
|
_dek = null;
|
||||||
|
_decoyMode = false;
|
||||||
|
_decoyIdentity = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
|
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
|
||||||
@@ -293,6 +380,11 @@ class SecureStorageService {
|
|||||||
|
|
||||||
/// Načti lokální identitu
|
/// Načti lokální identitu
|
||||||
Future<LocalIdentity?> loadIdentity() async {
|
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);
|
final json = await _secureStorage.read(key: _identityKey);
|
||||||
if (json == null) return null;
|
if (json == null) return null;
|
||||||
try {
|
try {
|
||||||
@@ -320,6 +412,7 @@ class SecureStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<String>?> loadSeedPhrase() async {
|
Future<List<String>?> loadSeedPhrase() async {
|
||||||
|
if (_decoyMode) return null;
|
||||||
final json = await _secureStorage.read(key: _seedPhraseKey);
|
final json = await _secureStorage.read(key: _seedPhraseKey);
|
||||||
if (json == null) return null;
|
if (json == null) return null;
|
||||||
return (jsonDecode(json) as List).cast<String>();
|
return (jsonDecode(json) as List).cast<String>();
|
||||||
@@ -331,6 +424,7 @@ class SecureStorageService {
|
|||||||
/// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings).
|
/// 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.
|
/// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním.
|
||||||
Future<void> enableBiometrics() async {
|
Future<void> enableBiometrics() async {
|
||||||
|
_requireRealSession();
|
||||||
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
||||||
await _secureStorage.write(
|
await _secureStorage.write(
|
||||||
key: _biometricDekKey,
|
key: _biometricDekKey,
|
||||||
@@ -342,6 +436,7 @@ class SecureStorageService {
|
|||||||
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
|
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
|
||||||
/// PIN-only model: DEK existuje jen za Argon2id).
|
/// PIN-only model: DEK existuje jen za Argon2id).
|
||||||
Future<void> disableBiometrics() async {
|
Future<void> disableBiometrics() async {
|
||||||
|
_requireRealSession();
|
||||||
await _secureStorage.delete(key: _biometricDekKey);
|
await _secureStorage.delete(key: _biometricDekKey);
|
||||||
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
|
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
|
||||||
}
|
}
|
||||||
@@ -442,11 +537,15 @@ class SecureStorageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> addMessage(String contactId, Message message) async {
|
/// Přidá zprávu do konverzace. Vrací false, když už tam zpráva s tímhle id
|
||||||
|
/// je (opakované doručení) — volající pak přeskočí navazující kroky jako
|
||||||
|
/// doručenku nebo stahování přílohy.
|
||||||
|
Future<bool> addMessage(String contactId, Message message) async {
|
||||||
var conv = await loadConversation(contactId);
|
var conv = await loadConversation(contactId);
|
||||||
conv ??= Conversation(contactId: contactId);
|
conv ??= Conversation(contactId: contactId);
|
||||||
conv.addMessage(message);
|
if (!conv.addMessage(message)) return false;
|
||||||
await saveConversation(conv);
|
await saveConversation(conv);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Založí prázdnou konverzaci, pokud ještě není (nová skupina musí být
|
/// Založí prázdnou konverzaci, pokud ještě není (nová skupina musí být
|
||||||
@@ -471,24 +570,29 @@ class SecureStorageService {
|
|||||||
if (!isUnlocked) return false;
|
if (!isUnlocked) return false;
|
||||||
var changed = false;
|
var changed = false;
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
for (final conv in await loadConversations()) {
|
bool isExpired(Message m) =>
|
||||||
final expired = conv.messages
|
|
||||||
.where((m) =>
|
|
||||||
m.disappearAfterSeconds != null &&
|
m.disappearAfterSeconds != null &&
|
||||||
m.timestamp
|
m.timestamp
|
||||||
.add(Duration(seconds: m.disappearAfterSeconds!))
|
.add(Duration(seconds: m.disappearAfterSeconds!))
|
||||||
.isBefore(now))
|
.isBefore(now);
|
||||||
.toList();
|
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;
|
if (expired.isEmpty) continue;
|
||||||
changed = true;
|
changed = true;
|
||||||
|
conv.messages.removeWhere(isExpired);
|
||||||
|
await saveConversation(conv);
|
||||||
for (final m in expired) {
|
for (final m in expired) {
|
||||||
final blobId = m.attachment?.blobId;
|
final blobId = m.attachment?.blobId;
|
||||||
if (blobId != null) {
|
if (blobId != null) {
|
||||||
await AttachmentStore().delete(blobId);
|
await AttachmentStore().delete(blobId);
|
||||||
}
|
}
|
||||||
conv.messages.remove(m);
|
|
||||||
}
|
}
|
||||||
await saveConversation(conv);
|
|
||||||
}
|
}
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
@@ -508,12 +612,19 @@ class SecureStorageService {
|
|||||||
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
|
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
|
||||||
/// i lokální bloby příloh.
|
/// i lokální bloby příloh.
|
||||||
Future<void> deleteAll() async {
|
Future<void> deleteAll() async {
|
||||||
final path = await _dbPath();
|
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();
|
lock();
|
||||||
for (final suffix in ['', '-wal', '-shm']) {
|
await _deleteDbFiles(decoyPath);
|
||||||
final f = File('$path$suffix');
|
return;
|
||||||
if (f.existsSync()) f.deleteSync();
|
|
||||||
}
|
}
|
||||||
|
final path = await _dbPath();
|
||||||
|
final decoyPath = await _dbPath(decoy: true);
|
||||||
|
lock();
|
||||||
|
await _deleteDbFiles(path);
|
||||||
|
await _deleteDbFiles(decoyPath);
|
||||||
await AttachmentStore().deleteAll();
|
await AttachmentStore().deleteAll();
|
||||||
await _secureStorage.deleteAll();
|
await _secureStorage.deleteAll();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class SepotColors {
|
|||||||
// Text
|
// Text
|
||||||
static const Color textPrimary = Color(0xFFF0EEE8); // mléko
|
static const Color textPrimary = Color(0xFFF0EEE8); // mléko
|
||||||
static const Color textSecondary = Color(0xFF9AA0B5); // mlha
|
static const Color textSecondary = Color(0xFF9AA0B5); // mlha
|
||||||
|
static const Color textEmphasis = Color(0xFFC4C8D6); // mlha, zvýrazněná
|
||||||
static const Color textMuted = Color(0xFF626880);
|
static const Color textMuted = Color(0xFF626880);
|
||||||
|
|
||||||
// Akcentní barvy
|
// Akcentní barvy
|
||||||
@@ -39,12 +40,57 @@ class SepotColors {
|
|||||||
// Bubliny zpráv — odeslaná teplá (ember), přijatá chladná (povrch)
|
// Bubliny zpráv — odeslaná teplá (ember), přijatá chladná (povrch)
|
||||||
static const Color bubbleSent = Color(0xFFF0795A);
|
static const Color bubbleSent = Color(0xFFF0795A);
|
||||||
static const Color bubbleReceived = Color(0xFF191C28);
|
static const Color bubbleReceived = Color(0xFF191C28);
|
||||||
|
|
||||||
|
/// Plovoucí lišty nad obsahem (hlavička chatu, pole pro psaní, navigace).
|
||||||
|
/// Průhlednost nese rozmazání pod sebou — bez něj by působila jako chyba.
|
||||||
|
static const Color glass = Color(0xC712141D);
|
||||||
|
static const Color glassBorder = Color(0x0FFFFFFF);
|
||||||
|
|
||||||
|
/// Podklad hlavičky profilu — o tón teplejší než noc, ať avatar vystoupí.
|
||||||
|
static const Color hero = Color(0xFF14131A);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tóny avatarů: tlumený podklad + světlejší inkoust téže barvy. Člověk
|
||||||
|
/// dostane tón podle otisku klíče, takže má všude a u všech stejnou barvu.
|
||||||
|
class SepotTint {
|
||||||
|
const SepotTint(this.background, this.ink);
|
||||||
|
|
||||||
|
final Color background;
|
||||||
|
final Color ink;
|
||||||
|
|
||||||
|
static const all = [
|
||||||
|
SepotTint(Color(0xFF3A2A33), Color(0xFFF2A58F)), // ember
|
||||||
|
SepotTint(Color(0xFF22352B), Color(0xFFA6CFB2)), // sage
|
||||||
|
SepotTint(Color(0xFF1E2A3A), Color(0xFF9FB6D6)), // mist
|
||||||
|
SepotTint(Color(0xFF3A3222), Color(0xFFE6C27A)), // jantar
|
||||||
|
SepotTint(Color(0xFF2C2638), Color(0xFFC3B2E0)), // levandule
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Stabilní napříč spuštěními — String.hashCode to Dart nezaručuje.
|
||||||
|
static SepotTint of(String seed) {
|
||||||
|
var h = 0x811c9dc5;
|
||||||
|
for (final c in seed.codeUnits) {
|
||||||
|
h = ((h ^ c) * 0x01000193) & 0xFFFFFFFF;
|
||||||
|
}
|
||||||
|
return all[h % all.length];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Témata aplikace
|
/// Témata aplikace
|
||||||
class SepotTheme {
|
class SepotTheme {
|
||||||
SepotTheme._();
|
SepotTheme._();
|
||||||
|
|
||||||
|
/// Velký nadpis hlavních karet (Chaty, Kontakty, Nastavení).
|
||||||
|
static const largeTitle = TextStyle(
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: -0.6,
|
||||||
|
color: SepotColors.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Výška lišty s velkým nadpisem.
|
||||||
|
static const largeTitleBarHeight = 76.0;
|
||||||
|
|
||||||
static ThemeData get dark {
|
static ThemeData get dark {
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/// Porovnávání textu pro hledání: bez ohledu na velikost písmen a diakritiku.
|
||||||
|
/// Lidé na mobilu často píšou „prectu“ místo „přečtu“ — hledání to má najít.
|
||||||
|
library;
|
||||||
|
|
||||||
|
const _map = {
|
||||||
|
'á': 'a', 'ä': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'ě': 'e', 'ë': 'e',
|
||||||
|
'í': 'i', 'ľ': 'l', 'ĺ': 'l', 'ň': 'n', 'ó': 'o', 'ô': 'o', 'ö': 'o',
|
||||||
|
'ř': 'r', 'ŕ': 'r', 'š': 's', 'ť': 't', 'ú': 'u', 'ů': 'u', 'ü': 'u',
|
||||||
|
'ý': 'y', 'ž': 'z', 'ß': 'ss',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Malá písmena bez diakritiky. Délka se může změnit jen u „ß“, proto
|
||||||
|
/// pozice shod počítej přes [foldedIndexes], ne přes indexy výsledku.
|
||||||
|
String fold(String input) {
|
||||||
|
final out = StringBuffer();
|
||||||
|
for (final ch in input.toLowerCase().split('')) {
|
||||||
|
out.write(_map[ch] ?? ch);
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Začátky všech výskytů [query] v [text] (indexy do PŮVODNÍHO textu).
|
||||||
|
/// Znaky se skládají po jednom, takže indexy sedí i pro zvýraznění.
|
||||||
|
List<int> foldedIndexes(String text, String query) {
|
||||||
|
final q = fold(query);
|
||||||
|
if (q.isEmpty) return const [];
|
||||||
|
final chars = text.split('');
|
||||||
|
final folded = chars.map(fold).toList();
|
||||||
|
final hits = <int>[];
|
||||||
|
for (var i = 0; i < chars.length; i++) {
|
||||||
|
var j = i;
|
||||||
|
var matched = '';
|
||||||
|
while (j < chars.length && matched.length < q.length) {
|
||||||
|
matched += folded[j];
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
if (matched.startsWith(q)) hits.add(i);
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:local_auth/local_auth.dart';
|
import 'package:local_auth/local_auth.dart';
|
||||||
import 'package:sepot/core/crypto/dek_wrap.dart';
|
import 'package:sepot/core/crypto/dek_wrap.dart';
|
||||||
|
import 'package:sepot/core/storage/secure_storage.dart' show PinLockedException;
|
||||||
import 'package:sepot/core/l10n/l10n.dart';
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
import 'package:sepot/core/theme/app_theme.dart';
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
import 'package:sepot/providers/messaging_provider.dart';
|
import 'package:sepot/providers/messaging_provider.dart';
|
||||||
import 'package:sepot/providers/push_provider.dart';
|
import 'package:sepot/providers/push_provider.dart';
|
||||||
|
import 'package:sepot/providers/settings_provider.dart';
|
||||||
import 'package:sepot/providers/storage_provider.dart';
|
import 'package:sepot/providers/storage_provider.dart';
|
||||||
import 'package:sepot/widgets/sepot_logo.dart';
|
import 'package:sepot/widgets/sepot_logo.dart';
|
||||||
|
|
||||||
@@ -41,7 +43,10 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
|||||||
try {
|
try {
|
||||||
final ok = await LocalAuthentication().authenticate(
|
final ok = await LocalAuthentication().authenticate(
|
||||||
localizedReason: unlockReason,
|
localizedReason: unlockReason,
|
||||||
options: const AuthenticationOptions(stickyAuth: true),
|
// biometricOnly: zámek telefonu (PIN/vzor zařízení) nesmí Šepot
|
||||||
|
// odemknout — obešel by Argon2id ochranu DEK slabším faktorem.
|
||||||
|
options: const AuthenticationOptions(
|
||||||
|
stickyAuth: true, biometricOnly: true),
|
||||||
);
|
);
|
||||||
if (!ok) return; // uživatel zrušil → zůstává PIN
|
if (!ok) return; // uživatel zrušil → zůstává PIN
|
||||||
await storage.unlockWithBiometrics();
|
await storage.unlockWithBiometrics();
|
||||||
@@ -222,6 +227,9 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
|||||||
void _restartMessagingAfterUnlock() {
|
void _restartMessagingAfterUnlock() {
|
||||||
ref.invalidate(messagingStartProvider);
|
ref.invalidate(messagingStartProvider);
|
||||||
ref.invalidate(pushInitProvider);
|
ref.invalidate(pushInitProvider);
|
||||||
|
// Zabezpečení obrazovky se čte z DB — před odemčením provider nic
|
||||||
|
// neaplikoval, teď má skutečnou hodnotu.
|
||||||
|
ref.invalidate(screenSecurityProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _verifyPin() async {
|
Future<void> _verifyPin() async {
|
||||||
@@ -234,12 +242,22 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
|||||||
_restartMessagingAfterUnlock();
|
_restartMessagingAfterUnlock();
|
||||||
if (mounted) context.go('/chats');
|
if (mounted) context.go('/chats');
|
||||||
} on WrongPinException {
|
} on WrongPinException {
|
||||||
|
final locked = await ref.read(storageProvider).pinLockRemaining();
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = context.l10n.pinWrong2;
|
_error = locked == null
|
||||||
|
? context.l10n.pinWrong2
|
||||||
|
: context.l10n.pinLockedFor(locked.inSeconds);
|
||||||
_pin = '';
|
_pin = '';
|
||||||
_isVerifying = false;
|
_isVerifying = false;
|
||||||
});
|
});
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback.heavyImpact();
|
||||||
|
} on PinLockedException catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = context.l10n.pinLockedFor(e.remaining.inSeconds);
|
||||||
|
_pin = '';
|
||||||
|
_isVerifying = false;
|
||||||
|
});
|
||||||
} on StateError {
|
} on StateError {
|
||||||
// Wrapped DEK neexistuje — nekonzistentní stav, zpět na onboarding
|
// Wrapped DEK neexistuje — nekonzistentní stav, zpět na onboarding
|
||||||
if (mounted) context.go('/onboarding');
|
if (mounted) context.go('/onboarding');
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sepot/features/calls/call_screen.dart';
|
||||||
|
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||||
|
import 'package:sepot/models/call_log.dart';
|
||||||
|
|
||||||
|
/// Ikona a text záznamu hovoru — sdílí chat i seznam chatů, ať se
|
||||||
|
/// formulace nerozejdou.
|
||||||
|
({IconData icon, String text, bool alert}) callLogLabel(
|
||||||
|
AppLocalizations l10n, CallLog log) {
|
||||||
|
final icon = log.video ? Icons.videocam_rounded : Icons.call_rounded;
|
||||||
|
return switch (log.outcome) {
|
||||||
|
CallOutcome.completed => (
|
||||||
|
icon: icon,
|
||||||
|
text: log.video
|
||||||
|
? l10n.callLogVideo(formatCallDuration(log.durationSeconds))
|
||||||
|
: l10n.callLogVoice(formatCallDuration(log.durationSeconds)),
|
||||||
|
alert: false,
|
||||||
|
),
|
||||||
|
CallOutcome.missed => (
|
||||||
|
icon: Icons.phone_missed_rounded,
|
||||||
|
text: l10n.callLogMissed,
|
||||||
|
alert: true,
|
||||||
|
),
|
||||||
|
CallOutcome.unanswered => (
|
||||||
|
icon: icon,
|
||||||
|
text: l10n.callLogUnanswered,
|
||||||
|
alert: false,
|
||||||
|
),
|
||||||
|
CallOutcome.rejected => (
|
||||||
|
icon: Icons.call_end_rounded,
|
||||||
|
text: l10n.callLogRejected,
|
||||||
|
alert: false,
|
||||||
|
),
|
||||||
|
CallOutcome.busy => (icon: icon, text: l10n.callLogBusy, alert: false),
|
||||||
|
CallOutcome.failed => (
|
||||||
|
icon: Icons.error_outline_rounded,
|
||||||
|
text: l10n.callLogFailed,
|
||||||
|
alert: true,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:sepot/core/calls/call_controller.dart';
|
||||||
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
|
import 'package:sepot/models/call_log.dart';
|
||||||
|
import 'package:sepot/widgets/avatar.dart';
|
||||||
|
import 'package:sepot/widgets/glass.dart';
|
||||||
|
|
||||||
|
/// Obrazovka hovoru: vytáčení, zvonění, spojování i hovor samotný.
|
||||||
|
/// Zavře se sama, když hovor skončí.
|
||||||
|
class CallScreen extends ConsumerStatefulWidget {
|
||||||
|
const CallScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CallScreen> createState() => _CallScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CallScreenState extends ConsumerState<CallScreen> {
|
||||||
|
// Počítadlo délky hovoru — překreslení jednou za sekundu stačí.
|
||||||
|
Timer? _tick;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tick?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _status(CallState call) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
switch (call.phase) {
|
||||||
|
case CallPhase.outgoing:
|
||||||
|
return l10n.callStatusRinging;
|
||||||
|
case CallPhase.incoming:
|
||||||
|
return call.video ? l10n.callIncomingVideo : l10n.callIncomingVoice;
|
||||||
|
case CallPhase.connecting:
|
||||||
|
return l10n.callStatusConnecting;
|
||||||
|
case CallPhase.active:
|
||||||
|
final d = DateTime.now().difference(call.connectedAt!);
|
||||||
|
return formatCallDuration(d.inSeconds);
|
||||||
|
case CallPhase.ended:
|
||||||
|
return switch (call.outcome) {
|
||||||
|
CallOutcome.busy => l10n.callEndedBusy,
|
||||||
|
CallOutcome.rejected => l10n.callEndedRejected,
|
||||||
|
CallOutcome.unanswered => l10n.callEndedUnanswered,
|
||||||
|
CallOutcome.failed => l10n.callEndedFailed,
|
||||||
|
_ => l10n.callEnded,
|
||||||
|
};
|
||||||
|
case CallPhase.idle:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final call = ref.watch(callProvider);
|
||||||
|
final controller = ref.read(callProvider.notifier);
|
||||||
|
ref.listen(callProvider.select((s) => s.phase), (_, phase) {
|
||||||
|
if (phase == CallPhase.idle && context.canPop()) context.pop();
|
||||||
|
});
|
||||||
|
|
||||||
|
final showVideo = call.video &&
|
||||||
|
(call.phase == CallPhase.active || call.phase == CallPhase.connecting);
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
// Zpět hovor neukončí ani neschová — jinak by běžel bez ovládání.
|
||||||
|
canPop: !call.busy,
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: SepotColors.hero,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
if (showVideo)
|
||||||
|
Positioned.fill(
|
||||||
|
child: RTCVideoView(
|
||||||
|
controller.remoteRenderer,
|
||||||
|
objectFit:
|
||||||
|
RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Positioned.fill: bez něj si Stack dá sloupci volné rozměry a ve
|
||||||
|
// fázi „ukončeno" (žádný ovládací řádek přes celou šířku) se sloupec
|
||||||
|
// smrskne na šířku jména a zarovná vlevo nahoru — avatar uskočí.
|
||||||
|
Positioned.fill(
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
if (!showVideo) ...[
|
||||||
|
SepotAvatar(
|
||||||
|
name: call.contactName,
|
||||||
|
seed: call.contactId,
|
||||||
|
size: 112),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
call.contactName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: SepotColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_status(call),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16, color: SepotColors.textSecondary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.lock_rounded,
|
||||||
|
size: 13, color: SepotColors.mist),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(context.l10n.callEncrypted,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: SepotColors.mist)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (call.phase == CallPhase.incoming)
|
||||||
|
_IncomingActions(
|
||||||
|
onAccept: controller.accept,
|
||||||
|
onReject: controller.reject,
|
||||||
|
)
|
||||||
|
else if (call.busy)
|
||||||
|
_InCallControls(call: call, controller: controller),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showVideo && !call.cameraOff)
|
||||||
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
top: MediaQuery.paddingOf(context).top + 16,
|
||||||
|
width: 108,
|
||||||
|
height: 152,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: RTCVideoView(
|
||||||
|
controller.localRenderer,
|
||||||
|
mirror: true,
|
||||||
|
objectFit:
|
||||||
|
RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IncomingActions extends StatelessWidget {
|
||||||
|
const _IncomingActions({required this.onAccept, required this.onReject});
|
||||||
|
|
||||||
|
final VoidCallback onAccept;
|
||||||
|
final VoidCallback onReject;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_RoundAction(
|
||||||
|
icon: Icons.call_end_rounded,
|
||||||
|
label: l10n.callReject,
|
||||||
|
color: SepotColors.error,
|
||||||
|
onTap: onReject,
|
||||||
|
),
|
||||||
|
_RoundAction(
|
||||||
|
icon: Icons.call_rounded,
|
||||||
|
label: l10n.callAccept,
|
||||||
|
color: SepotColors.sage,
|
||||||
|
onTap: onAccept,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InCallControls extends StatelessWidget {
|
||||||
|
const _InCallControls({required this.call, required this.controller});
|
||||||
|
|
||||||
|
final CallState call;
|
||||||
|
final CallController controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: GlassSurface(
|
||||||
|
borderRadius: BorderRadius.circular(32),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_ToggleAction(
|
||||||
|
icon: call.muted ? Icons.mic_off_rounded : Icons.mic_rounded,
|
||||||
|
label: l10n.callMute,
|
||||||
|
active: call.muted,
|
||||||
|
onTap: controller.toggleMute,
|
||||||
|
),
|
||||||
|
_ToggleAction(
|
||||||
|
icon: Icons.volume_up_rounded,
|
||||||
|
label: l10n.callSpeaker,
|
||||||
|
active: call.speaker,
|
||||||
|
onTap: controller.toggleSpeaker,
|
||||||
|
),
|
||||||
|
if (call.video) ...[
|
||||||
|
_ToggleAction(
|
||||||
|
icon: call.cameraOff
|
||||||
|
? Icons.videocam_off_rounded
|
||||||
|
: Icons.videocam_rounded,
|
||||||
|
label: l10n.callCamera,
|
||||||
|
active: call.cameraOff,
|
||||||
|
onTap: controller.toggleCamera,
|
||||||
|
),
|
||||||
|
_ToggleAction(
|
||||||
|
icon: Icons.cameraswitch_rounded,
|
||||||
|
label: l10n.callSwitchCamera,
|
||||||
|
active: false,
|
||||||
|
onTap: controller.switchCamera,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
_RoundAction(
|
||||||
|
icon: Icons.call_end_rounded,
|
||||||
|
label: l10n.callHangUp,
|
||||||
|
color: SepotColors.error,
|
||||||
|
onTap: controller.hangUp,
|
||||||
|
size: 56,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ToggleAction extends StatelessWidget {
|
||||||
|
const _ToggleAction({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.active,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final bool active;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
toggled: active,
|
||||||
|
label: label,
|
||||||
|
child: InkResponse(
|
||||||
|
onTap: onTap,
|
||||||
|
radius: 32,
|
||||||
|
child: Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: active
|
||||||
|
? SepotColors.textPrimary
|
||||||
|
: SepotColors.textPrimary.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
child: Icon(icon,
|
||||||
|
color: active ? SepotColors.background : SepotColors.textPrimary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RoundAction extends StatelessWidget {
|
||||||
|
const _RoundAction({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.color,
|
||||||
|
required this.onTap,
|
||||||
|
this.size = 72,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
label: label,
|
||||||
|
child: InkResponse(
|
||||||
|
onTap: onTap,
|
||||||
|
radius: size / 2 + 8,
|
||||||
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
||||||
|
child: Icon(icon, color: SepotColors.background, size: size * 0.42),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „3:12“, u dlouhého hovoru „1:03:12“.
|
||||||
|
String formatCallDuration(int seconds) {
|
||||||
|
final h = seconds ~/ 3600;
|
||||||
|
final m = (seconds % 3600) ~/ 60;
|
||||||
|
final s = (seconds % 60).toString().padLeft(2, '0');
|
||||||
|
return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$s' : '$m:$s';
|
||||||
|
}
|
||||||