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/),
|
||||
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
|
||||
|
||||
### 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.
|
||||
|
||||
> 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
|
||||
|
||||
@@ -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.
|
||||
- **Attachments** — images and files are encrypted client-side (AES-256-GCM) and uploaded as opaque blobs with a TTL; image metadata (EXIF/GPS) is stripped on send.
|
||||
- **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers).
|
||||
- **Push without Google by default** — UnifiedPush/ntfy; FCM (Android) and APNs (iOS) are opt-in. A push wake-up never carries content, sender, or count.
|
||||
- **Push with or without Google** — UnifiedPush/ntfy when a distributor is installed, otherwise FCM on Android (default since 1.2.0, can be switched off) and APNs on iOS. A push wake-up never carries content, sender, or count.
|
||||
- **Tor transport** (Android, via Orbot) — routes relay traffic over a `.onion` to hide the client IP. Off by default.
|
||||
- **Five languages** — Czech, English, German, Russian, Italian. The UI follows the system language and can be overridden in Settings.
|
||||
|
||||
@@ -71,10 +71,10 @@ UI strings live in `app/lib/l10n/app_*.arb` (`cs`, `en`, `de`, `ru`, `it`) and a
|
||||
|
||||
## Security
|
||||
|
||||
Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **bezpecnost@sepot.cz** rather than opening a public issue.
|
||||
Cryptography is implemented by 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
|
||||
|
||||
Š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
|
||||
complete corresponding source available under the AGPL through channels
|
||||
without app-store restrictions (this public repository, F-Droid, direct
|
||||
without app-store restrictions (this public repository, direct
|
||||
APK). A written clarification for the current Rust `libsignal` has been
|
||||
requested from Signal (tracking: <https://github.com/signalapp/libsignal/issues/684>).
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Nahrávání hlasových zpráv -->
|
||||
<!-- Nahrávání hlasových zpráv a hovory -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Hovory (WebRTC): kamera u videohovoru, přepínání reproduktoru
|
||||
a Bluetooth sluchátka, sledování změny sítě během hovoru. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<!-- Firebase Messaging tiše přidává AD_ID oprávnění; Šepot inzertní ID
|
||||
nepoužívá (žádné reklamy/tracking) → odebíráme, ať sedí Play deklarace. -->
|
||||
<uses-permission android:name="com.google.android.gms.permission.AD_ID"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cz.sepot.sepot
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
@@ -7,10 +8,20 @@ import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
)
|
||||
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací.
|
||||
// Výchozí stav; Dart přepínač „Zabezpečení obrazovky" ho může po
|
||||
// odemčení vypnout (default zapnuto, jako u Signalu).
|
||||
// Debug build: screenshoty povolené kvůli QA na emulátoru (adb screencap
|
||||
// by u FLAG_SECURE okna selhal); release vždy chráněný.
|
||||
val debuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
if (!debuggable) {
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
)
|
||||
}
|
||||
// Tapjacking: dotyky přes překryvné okno jiné appky (částečně
|
||||
// průhledný overlay nad PIN klávesnicí) se zahodí.
|
||||
window.decorView.filterTouchesWhenObscured = true
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 7.6 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0a0e17</color>
|
||||
<color name="ic_launcher_background">#0B0C12</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 575 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 27 KiB |
@@ -1,9 +1,9 @@
|
||||
# Launcher ikony — kandidát 1 „Š s tečkami" (nokturno-ikona-B2, ember paleta).
|
||||
# Zdroj: sepot-web/koncept/logo/nokturno-ikona-B2.png; regenerace:
|
||||
# Launcher icons: the "Š that fades into dots" mark (variant B, 2026-10).
|
||||
# Source: assets/icon/icon-1024.png (full) and maskable-fg-512.png (adaptive foreground); regenerate with:
|
||||
# dart run flutter_launcher_icons
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: assets/icon/icon-1024.png
|
||||
adaptive_icon_background: "#0a0e17"
|
||||
adaptive_icon_background: "#0B0C12"
|
||||
adaptive_icon_foreground: assets/icon/maskable-fg-512.png
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import CallKit
|
||||
import Flutter
|
||||
import PushKit
|
||||
import UIKit
|
||||
import WebRTC
|
||||
import flutter_callkit_incoming
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
|
||||
// PushKit registry musí žít po celou dobu běhu appky, jinak VoIP pushe nechodí.
|
||||
private var voipRegistry: PKPushRegistry?
|
||||
private var apnsChannel: FlutterMethodChannel?
|
||||
// Token dorazí asynchronně z APNs; když si o něj Dart řekne dřív, podržíme ho.
|
||||
private var pendingToken: String?
|
||||
@@ -29,13 +35,100 @@ import UIKit
|
||||
apnsChannel = channel
|
||||
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
|
||||
// Hovory: VoIP push z relaye → CallKit.
|
||||
let registry = PKPushRegistry(queue: DispatchQueue.main)
|
||||
registry.delegate = self
|
||||
registry.desiredPushTypes = [.voIP]
|
||||
voipRegistry = registry
|
||||
|
||||
// Zvukovou relaci u hovoru řídí CallKit; WebRTC ji smí zapnout až po
|
||||
// jeho aktivaci (didActivateAudioSession), jinak hovor z uzamčené
|
||||
// obrazovky nemá zvuk.
|
||||
RTCAudioSession.sharedInstance().useManualAudio = true
|
||||
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
// MARK: - PushKit (VoIP)
|
||||
|
||||
func pushRegistry(_ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType) {
|
||||
let token = credentials.token.map { String(format: "%02x", $0) }.joined()
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(token)
|
||||
}
|
||||
|
||||
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
|
||||
}
|
||||
|
||||
// iOS vyžaduje, aby každý VoIP push HNED skončil hovorem v CallKitu — jinak
|
||||
// appku ukončí a po opakování VoIP pushe přestane doručovat. Relay neví,
|
||||
// kdo volá (sealed sender), proto zvoní „Šepot“; Dart zvonění spáruje
|
||||
// s nabídkou podle `extra.push`, až ji po připojení dešifruje.
|
||||
func pushRegistry(
|
||||
_ registry: PKPushRegistry,
|
||||
didReceiveIncomingPushWith payload: PKPushPayload,
|
||||
for type: PKPushType,
|
||||
completion: @escaping () -> Void
|
||||
) {
|
||||
guard type == .voIP else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
let data = flutter_callkit_incoming.Data(
|
||||
id: UUID().uuidString, nameCaller: "Šepot", handle: "Šepot", type: 0)
|
||||
data.appName = "Šepot"
|
||||
data.duration = 45000
|
||||
data.extra = ["push": true]
|
||||
data.handleType = "generic"
|
||||
data.supportsVideo = true
|
||||
data.maximumCallGroups = 1
|
||||
data.maximumCallsPerCallGroup = 1
|
||||
data.supportsHolding = false
|
||||
data.supportsDTMF = false
|
||||
data.includesCallsInRecents = false
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming(data, fromPushKit: true) {
|
||||
completion()
|
||||
}
|
||||
// Běží-li Dart, ať se hned připojí a vyzvedne nabídku z fronty.
|
||||
apnsChannel?.invokeMethod("onWake", arguments: nil)
|
||||
}
|
||||
|
||||
// MARK: - CallKit
|
||||
// Rozhodnutí (přijmout/odmítnout) řeší Dart přes události pluginu;
|
||||
// tady jen potvrdit akci CallKitu, jinak by ji systém po čase zrušil.
|
||||
|
||||
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
func onDecline(_ call: Call, _ action: CXEndCallAction) {
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
func onEnd(_ call: Call, _ action: CXEndCallAction) {
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
func onTimeOut(_ call: Call) {}
|
||||
|
||||
func didActivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
|
||||
RTCAudioSession.sharedInstance().isAudioEnabled = true
|
||||
}
|
||||
|
||||
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession)
|
||||
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
||||
}
|
||||
|
||||
func providerDidReset() {}
|
||||
|
||||
// APNs vydal device token → předáme Dartu (ten ho pošle relayi).
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Foundation.Data
|
||||
) {
|
||||
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
||||
pendingToken = token
|
||||
|
||||
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 782 B After Width: | Height: | Size: 560 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 817 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
@@ -5,9 +5,11 @@
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
<string>audio</string>
|
||||
<string>voip</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>The camera is used to scan contacts' QR codes and to take photos for attachments.</string>
|
||||
<string>The camera is used to scan contacts' QR codes, take photos for attachments and for video calls.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>The photo library is used to pick photos to send in a conversation.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
@@ -41,7 +43,7 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<true/>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
@@ -66,6 +68,6 @@
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Šepot needs the microphone to record voice messages.</string>
|
||||
<string>Šepot needs the microphone for voice messages and calls.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Šepot — texty oprávnění iOS (čeština) */
|
||||
"NSCameraUsageDescription" = "Kamera slouží ke skenování QR kódů kontaktů a focení příloh.";
|
||||
"NSCameraUsageDescription" = "Kamera slouží ke skenování QR kódů kontaktů, focení příloh a videohovorům.";
|
||||
"NSPhotoLibraryUsageDescription" = "Galerie slouží k výběru fotek pro odeslání v konverzaci.";
|
||||
"NSFaceIDUsageDescription" = "Face ID odemyká aplikaci místo PINu.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot potřebuje mikrofon pro nahrávání hlasových zpráv.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot potřebuje mikrofon pro hlasové zprávy a hovory.";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Šepot — iOS-Berechtigungstexte (Deutsch) */
|
||||
"NSCameraUsageDescription" = "Die Kamera dient zum Scannen der QR-Codes deiner Kontakte und zum Aufnehmen von Fotos für Anhänge.";
|
||||
"NSCameraUsageDescription" = "Die Kamera dient zum Scannen der QR-Codes deiner Kontakte, zum Aufnehmen von Fotos für Anhänge und für Videoanrufe.";
|
||||
"NSPhotoLibraryUsageDescription" = "Die Fotomediathek dient zum Auswählen von Fotos, die du in einer Unterhaltung sendest.";
|
||||
"NSFaceIDUsageDescription" = "Face ID entsperrt die App anstelle der PIN.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot benötigt das Mikrofon zum Aufnehmen von Sprachnachrichten.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot benötigt das Mikrofon für Sprachnachrichten und Anrufe.";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Šepot — iOS permission strings (English) */
|
||||
"NSCameraUsageDescription" = "The camera is used to scan contacts' QR codes and to take photos for attachments.";
|
||||
"NSCameraUsageDescription" = "The camera is used to scan contacts' QR codes, take photos for attachments and for video calls.";
|
||||
"NSPhotoLibraryUsageDescription" = "The photo library is used to pick photos to send in a conversation.";
|
||||
"NSFaceIDUsageDescription" = "Face ID unlocks the app instead of your PIN.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot needs the microphone to record voice messages.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot needs the microphone for voice messages and calls.";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Šepot — testi delle autorizzazioni iOS (italiano) */
|
||||
"NSCameraUsageDescription" = "La fotocamera serve a scansionare i codici QR dei contatti e a scattare foto per gli allegati.";
|
||||
"NSCameraUsageDescription" = "La fotocamera serve a scansionare i codici QR dei contatti, a scattare foto per gli allegati e per le videochiamate.";
|
||||
"NSPhotoLibraryUsageDescription" = "La libreria foto serve a scegliere le foto da inviare in una conversazione.";
|
||||
"NSFaceIDUsageDescription" = "Face ID sblocca l'app al posto del PIN.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot ha bisogno del microfono per registrare i messaggi vocali.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot ha bisogno del microfono per i messaggi vocali e le chiamate.";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Šepot — тексты разрешений iOS (русский) */
|
||||
"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов и съёмки фотографий для вложений.";
|
||||
"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов, съёмки фотографий для вложений и видеозвонков.";
|
||||
"NSPhotoLibraryUsageDescription" = "Галерея используется для выбора фотографий, которые вы отправляете в чате.";
|
||||
"NSFaceIDUsageDescription" = "Face ID разблокирует приложение вместо PIN-кода.";
|
||||
"NSMicrophoneUsageDescription" = "Для записи голосовых сообщений приложению Šepot нужен доступ к микрофону.";
|
||||
"NSMicrophoneUsageDescription" = "Приложению Šepot нужен доступ к микрофону для голосовых сообщений и звонков.";
|
||||
|
||||
@@ -5,7 +5,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/core/calls/call_controller.dart';
|
||||
import 'package:sepot/features/auth/change_pin_screen.dart';
|
||||
import 'package:sepot/features/calls/call_screen.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/features/onboarding/onboarding_screen.dart';
|
||||
import 'package:sepot/features/onboarding/restore_screen.dart';
|
||||
@@ -17,6 +19,8 @@ import 'package:sepot/features/conversation/conversation_screen.dart';
|
||||
import 'package:sepot/features/contacts/contact_verify_screen.dart';
|
||||
import 'package:sepot/features/contacts/contacts_screen.dart';
|
||||
import 'package:sepot/features/groups/create_group_screen.dart';
|
||||
import 'package:sepot/features/profile/contact_profile_screen.dart';
|
||||
import 'package:sepot/features/profile/group_profile_screen.dart';
|
||||
import 'package:sepot/features/settings/settings_screen.dart';
|
||||
import 'package:sepot/features/safety/report_screen.dart';
|
||||
import 'package:sepot/features/qr_pairing/qr_share_screen.dart';
|
||||
@@ -62,6 +66,21 @@ final _router = GoRouter(
|
||||
path: '/conversation/:contactId',
|
||||
builder: (context, state) => ConversationScreen(
|
||||
contactId: state.pathParameters['contactId']!,
|
||||
startSearch: state.uri.queryParameters['search'] == '1',
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/contact/:contactId',
|
||||
builder: (context, state) => ContactProfileScreen(
|
||||
contactId: state.pathParameters['contactId']!,
|
||||
fromChat: state.uri.queryParameters['from'] == 'chat',
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/group/:groupId',
|
||||
builder: (context, state) => GroupProfileScreen(
|
||||
groupId: state.pathParameters['groupId']!,
|
||||
fromChat: state.uri.queryParameters['from'] == 'chat',
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
@@ -72,6 +91,11 @@ final _router = GoRouter(
|
||||
path: '/create-group',
|
||||
builder: (context, state) => const CreateGroupScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/group/:groupId/add',
|
||||
builder: (context, state) =>
|
||||
CreateGroupScreen(addToGroupId: state.pathParameters['groupId']),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/verify/:contactId',
|
||||
builder: (context, state) => ContactVerifyScreen(
|
||||
@@ -101,6 +125,10 @@ final _router = GoRouter(
|
||||
path: '/change-pin',
|
||||
builder: (context, state) => const ChangePinScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/call',
|
||||
builder: (context, state) => const CallScreen(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -116,6 +144,16 @@ class SepotApp extends ConsumerWidget {
|
||||
// null = systémový jazyk; než se volba načte z keystore, jede systém.
|
||||
final locale = ref.watch(localeProvider).valueOrNull;
|
||||
|
||||
// Příchozí i odchozí hovor otevře obrazovku hovoru odkudkoli.
|
||||
ref.listen(callProvider.select((s) => s.phase), (previous, phase) {
|
||||
final starting =
|
||||
phase == CallPhase.incoming || phase == CallPhase.outgoing;
|
||||
final path = _router.routerDelegate.currentConfiguration.uri.path;
|
||||
if (starting && previous != phase && path != '/call') {
|
||||
_router.push('/call');
|
||||
}
|
||||
});
|
||||
|
||||
return _AutoLockGate(
|
||||
child: MaterialApp.router(
|
||||
onGenerateTitle: (context) => context.l10n.appTitle,
|
||||
@@ -251,7 +289,9 @@ class _LoadingScreenState extends ConsumerState<_LoadingScreen> {
|
||||
final storage = ref.read(storageProvider);
|
||||
final legacyPin = await storage.hasLegacyPin();
|
||||
await storage.unlockWithoutPin();
|
||||
if (mounted) context.go(legacyPin ? '/pin-setup' : '/chats');
|
||||
if (!mounted) return;
|
||||
ref.invalidate(screenSecurityProvider);
|
||||
context.go(legacyPin ? '/pin-setup' : '/chats');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/// Verze aplikace čtená z nainstalovaného balíčku.
|
||||
///
|
||||
/// Proč ne konstanta: verze se dřív psala ručně do `AppConstants` a musela se
|
||||
/// hlídat proti `version:` v pubspec.yaml. Nehlídala se — produkční 1.1.2+22
|
||||
/// posílala v hlášeních zneužití „1.1.0" a podle hlášení tak nešlo dohledat,
|
||||
/// ze kterého buildu přišlo. `package_info_plus` čte `versionName` (Android)
|
||||
/// / `CFBundleShortVersionString` (iOS), které Flutter generuje přímo
|
||||
/// z pubspecu — rozejít se to už nemůže a ruční editace odpadá.
|
||||
library;
|
||||
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
class AppInfo {
|
||||
AppInfo._();
|
||||
|
||||
/// Zástupný text, dokud verzi nezná (nebo když ji nešlo přečíst). Znak, ne
|
||||
/// slovo — appka běží v pěti jazycích a české „neznámá" by v nich čouhalo.
|
||||
static const String unknownVersion = '—';
|
||||
|
||||
static PackageInfo? _info;
|
||||
|
||||
/// Načte verzi z balíčku. Volat jednou při startu, před prvním `runApp`.
|
||||
static Future<void> init() async {
|
||||
_info = await PackageInfo.fromPlatform();
|
||||
}
|
||||
|
||||
/// Verze pro uživatele: `1.1.2`.
|
||||
static String get version => _info?.version ?? unknownVersion;
|
||||
|
||||
/// Verze pro diagnostiku: `1.1.2+22`. Build číslo je jediné, čím se hlášení
|
||||
/// zneužití přiřadí konkrétnímu buildu — do UI nepatří, do hlášení ano.
|
||||
/// `null`, dokud verzi nezná: hlášení pak pole vynechá, ať server dostane
|
||||
/// prázdno místo vymyšlené hodnoty.
|
||||
static String? get versionWithBuild {
|
||||
final info = _info;
|
||||
if (info == null) return null;
|
||||
// iOS bez vyplněného CFBundleVersion vrací v buildNumber rovnou verzi
|
||||
// (dokumentované chování balíčku) — nelepit „1.1.2+1.1.2".
|
||||
if (info.buildNumber.isEmpty || info.buildNumber == info.version) {
|
||||
return info.version;
|
||||
}
|
||||
return '${info.version}+${info.buildNumber}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' hide MessageType;
|
||||
import 'package:sepot/core/calls/call_ringer.dart';
|
||||
import 'package:sepot/models/call_log.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/push_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Fáze hovoru.
|
||||
enum CallPhase { idle, outgoing, incoming, connecting, active, ended }
|
||||
|
||||
class CallState {
|
||||
const CallState({
|
||||
this.phase = CallPhase.idle,
|
||||
this.callId,
|
||||
this.contactId,
|
||||
this.contactName = '',
|
||||
this.video = false,
|
||||
this.outgoing = false,
|
||||
this.connectedAt,
|
||||
this.muted = false,
|
||||
this.speaker = false,
|
||||
this.cameraOff = false,
|
||||
this.outcome,
|
||||
});
|
||||
|
||||
final CallPhase phase;
|
||||
final String? callId;
|
||||
final String? contactId;
|
||||
final String contactName;
|
||||
final bool video;
|
||||
final bool outgoing;
|
||||
final DateTime? connectedAt;
|
||||
final bool muted;
|
||||
final bool speaker;
|
||||
final bool cameraOff;
|
||||
|
||||
/// Proč hovor skončil (jen ve fázi [CallPhase.ended]).
|
||||
final CallOutcome? outcome;
|
||||
|
||||
bool get busy => phase != CallPhase.idle && phase != CallPhase.ended;
|
||||
|
||||
CallState copyWith({
|
||||
CallPhase? phase,
|
||||
DateTime? connectedAt,
|
||||
bool? muted,
|
||||
bool? speaker,
|
||||
bool? cameraOff,
|
||||
CallOutcome? outcome,
|
||||
}) =>
|
||||
CallState(
|
||||
phase: phase ?? this.phase,
|
||||
callId: callId,
|
||||
contactId: contactId,
|
||||
contactName: contactName,
|
||||
video: video,
|
||||
outgoing: outgoing,
|
||||
connectedAt: connectedAt ?? this.connectedAt,
|
||||
muted: muted ?? this.muted,
|
||||
speaker: speaker ?? this.speaker,
|
||||
cameraOff: cameraOff ?? this.cameraOff,
|
||||
outcome: outcome ?? this.outcome,
|
||||
);
|
||||
}
|
||||
|
||||
/// Řídí jeden hovor 1:1: WebRTC spojení, signalizaci přes E2E kanál
|
||||
/// Šepotu a záznam výsledku do chatu. Druhý souběžný hovor se odmítne
|
||||
/// jako `busy`.
|
||||
class CallController extends Notifier<CallState> {
|
||||
/// Nabídka starší než tohle už nezvoní — jen se zapíše jako zmeškaná.
|
||||
/// Stejná mez jako expirace VoIP pushe na relayi.
|
||||
static const ringTimeout = Duration(seconds: 45);
|
||||
|
||||
/// ICE bez trickle: nabídka jde až po sběru kandidátů, nejdéle po této
|
||||
/// době (jinak by pomalé rozhraní zdrželo zvonění).
|
||||
static const _iceGatherTimeout = Duration(seconds: 3);
|
||||
|
||||
/// Jak dlouho je vidět obrazovka „Hovor skončil“, než se zavře.
|
||||
static const endedLinger = Duration(seconds: 2);
|
||||
|
||||
RTCPeerConnection? _pc;
|
||||
MediaStream? _localStream;
|
||||
String? _pendingOfferSdp;
|
||||
Contact? _peer;
|
||||
Timer? _ringTimer;
|
||||
|
||||
/// Id systémového zvonění (CallKit / Android) k běžícímu příchozímu
|
||||
/// hovoru; null = zvoní jen obrazovka appky.
|
||||
String? _ringId;
|
||||
|
||||
final localRenderer = RTCVideoRenderer();
|
||||
final remoteRenderer = RTCVideoRenderer();
|
||||
bool _renderersReady = false;
|
||||
|
||||
@override
|
||||
CallState build() {
|
||||
CallRinger.listen(
|
||||
onAccept: _onRingAccept,
|
||||
onDecline: _onRingDecline,
|
||||
onEnded: _onRingEnded,
|
||||
onVoipToken: () => ref.read(pushServiceProvider).refreshVoipToken(),
|
||||
);
|
||||
ref.onDispose(() {
|
||||
unawaited(_cleanup());
|
||||
localRenderer.dispose();
|
||||
remoteRenderer.dispose();
|
||||
});
|
||||
return const CallState();
|
||||
}
|
||||
|
||||
// ── Odchozí ────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> startCall(Contact contact, {required bool video}) async {
|
||||
if (state.busy) return;
|
||||
_peer = contact;
|
||||
state = CallState(
|
||||
phase: CallPhase.outgoing,
|
||||
callId: const Uuid().v4(),
|
||||
contactId: contact.id,
|
||||
contactName: contact.displayName,
|
||||
video: video,
|
||||
outgoing: true,
|
||||
speaker: video,
|
||||
);
|
||||
try {
|
||||
await _openMedia(video: video);
|
||||
final pc = await _createPeerConnection();
|
||||
final offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
final sdp = await _gatheredSdp(pc);
|
||||
await ref.read(messagingServiceProvider).sendCallSignal(
|
||||
contact,
|
||||
{'cid': state.callId, 'k': 'offer', 'sdp': sdp, 'vid': video},
|
||||
ring: true,
|
||||
);
|
||||
_ringTimer = Timer(ringTimeout, () {
|
||||
if (state.phase == CallPhase.outgoing) {
|
||||
unawaited(_hangup(CallOutcome.unanswered, notify: true));
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
await _finish(CallOutcome.failed);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Příchozí ───────────────────────────────────────────────────────
|
||||
|
||||
/// Obsluha příchozí signalizace z [MessagingService.onCallSignal].
|
||||
Future<void> onSignal(Contact contact, Map<String, dynamic> signal) async {
|
||||
final cid = signal['cid'];
|
||||
final kind = signal['k'];
|
||||
if (cid is! String || kind is! String) return;
|
||||
|
||||
if (kind == 'offer') {
|
||||
await _onOffer(contact, cid, signal);
|
||||
return;
|
||||
}
|
||||
// Ostatní patří jen k právě běžícímu hovoru s tímtéž člověkem.
|
||||
if (cid != state.callId || contact.id != state.contactId) return;
|
||||
switch (kind) {
|
||||
case 'answer':
|
||||
final sdp = signal['sdp'];
|
||||
if (sdp is! String || state.phase != CallPhase.outgoing) return;
|
||||
_ringTimer?.cancel();
|
||||
state = state.copyWith(phase: CallPhase.connecting);
|
||||
await _pc?.setRemoteDescription(RTCSessionDescription(sdp, 'answer'));
|
||||
case 'hangup':
|
||||
final answered = state.connectedAt != null;
|
||||
await _finish(answered
|
||||
? CallOutcome.completed
|
||||
: state.outgoing
|
||||
? CallOutcome.rejected
|
||||
: CallOutcome.missed);
|
||||
case 'reject':
|
||||
await _finish(CallOutcome.rejected);
|
||||
case 'busy':
|
||||
await _finish(CallOutcome.busy);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onOffer(
|
||||
Contact contact, String cid, Map<String, dynamic> signal) async {
|
||||
final sdp = signal['sdp'];
|
||||
if (sdp is! String) return;
|
||||
final video = signal['vid'] == true;
|
||||
final sentAt = DateTime.fromMillisecondsSinceEpoch(
|
||||
(signal['ts'] as num?)?.toInt() ?? 0);
|
||||
// Pozdě doručená nabídka (telefon byl mimo signál) — nezvonit.
|
||||
if (DateTime.now().difference(sentAt) > ringTimeout) {
|
||||
await _log(contact.id,
|
||||
CallLog(outcome: CallOutcome.missed, outgoing: false, video: video),
|
||||
at: sentAt);
|
||||
return;
|
||||
}
|
||||
if (state.busy) {
|
||||
if (cid != state.callId) {
|
||||
await ref
|
||||
.read(messagingServiceProvider)
|
||||
.sendCallSignal(contact, {'cid': cid, 'k': 'busy'});
|
||||
}
|
||||
return;
|
||||
}
|
||||
_peer = contact;
|
||||
_pendingOfferSdp = sdp;
|
||||
state = CallState(
|
||||
phase: CallPhase.incoming,
|
||||
callId: cid,
|
||||
contactId: contact.id,
|
||||
contactName: contact.displayName,
|
||||
video: video,
|
||||
speaker: video,
|
||||
);
|
||||
_ringTimer = Timer(ringTimeout, () {
|
||||
if (state.phase == CallPhase.incoming) {
|
||||
unawaited(_finish(CallOutcome.missed));
|
||||
}
|
||||
});
|
||||
await _attachSystemRing(cid, contact.displayName, video);
|
||||
}
|
||||
|
||||
/// Spáruje hovor se systémovým zvoněním. Zvonění z budíčku relaye už
|
||||
/// běží (možná ho uživatel mezitím přijal na zamčené obrazovce); jinak
|
||||
/// se zvonění spustí, jen když appka není v popředí — v popředí stačí
|
||||
/// vlastní obrazovka hovoru.
|
||||
Future<void> _attachSystemRing(String cid, String name, bool video) async {
|
||||
final pushRing = await CallRinger.pendingPushRing();
|
||||
if (pushRing != null) {
|
||||
_ringId = pushRing.id;
|
||||
if (pushRing.accepted) {
|
||||
await accept();
|
||||
return;
|
||||
}
|
||||
if (CallRinger.declinedEarly.remove(pushRing.id)) {
|
||||
await reject();
|
||||
return;
|
||||
}
|
||||
// Spárováno s nabídkou: aby grace z `ringFromPush` zvonění neukončila,
|
||||
// nahradí se jménem volajícího (bez značky push).
|
||||
await _claimPushRing(pushRing.id, name, video);
|
||||
return;
|
||||
}
|
||||
final foreground =
|
||||
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed;
|
||||
if (!foreground) {
|
||||
_ringId = cid;
|
||||
await CallRinger.ringFor(callId: cid, name: name, video: video);
|
||||
}
|
||||
}
|
||||
|
||||
/// Převezme zvonění z budíčku: jméno volajícího místo „Šepot" a bez
|
||||
/// značky push (grace v [CallRinger.ringFromPush] ho pak nechá být).
|
||||
Future<void> _claimPushRing(String pushId, String name, bool video) async {
|
||||
if (await CallRinger.isAccepted(pushId)) return; // uživatel už zvedl
|
||||
await CallRinger.end(pushId);
|
||||
_ringId = state.callId;
|
||||
await CallRinger.ringFor(callId: state.callId!, name: name, video: video);
|
||||
}
|
||||
|
||||
void _onRingAccept(String id) {
|
||||
if (id == _ringId && state.phase == CallPhase.incoming) {
|
||||
unawaited(accept());
|
||||
}
|
||||
}
|
||||
|
||||
void _onRingDecline(String id) {
|
||||
if (id == _ringId && state.phase == CallPhase.incoming) {
|
||||
unawaited(reject());
|
||||
} else if (!state.busy) {
|
||||
CallRinger.declinedEarly.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Zavěšení ze systémového rozhraní (zamčená obrazovka, sluchátka).
|
||||
void _onRingEnded(String id) {
|
||||
if (id == _ringId && state.busy) unawaited(hangUp());
|
||||
}
|
||||
|
||||
Future<void> accept() async {
|
||||
final peer = _peer;
|
||||
final offer = _pendingOfferSdp;
|
||||
if (state.phase != CallPhase.incoming || peer == null || offer == null) {
|
||||
return;
|
||||
}
|
||||
_ringTimer?.cancel();
|
||||
state = state.copyWith(phase: CallPhase.connecting);
|
||||
try {
|
||||
await _openMedia(video: state.video);
|
||||
final pc = await _createPeerConnection();
|
||||
await pc.setRemoteDescription(RTCSessionDescription(offer, 'offer'));
|
||||
final answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
final sdp = await _gatheredSdp(pc);
|
||||
await ref.read(messagingServiceProvider).sendCallSignal(
|
||||
peer, {'cid': state.callId, 'k': 'answer', 'sdp': sdp});
|
||||
} catch (_) {
|
||||
await _hangup(CallOutcome.failed, notify: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reject() async {
|
||||
if (state.phase != CallPhase.incoming) return;
|
||||
await _hangup(CallOutcome.rejected, notify: true, kind: 'reject');
|
||||
}
|
||||
|
||||
/// Zavěšení v jakékoli fázi (vytáčení, spojování, hovor).
|
||||
Future<void> hangUp() async {
|
||||
if (!state.busy) return;
|
||||
final outcome = state.connectedAt != null
|
||||
? CallOutcome.completed
|
||||
: state.outgoing
|
||||
? CallOutcome.unanswered
|
||||
: CallOutcome.rejected;
|
||||
await _hangup(outcome, notify: true);
|
||||
}
|
||||
|
||||
// ── Ovládání během hovoru ──────────────────────────────────────────
|
||||
|
||||
void toggleMute() {
|
||||
final muted = !state.muted;
|
||||
for (final t in _localStream?.getAudioTracks() ?? const []) {
|
||||
t.enabled = !muted;
|
||||
}
|
||||
state = state.copyWith(muted: muted);
|
||||
}
|
||||
|
||||
Future<void> toggleSpeaker() async {
|
||||
final speaker = !state.speaker;
|
||||
await Helper.setSpeakerphoneOn(speaker);
|
||||
state = state.copyWith(speaker: speaker);
|
||||
}
|
||||
|
||||
void toggleCamera() {
|
||||
final off = !state.cameraOff;
|
||||
for (final t in _localStream?.getVideoTracks() ?? const []) {
|
||||
t.enabled = !off;
|
||||
}
|
||||
state = state.copyWith(cameraOff: off);
|
||||
}
|
||||
|
||||
Future<void> switchCamera() async {
|
||||
final track = _localStream?.getVideoTracks().firstOrNull;
|
||||
if (track != null) await Helper.switchCamera(track);
|
||||
}
|
||||
|
||||
// ── WebRTC ─────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _openMedia({required bool video}) async {
|
||||
if (!_renderersReady) {
|
||||
await localRenderer.initialize();
|
||||
await remoteRenderer.initialize();
|
||||
_renderersReady = true;
|
||||
}
|
||||
final stream = await navigator.mediaDevices.getUserMedia({
|
||||
'audio': true,
|
||||
'video': video ? {'facingMode': 'user'} : false,
|
||||
});
|
||||
_localStream = stream;
|
||||
localRenderer.srcObject = video ? stream : null;
|
||||
await Helper.setSpeakerphoneOn(video);
|
||||
}
|
||||
|
||||
Future<RTCPeerConnection> _createPeerConnection() async {
|
||||
final pc = await createPeerConnection({
|
||||
'iceServers': await _iceServers(),
|
||||
'sdpSemantics': 'unified-plan',
|
||||
});
|
||||
final local = _localStream;
|
||||
for (final track in local?.getTracks() ?? const <MediaStreamTrack>[]) {
|
||||
await pc.addTrack(track, local!);
|
||||
}
|
||||
pc.onTrack = (event) {
|
||||
if (event.streams.isNotEmpty) {
|
||||
remoteRenderer.srcObject = event.streams.first;
|
||||
}
|
||||
};
|
||||
pc.onConnectionState = (s) {
|
||||
switch (s) {
|
||||
case RTCPeerConnectionState.RTCPeerConnectionStateConnected:
|
||||
if (state.connectedAt == null) {
|
||||
state = state.copyWith(
|
||||
phase: CallPhase.active, connectedAt: DateTime.now());
|
||||
final ring = _ringId;
|
||||
if (ring != null) unawaited(CallRinger.connected(ring));
|
||||
}
|
||||
case RTCPeerConnectionState.RTCPeerConnectionStateFailed:
|
||||
unawaited(_hangup(
|
||||
state.connectedAt != null
|
||||
? CallOutcome.completed
|
||||
: CallOutcome.failed,
|
||||
notify: true));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
_pc = pc;
|
||||
return pc;
|
||||
}
|
||||
|
||||
/// STUN bez údajů: coturn na témž hostiteli odpovídá na STUN bez
|
||||
/// přihlášení, takže i když relay TURN údaje nevydá (503, timeout),
|
||||
/// jde aspoň přímé spojení přes NAT. Dřív byl seznam prázdný = jen host
|
||||
/// kandidáti = hovor jen v jedné LAN.
|
||||
static const _stunFallback = {
|
||||
'urls': ['stun:turn.sepot.cz:3478'],
|
||||
};
|
||||
|
||||
/// STUN i TURN běží na stejném serveru; bez údajů z relaye aspoň STUN
|
||||
/// (přímé spojení, když obě strany nejsou za symetrickým NATem).
|
||||
Future<List<Map<String, dynamic>>> _iceServers() async {
|
||||
final turn = await ref.read(messagingServiceProvider).turnCredentials();
|
||||
if (turn == null || turn.urls.isEmpty) return const [_stunFallback];
|
||||
return [
|
||||
{
|
||||
'urls': turn.urls,
|
||||
'username': turn.username,
|
||||
'credential': turn.credential,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/// SDP po sběru ICE kandidátů (non-trickle).
|
||||
Future<String> _gatheredSdp(RTCPeerConnection pc) async {
|
||||
final done = Completer<void>();
|
||||
pc.onIceGatheringState = (s) {
|
||||
if (s == RTCIceGatheringState.RTCIceGatheringStateComplete &&
|
||||
!done.isCompleted) {
|
||||
done.complete();
|
||||
}
|
||||
};
|
||||
if (pc.iceGatheringState ==
|
||||
RTCIceGatheringState.RTCIceGatheringStateComplete) {
|
||||
done.complete();
|
||||
}
|
||||
await done.future.timeout(_iceGatherTimeout, onTimeout: () {});
|
||||
final local = await pc.getLocalDescription();
|
||||
final sdp = local?.sdp;
|
||||
if (sdp == null) throw StateError('no local description');
|
||||
return sdp;
|
||||
}
|
||||
|
||||
// ── Konec hovoru ───────────────────────────────────────────────────
|
||||
|
||||
Future<void> _hangup(CallOutcome outcome,
|
||||
{required bool notify, String kind = 'hangup'}) async {
|
||||
final peer = _peer;
|
||||
final cid = state.callId;
|
||||
if (notify && peer != null && cid != null) {
|
||||
try {
|
||||
await ref
|
||||
.read(messagingServiceProvider)
|
||||
.sendCallSignal(peer, {'cid': cid, 'k': kind});
|
||||
} catch (_) {
|
||||
// Protistrana zavěšení nedostala — její hovor skončí sám, až
|
||||
// spojení vypadne (ICE failed) nebo vyprší zvonění.
|
||||
}
|
||||
}
|
||||
await _finish(outcome);
|
||||
}
|
||||
|
||||
Future<void> _finish(CallOutcome outcome) async {
|
||||
if (state.phase == CallPhase.idle || state.phase == CallPhase.ended) {
|
||||
return;
|
||||
}
|
||||
final connectedAt = state.connectedAt;
|
||||
final contactId = state.contactId;
|
||||
final log = CallLog(
|
||||
outcome: outcome,
|
||||
outgoing: state.outgoing,
|
||||
video: state.video,
|
||||
durationSeconds: connectedAt == null
|
||||
? 0
|
||||
: DateTime.now().difference(connectedAt).inSeconds,
|
||||
);
|
||||
state = state.copyWith(phase: CallPhase.ended, outcome: outcome);
|
||||
await _cleanup();
|
||||
if (contactId != null) await _log(contactId, log);
|
||||
Timer(endedLinger, () {
|
||||
if (state.phase == CallPhase.ended) state = const CallState();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _cleanup() async {
|
||||
_ringTimer?.cancel();
|
||||
_ringTimer = null;
|
||||
final ring = _ringId;
|
||||
_ringId = null;
|
||||
// Konec zvonění vyvolá událost „ukončeno“ — to už je stav mimo hovor,
|
||||
// takže ji _onRingEnded ignoruje.
|
||||
if (ring != null) unawaited(CallRinger.end(ring));
|
||||
_pendingOfferSdp = null;
|
||||
_peer = null;
|
||||
for (final t in _localStream?.getTracks() ?? const <MediaStreamTrack>[]) {
|
||||
await t.stop();
|
||||
}
|
||||
await _localStream?.dispose();
|
||||
_localStream = null;
|
||||
await _pc?.close();
|
||||
_pc = null;
|
||||
if (_renderersReady) {
|
||||
localRenderer.srcObject = null;
|
||||
remoteRenderer.srcObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Řádek „Hovor · 3:12“ do chatu. Jen lokálně — nic se neposílá.
|
||||
Future<void> _log(String contactId, CallLog log, {DateTime? at}) async {
|
||||
final myId = ref.read(identityProvider).valueOrNull?.id;
|
||||
if (myId == null) return;
|
||||
final message = Message(
|
||||
id: const Uuid().v4(),
|
||||
senderId: log.outgoing ? myId : contactId,
|
||||
recipientId: log.outgoing ? contactId : myId,
|
||||
type: MessageType.text,
|
||||
timestamp: at ?? DateTime.now(),
|
||||
// Záznam hovoru není nepřečtená zpráva — kromě zmeškaného.
|
||||
isRead: log.outcome != CallOutcome.missed,
|
||||
isDelivered: true,
|
||||
callLog: log,
|
||||
);
|
||||
await ref.read(storageProvider).addMessage(contactId, message);
|
||||
ref.invalidate(conversationsProvider);
|
||||
ref.invalidate(conversationProvider(contactId));
|
||||
}
|
||||
}
|
||||
|
||||
final callProvider =
|
||||
NotifierProvider<CallController, CallState>(CallController.new);
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Systémové zvonění (CallKit na iOS, celoobrazovkové na Androidu) pro
|
||||
/// hovory, které přijdou, když appka není v popředí.
|
||||
///
|
||||
/// Budíček z relaye nenese, kdo volá — relay to neví. Zvonění z budíčku
|
||||
/// proto ukáže „Šepot“ a označí se `extra.push`; až appka dešifruje
|
||||
/// nabídku, [CallController] ho spáruje s hovorem podle toho.
|
||||
class CallRinger {
|
||||
CallRinger._();
|
||||
|
||||
static const _appName = 'Šepot';
|
||||
|
||||
/// Zvonění vzniklé z budíčku, ke kterému ještě nedorazila nabídka.
|
||||
static const pushMarker = 'push';
|
||||
|
||||
/// Jak dlouho zvoní — stejné jako expirace budíčku na relayi.
|
||||
static const ringMillis = 45000;
|
||||
|
||||
/// Zvonění z budíčku, ke kterému do té doby nedorazila nabídka, se
|
||||
/// ukončí samo. Relay budíček hovoru nijak neověřuje (sealed sender),
|
||||
/// takže kdokoli se znalostí adresy by jinak nechal telefon zvonit
|
||||
/// 45 s na každou obálku s hintem CALL. Nabídka od
|
||||
/// skutečného volajícího dorazí do pár sekund po připojení.
|
||||
static const pushRingGrace = Duration(seconds: 12);
|
||||
|
||||
static StreamSubscription<CallEvent?>? _sub;
|
||||
|
||||
/// Id zvonění, která uživatel odmítl dřív, než dorazila nabídka.
|
||||
/// (Přijetí si pamatuje samo zvonění — `isAccepted` v [pendingPushRing].)
|
||||
static final declinedEarly = <String>{};
|
||||
|
||||
static void listen({
|
||||
required void Function(String id) onAccept,
|
||||
required void Function(String id) onDecline,
|
||||
required void Function(String id) onEnded,
|
||||
required void Function() onVoipToken,
|
||||
}) {
|
||||
_sub ??= FlutterCallkitIncoming.onEvent.listen((event) {
|
||||
switch (event) {
|
||||
case CallEventActionCallAccept(:final callKitParams):
|
||||
onAccept(callKitParams.id);
|
||||
case CallEventActionCallDecline(:final callKitParams):
|
||||
onDecline(callKitParams.id);
|
||||
case CallEventActionCallEnded(:final callKitParams):
|
||||
onEnded(callKitParams.id);
|
||||
case CallEventActionDidUpdateDevicePushTokenVoip():
|
||||
onVoipToken();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static Future<String?> voipToken() async {
|
||||
final token = await FlutterCallkitIncoming.getDevicePushTokenVoIP();
|
||||
return token != null && token.isNotEmpty ? token : null;
|
||||
}
|
||||
|
||||
/// Zvonění pro dešifrovanou nabídku (appka běží na pozadí).
|
||||
static Future<void> ringFor({
|
||||
required String callId,
|
||||
required String name,
|
||||
required bool video,
|
||||
}) =>
|
||||
FlutterCallkitIncoming.showCallkitIncoming(
|
||||
_params(id: callId, name: name, video: video));
|
||||
|
||||
/// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme.
|
||||
/// Vrací až po [pushRingGrace]: pokud se zvonění do té doby nespárovalo
|
||||
/// s nabídkou (přijetí nebo ukončení ho z aktivních hovorů odebere, nebo
|
||||
/// ho [CallController] převezme), tiše se ukončí. Background isolate FCM
|
||||
/// handleru tím zůstane naživu přesně tak dlouho, jak je potřeba.
|
||||
static Future<void> ringFromPush() async {
|
||||
final id = const Uuid().v4();
|
||||
await FlutterCallkitIncoming.showCallkitIncoming(_params(
|
||||
id: id, name: _appName, video: false, extra: {pushMarker: true}));
|
||||
await Future<void>.delayed(pushRingGrace);
|
||||
await endIfUnmatched(id);
|
||||
}
|
||||
|
||||
/// iOS: zvonění z VoIP pushe zakládá nativní AppDelegate (CallKit musí
|
||||
/// zazvonit hned), takže grace z [ringFromPush] tam neběží. Po startu
|
||||
/// Dartu se proto každé nepřevzaté zvonění z budíčku ohlídá odsud.
|
||||
static Future<void> expireStalePushRings() async {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final call in calls) {
|
||||
if (call.extra?[pushMarker] == true && !call.isAccepted) {
|
||||
final id = call.id;
|
||||
unawaited(Future<void>.delayed(pushRingGrace, () => endIfUnmatched(id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ukončí zvonění z budíčku, které nikdo nepřevzal (bez záznamu
|
||||
/// o zmeškaném hovoru — nebylo od koho).
|
||||
static Future<void> endIfUnmatched(String id) async {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final call in calls) {
|
||||
if (call.id == id && call.extra?[pushMarker] == true && !call.isAccepted) {
|
||||
await FlutterCallkitIncoming.endCall(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zvonění z budíčku, které čeká na nabídku (nejnovější), a jestli ho
|
||||
/// uživatel už přijal — typicky na zamčené obrazovce, než appka naběhla.
|
||||
static Future<({String id, bool accepted})?> pendingPushRing() async {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final call in calls.reversed) {
|
||||
if (call.extra?[pushMarker] == true) {
|
||||
return (id: call.id, accepted: call.isAccepted);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<bool> isAccepted(String id) async {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final call in calls) {
|
||||
if (call.id == id) return call.isAccepted;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<void> connected(String id) =>
|
||||
FlutterCallkitIncoming.setCallConnected(id);
|
||||
|
||||
static Future<void> end(String id) => FlutterCallkitIncoming.endCall(id);
|
||||
|
||||
/// Texty zvonění. Zvonění z budíčku běží v izolátu na pozadí bez
|
||||
/// lokalizace appky, proto vlastní malý slovník podle jazyka systému.
|
||||
static const _texts = {
|
||||
'cs': ('Přijmout', 'Odmítnout', 'Příchozí hovory'),
|
||||
'de': ('Annehmen', 'Ablehnen', 'Eingehende Anrufe'),
|
||||
'it': ('Accetta', 'Rifiuta', 'Chiamate in arrivo'),
|
||||
'ru': ('Принять', 'Отклонить', 'Входящие звонки'),
|
||||
'en': ('Accept', 'Decline', 'Incoming calls'),
|
||||
};
|
||||
|
||||
static (String, String, String) get _localTexts =>
|
||||
_texts[PlatformDispatcher.instance.locale.languageCode] ??
|
||||
_texts['en']!;
|
||||
|
||||
static CallKitParams _params({
|
||||
required String id,
|
||||
required String name,
|
||||
required bool video,
|
||||
Map<String, dynamic>? extra,
|
||||
}) =>
|
||||
CallKitParams(
|
||||
id: id,
|
||||
nameCaller: name,
|
||||
appName: _appName,
|
||||
handle: _appName,
|
||||
type: video ? 1 : 0,
|
||||
duration: ringMillis,
|
||||
extra: extra ?? const {},
|
||||
missedCallNotification: const NotificationParams(
|
||||
showNotification: false,
|
||||
),
|
||||
android: AndroidParams(
|
||||
isCustomNotification: true,
|
||||
isShowLogo: false,
|
||||
ringtonePath: 'system_ringtone_default',
|
||||
backgroundColor: '#0B0C12',
|
||||
actionColor: '#7FAE8C',
|
||||
textColor: '#F0EEE8',
|
||||
incomingCallNotificationChannelName: _localTexts.$3,
|
||||
textAccept: _localTexts.$1,
|
||||
textDecline: _localTexts.$2,
|
||||
isShowFullLockedScreen: true,
|
||||
),
|
||||
ios: const IOSParams(
|
||||
handleType: 'generic',
|
||||
supportsVideo: true,
|
||||
maximumCallGroups: 1,
|
||||
maximumCallsPerCallGroup: 1,
|
||||
supportsDTMF: false,
|
||||
supportsHolding: false,
|
||||
supportsGrouping: false,
|
||||
supportsUngrouping: false,
|
||||
ringtonePath: 'system_ringtone_default',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,12 @@ class AppConstants {
|
||||
AppConstants._();
|
||||
|
||||
static const String appName = 'Šepot';
|
||||
/// Musí sedět s `version:` v pubspec.yaml (bez build čísla).
|
||||
static const String appVersion = '1.1.0';
|
||||
static const String appAuthor = 'ajtak.it';
|
||||
|
||||
/// Id konverzace „Poznámky pro sebe“. Nekoliduje s fingerprintem (hex)
|
||||
/// ani s gid skupiny (UUID); podle něj se pozná, že nic nejde do sítě.
|
||||
static const String notesConversationId = 'notes';
|
||||
|
||||
// Relay server (REST + WS na jednom portu). Přepsatelné při buildu:
|
||||
// flutter build --dart-define=SEPOT_RELAY=http://<dev-relay-host>:8470
|
||||
static const String relayUrl = String.fromEnvironment(
|
||||
|
||||
@@ -53,16 +53,20 @@ class DekWrap {
|
||||
int memoryKiB = defaultMemoryKiB,
|
||||
int iterations = defaultIterations,
|
||||
int parallelism = defaultParallelism,
|
||||
Uint8List? salt,
|
||||
}) async {
|
||||
if (dek.length != 32) {
|
||||
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
|
||||
}
|
||||
_enforceFloor(memoryKiB, iterations);
|
||||
|
||||
final salt = _randomBytes(16);
|
||||
// Volitelná sdílená sůl: nátlakový blob se stejnou solí a parametry
|
||||
// jako skutečný odemyká jedinou derivací (viz unwrapFirstMatching) —
|
||||
// bez ní by decoy PIN trval 2× déle a šel poznat stopkami.
|
||||
final useSalt = salt ?? _randomBytes(16);
|
||||
final unlockKey = await _deriveUnlockKey(
|
||||
pin: pin,
|
||||
salt: salt,
|
||||
salt: useSalt,
|
||||
memoryKiB: memoryKiB,
|
||||
iterations: iterations,
|
||||
parallelism: parallelism,
|
||||
@@ -80,13 +84,72 @@ class DekWrap {
|
||||
'$memoryKiB',
|
||||
'$iterations',
|
||||
'$parallelism',
|
||||
base64Encode(salt),
|
||||
base64Encode(useSalt),
|
||||
base64Encode(box.nonce),
|
||||
base64Encode(box.cipherText),
|
||||
base64Encode(box.mac.bytes),
|
||||
].join(':');
|
||||
}
|
||||
|
||||
/// Sůl použitá v zabaleném blobu (pro sdílení s nátlakovým blobem).
|
||||
static Uint8List saltOf(String wrapped) {
|
||||
final parts = wrapped.split(':');
|
||||
if (parts.length != 8 || parts[0] != _version) {
|
||||
throw FormatException('Unknown wrapped DEK format', wrapped);
|
||||
}
|
||||
return Uint8List.fromList(base64Decode(parts[4]));
|
||||
}
|
||||
|
||||
/// Zkusí PIN proti několika blobům (skutečný + nátlakový) a vrátí index
|
||||
/// prvního, který sedí, s rozbaleným DEK. Argon2id se počítá jen jednou
|
||||
/// pro každou odlišnou kombinaci parametrů a soli, takže u blobů se
|
||||
/// sdílenou solí stojí odemčení stejně, ať sedí kterýkoli — čas
|
||||
/// odemčení neprozradí, který PIN byl zadán. Nic nesedí →
|
||||
/// [WrongPinException].
|
||||
static Future<(int, Uint8List)> unwrapFirstMatching({
|
||||
required List<String> wrapped,
|
||||
required String pin,
|
||||
}) async {
|
||||
final parsed = <List<String>>[];
|
||||
for (final w in wrapped) {
|
||||
final parts = w.split(':');
|
||||
if (parts.length != 8 || parts[0] != _version) {
|
||||
throw FormatException('Unknown wrapped DEK format', w);
|
||||
}
|
||||
_enforceFloor(int.parse(parts[1]), int.parse(parts[2]));
|
||||
parsed.add(parts);
|
||||
}
|
||||
final keys = <String, Uint8List>{};
|
||||
for (final parts in parsed) {
|
||||
final group = parts.sublist(1, 5).join(':');
|
||||
keys[group] ??= await _deriveUnlockKey(
|
||||
pin: pin,
|
||||
salt: base64Decode(parts[4]),
|
||||
memoryKiB: int.parse(parts[1]),
|
||||
iterations: int.parse(parts[2]),
|
||||
parallelism: int.parse(parts[3]),
|
||||
);
|
||||
}
|
||||
final aes = AesGcm.with256bits();
|
||||
for (var i = 0; i < parsed.length; i++) {
|
||||
final parts = parsed[i];
|
||||
try {
|
||||
final dek = await aes.decrypt(
|
||||
SecretBox(
|
||||
base64Decode(parts[6]),
|
||||
nonce: base64Decode(parts[5]),
|
||||
mac: Mac(base64Decode(parts[7])),
|
||||
),
|
||||
secretKey: SecretKey(keys[parts.sublist(1, 5).join(':')]!),
|
||||
);
|
||||
return (i, Uint8List.fromList(dek));
|
||||
} on SecretBoxAuthenticationError {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw const WrongPinException();
|
||||
}
|
||||
|
||||
/// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí
|
||||
/// [WrongPinException]; na neplatném formátu [FormatException].
|
||||
static Future<Uint8List> unwrap({
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/// Admin-signed membership pro skupiny.
|
||||
///
|
||||
/// Původní skupiny nemají role: roster update přijímají od kteréhokoli člena. V2 přidává
|
||||
/// admin/member role a KAŽDOU změnu členství/role/jména podepisuje admin svým
|
||||
/// identity klíčem (XEdDSA). Ostatní klienti podpis ověří proti identity klíči
|
||||
/// admina z rosteru — server tu roli nehraje (sealed sender, hloupý relay).
|
||||
///
|
||||
/// Podpis pokrývá VÝSLEDNÝ STAV (setříděné množiny členů a adminů + jméno +
|
||||
/// epocha) zřetězený na hash předchozího stavu (`prev`) — admin autorizuje
|
||||
/// konkrétní výsledek navázaný na známou historii, ne jen delta. Tím padá
|
||||
/// nejednoznačnost i fork: kdo nesedí na `prev`, ví, že se rozešel a musí
|
||||
/// resync.
|
||||
///
|
||||
/// Kanonická serializace je záměrně délkově-prefixovaná: přesun bajtů mezi poli
|
||||
/// (fingerprint ↔ jméno apod.) MUSÍ změnit podepisované bajty, jinak by šlo
|
||||
/// zaměnit význam podpisu.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:libsignal/libsignal.dart' show PublicKey;
|
||||
import 'package:pointycastle/digests/sha256.dart';
|
||||
|
||||
/// Typ admin operace. Pořadí (index) je součást podpisu — NIKDY nepřehazovat,
|
||||
/// jen přidávat na konec (jinak se rozbijí staré podpisy).
|
||||
///
|
||||
/// `sync` není změna: správce jím podepisuje svůj aktuální stav jako odpověď
|
||||
/// na žádost o resync (mezera v epoše / rozchod historie u příjemce).
|
||||
enum GroupAdminAction { add, remove, promote, demote, rename, sync }
|
||||
|
||||
/// Doménové separátory — jiný kontext = jiné podepisované bajty.
|
||||
const String _opDomain = 'sepot-group-adm-v1';
|
||||
const String _stateDomain = 'sepot-group-state-v1';
|
||||
|
||||
/// Jedna admin operace = autorizovaný přechod skupiny do výsledného stavu.
|
||||
class GroupAdminOp {
|
||||
GroupAdminOp({
|
||||
required this.gid,
|
||||
required this.epoch,
|
||||
required this.action,
|
||||
required this.actor,
|
||||
required List<String> members,
|
||||
required List<String> admins,
|
||||
required this.name,
|
||||
required this.prev,
|
||||
}) : members = _sortedUnique(members),
|
||||
admins = _sortedUnique(admins);
|
||||
|
||||
/// UUID skupiny (`gid` na drátu).
|
||||
final String gid;
|
||||
|
||||
/// Epocha, kterou tato operace ustavuje (monotónní, prev + 1).
|
||||
final int epoch;
|
||||
|
||||
final GroupAdminAction action;
|
||||
|
||||
/// Fingerprint podepisujícího admina. MUSÍ se rovnat sealed-sender
|
||||
/// fingerprintu odesílatele (anti-spoof).
|
||||
final String actor;
|
||||
|
||||
/// Výsledný roster (setříděné fingerprinty, včetně nezměněných členů).
|
||||
final List<String> members;
|
||||
|
||||
/// Výsledná množina adminů (setříděné fingerprinty, podmnožina `members`).
|
||||
final List<String> admins;
|
||||
|
||||
/// Výsledné jméno skupiny.
|
||||
final String name;
|
||||
|
||||
/// Hash předchozího stavu (`stateHash` epochy `epoch-1`); prázdné pro
|
||||
/// založení (epoch 0).
|
||||
final Uint8List prev;
|
||||
|
||||
/// Podepisované bajty — kanonické, délkově-prefixované, deterministické.
|
||||
Uint8List canonicalBytes() {
|
||||
final b = BytesBuilder(copy: false);
|
||||
b.add(_lpStr(_opDomain));
|
||||
b.add(_lpStr(gid));
|
||||
b.add(_u64be(epoch));
|
||||
b.addByte(action.index);
|
||||
b.add(_lpStr(actor));
|
||||
b.add(_lpList(members));
|
||||
b.add(_lpList(admins));
|
||||
b.add(_lpStr(name));
|
||||
b.add(_lp(prev));
|
||||
return b.toBytes();
|
||||
}
|
||||
|
||||
/// Hash tohoto výsledného stavu — vstupuje jako `prev` do NÁSLEDUJÍCÍ
|
||||
/// operace (řetěz proti forku).
|
||||
Uint8List stateHash() => hashState(
|
||||
gid: gid,
|
||||
epoch: epoch,
|
||||
members: members,
|
||||
admins: admins,
|
||||
name: name,
|
||||
);
|
||||
|
||||
/// XEdDSA podpis operace daným signerem (typicky `SepotCrypto.sign`).
|
||||
Uint8List signWith(Uint8List Function(List<int> message) signer) =>
|
||||
signer(canonicalBytes());
|
||||
|
||||
/// Ověří podpis proti identity klíči admina (33 B serializovaný pub klíč).
|
||||
/// Volající NAVÍC musí ověřit, že `actor` je v aktuální množině adminů a že
|
||||
/// `actor` == sealed-sender fingerprint — to zde nevidíme.
|
||||
bool verify(Uint8List signature, Uint8List actorIdentityKey) {
|
||||
final pk = PublicKey.deserialize(bytes: actorIdentityKey.toList());
|
||||
return pk.verify(
|
||||
message: canonicalBytes().toList(),
|
||||
signature: signature.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash stavu skupiny (setříděné množiny) — sdílený mezi op.stateHash a
|
||||
/// výpočtem `prev` u příjemce.
|
||||
Uint8List hashState({
|
||||
required String gid,
|
||||
required int epoch,
|
||||
required List<String> members,
|
||||
required List<String> admins,
|
||||
required String name,
|
||||
}) {
|
||||
final b = BytesBuilder(copy: false);
|
||||
b.add(_lpStr(_stateDomain));
|
||||
b.add(_lpStr(gid));
|
||||
b.add(_u64be(epoch));
|
||||
b.add(_lpList(_sortedUnique(members)));
|
||||
b.add(_lpList(_sortedUnique(admins)));
|
||||
b.add(_lpStr(name));
|
||||
return Uint8List.fromList(SHA256Digest().process(b.toBytes()));
|
||||
}
|
||||
|
||||
// ── Kanonické kódování ────────────────────────────────────────────────────
|
||||
|
||||
List<String> _sortedUnique(List<String> xs) {
|
||||
final s = xs.toSet().toList()..sort();
|
||||
return s;
|
||||
}
|
||||
|
||||
Uint8List _lp(List<int> bytes) {
|
||||
final b = BytesBuilder(copy: false);
|
||||
b.add(_u32be(bytes.length));
|
||||
b.add(bytes);
|
||||
return b.toBytes();
|
||||
}
|
||||
|
||||
Uint8List _lpStr(String s) => _lp(utf8.encode(s));
|
||||
|
||||
Uint8List _lpList(List<String> items) {
|
||||
final b = BytesBuilder(copy: false);
|
||||
b.add(_u32be(items.length));
|
||||
for (final item in items) {
|
||||
b.add(_lpStr(item));
|
||||
}
|
||||
return b.toBytes();
|
||||
}
|
||||
|
||||
Uint8List _u32be(int n) {
|
||||
final d = ByteData(4)..setUint32(0, n, Endian.big);
|
||||
return d.buffer.asUint8List();
|
||||
}
|
||||
|
||||
Uint8List _u64be(int n) {
|
||||
final d = ByteData(8)..setUint64(0, n, Endian.big);
|
||||
return d.buffer.asUint8List();
|
||||
}
|
||||
@@ -81,6 +81,32 @@ class SepotSealedMessage {
|
||||
}
|
||||
|
||||
/// Jeden one-time prekey k publikaci na relay.
|
||||
/// Nový signed prekey k nahrání na relay (rotace).
|
||||
class SepotSignedPreKey {
|
||||
SepotSignedPreKey({
|
||||
required this.id,
|
||||
required this.publicKey,
|
||||
required this.signature,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final Uint8List publicKey;
|
||||
final Uint8List signature;
|
||||
}
|
||||
|
||||
/// Nový last-resort Kyber prekey k nahrání na relay.
|
||||
class SepotKyberPreKey {
|
||||
SepotKyberPreKey({
|
||||
required this.id,
|
||||
required this.publicKey,
|
||||
required this.signature,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final Uint8List publicKey;
|
||||
final Uint8List signature;
|
||||
}
|
||||
|
||||
class SepotOneTimePreKey {
|
||||
SepotOneTimePreKey({required this.id, required this.publicKey});
|
||||
|
||||
@@ -420,4 +446,64 @@ class SepotCrypto {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Nový signed prekey (rotace). Starý zůstává ve store — relace založené
|
||||
/// z bundle těsně před rotací ho ještě potřebují.
|
||||
Future<SepotSignedPreKey> generateSignedPreKey({
|
||||
required int id,
|
||||
required int timestampMs,
|
||||
}) async {
|
||||
final identityPrivate = PrivateKey.deserialize(
|
||||
bytes: _identity.privateKey.toList(),
|
||||
);
|
||||
final private = PrivateKey.generate();
|
||||
final public = private.getPublicKey();
|
||||
final signature = identityPrivate.sign(
|
||||
message: public.serialize().toList(),
|
||||
);
|
||||
await _stores.signedPreKey.storeSignedPreKey(
|
||||
id,
|
||||
SignedPreKeyRecord(
|
||||
id: id,
|
||||
publicKey: public,
|
||||
privateKey: private,
|
||||
signature: signature.toList(),
|
||||
timestamp: BigInt.from(timestampMs),
|
||||
),
|
||||
);
|
||||
return SepotSignedPreKey(
|
||||
id: id,
|
||||
publicKey: Uint8List.fromList(public.serialize().toList()),
|
||||
signature: Uint8List.fromList(signature.toList()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Nový last-resort Kyber prekey (rotace PQXDH kotvy).
|
||||
Future<SepotKyberPreKey> generateKyberPreKey({
|
||||
required int id,
|
||||
required int timestampMs,
|
||||
}) async {
|
||||
final identityPrivate = PrivateKey.deserialize(
|
||||
bytes: _identity.privateKey.toList(),
|
||||
);
|
||||
final keyPair = KyberKeyPair.generate();
|
||||
final public = keyPair.getPublicKey();
|
||||
final signature = identityPrivate.sign(
|
||||
message: public.serialize().toList(),
|
||||
);
|
||||
await _stores.kyberPreKey.storeKyberPreKey(
|
||||
id,
|
||||
KyberPreKeyRecord.create(
|
||||
id: id,
|
||||
keyPair: keyPair,
|
||||
signature: signature.toList(),
|
||||
timestamp: BigInt.from(timestampMs),
|
||||
),
|
||||
);
|
||||
return SepotKyberPreKey(
|
||||
id: id,
|
||||
publicKey: Uint8List.fromList(public.serialize().toList()),
|
||||
signature: Uint8List.fromList(signature.toList()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,30 @@ class AttachmentTooLargeError extends SepotUserError {
|
||||
String toString() => 'attachment too large (max $maxBytes bytes)';
|
||||
}
|
||||
|
||||
/// Příloha pro přeposlání už v telefonu není (smazaná, mizící, nestažená).
|
||||
class AttachmentUnavailableError extends SepotUserError {
|
||||
const AttachmentUnavailableError();
|
||||
|
||||
@override
|
||||
String toString() => 'attachment not available locally';
|
||||
}
|
||||
|
||||
/// Změnu skupiny smí udělat jen správce.
|
||||
class NotGroupAdminError extends SepotUserError {
|
||||
const NotGroupAdminError();
|
||||
|
||||
@override
|
||||
String toString() => 'only a group admin can do this';
|
||||
}
|
||||
|
||||
/// Poslední správce se nesmí odebrat ani zbavit role.
|
||||
class LastGroupAdminError extends SepotUserError {
|
||||
const LastGroupAdminError();
|
||||
|
||||
@override
|
||||
String toString() => 'the last group admin cannot step down';
|
||||
}
|
||||
|
||||
/// Skupina bez jediného dalšího člena nedává smysl.
|
||||
class GroupNeedsMemberError extends SepotUserError {
|
||||
const GroupNeedsMemberError();
|
||||
|
||||
@@ -15,7 +15,10 @@ String describeError(AppLocalizations l10n, Object error) => switch (error) {
|
||||
ContactLinkIncompleteError() => l10n.errorContactLinkIncomplete,
|
||||
AttachmentTooLargeError(:final maxMb) =>
|
||||
l10n.errorAttachmentTooLarge(maxMb),
|
||||
AttachmentUnavailableError() => l10n.errorAttachmentUnavailable,
|
||||
GroupNeedsMemberError() => l10n.errorGroupNeedsMember,
|
||||
NotGroupAdminError() => l10n.errorGroupNotAdmin,
|
||||
LastGroupAdminError() => l10n.errorGroupLastAdmin,
|
||||
GroupTooLargeError(:final maxMembers) =>
|
||||
l10n.errorGroupMaxMembers(maxMembers),
|
||||
MemberNoDeliveryAddressError(:final displayName) =>
|
||||
|
||||
@@ -63,9 +63,18 @@ class RelayClient {
|
||||
StreamController<RelayConnectionState>.broadcast();
|
||||
final _deliverController = StreamController<pb.Envelope>.broadcast();
|
||||
final _receiptController = StreamController<pb.Receipt>.broadcast();
|
||||
final _turnController = StreamController<pb.TurnCredentials>.broadcast();
|
||||
final _prekeysLowController = StreamController<int>.broadcast();
|
||||
|
||||
var _state = RelayConnectionState.disconnected;
|
||||
|
||||
/// Kdy naposled přišel aplikační rámec od relaye.
|
||||
DateTime _lastFrameAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
static const _staleAfter = Duration(seconds: 20);
|
||||
|
||||
/// Relay hlásí docházející one-time prekeys (zbývající počet).
|
||||
Stream<int> get prekeysLow => _prekeysLowController.stream;
|
||||
|
||||
RelayConnectionState get state => _state;
|
||||
Stream<RelayConnectionState> get stateStream => _stateController.stream;
|
||||
|
||||
@@ -99,9 +108,18 @@ class RelayClient {
|
||||
required List<SepotOneTimePreKey> oneTimePreKeys,
|
||||
required Uint8List deliveryAddress,
|
||||
}) async {
|
||||
// Důkaz držení identity klíče: obnova ze 24 slov na novém telefonu
|
||||
// narazí na relayi na už existující identitu (409) a bez tohohle
|
||||
// podpisu by ji nikdy nepřevzala. Starší relay hlavičku
|
||||
// ignoruje.
|
||||
final proofTs = DateTime.now().millisecondsSinceEpoch;
|
||||
final proof = _crypto.sign(utf8.encode('POST /v1/register\n$proofTs'));
|
||||
final resp = await _transport.httpClient().post(
|
||||
Uri.parse('$baseUrl/v1/register'),
|
||||
headers: {'content-type': 'application/json'},
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-sepot-identity-proof': '$proofTs.${base64Encode(proof)}',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'identity_key': base64Encode(_crypto.identityPublicKey),
|
||||
'device_id': 1,
|
||||
@@ -143,6 +161,49 @@ class RelayClient {
|
||||
_crypto.bindAccount(accountId);
|
||||
}
|
||||
|
||||
/// POST /v1/keys — doplnění one-time prekeys a rotace signed / Kyber
|
||||
/// prekey. Vrací počet OTP na relayi po nahrání.
|
||||
Future<int> uploadKeys({
|
||||
SepotSignedPreKey? signedPreKey,
|
||||
SepotKyberPreKey? kyberPreKey,
|
||||
List<SepotOneTimePreKey> oneTimePreKeys = const [],
|
||||
}) async {
|
||||
final deviceUuid = _requireDevice();
|
||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||
final signature = _crypto.sign(utf8.encode('POST /v1/keys\n$ts'));
|
||||
final resp = await _transport.httpClient().post(
|
||||
Uri.parse('$baseUrl/v1/keys'),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-sepot-auth': '$deviceUuid.$ts.${base64Encode(signature)}',
|
||||
},
|
||||
body: jsonEncode({
|
||||
if (signedPreKey != null)
|
||||
'signed_prekey': {
|
||||
'key_id': signedPreKey.id,
|
||||
'public_key': base64Encode(signedPreKey.publicKey),
|
||||
'signature': base64Encode(signedPreKey.signature),
|
||||
},
|
||||
if (kyberPreKey != null)
|
||||
'kyber_prekey': {
|
||||
'key_id': kyberPreKey.id,
|
||||
'public_key': base64Encode(kyberPreKey.publicKey),
|
||||
'signature': base64Encode(kyberPreKey.signature),
|
||||
'is_last_resort': true,
|
||||
},
|
||||
'one_time_prekeys': [
|
||||
for (final otp in oneTimePreKeys)
|
||||
{'key_id': otp.id, 'public_key': base64Encode(otp.publicKey)},
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (resp.statusCode != 200) {
|
||||
throw RelayException('prekey upload failed', resp.statusCode, resp.body);
|
||||
}
|
||||
final body = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
return (body['one_time_prekeys'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
|
||||
/// GET /v1/keys/{addr} — bundle protistrany (spotřebuje 1 OTP).
|
||||
Future<SepotPreKeyBundle> fetchBundle(Uint8List deliveryAddress) async {
|
||||
final hexAddr = deliveryAddress
|
||||
@@ -279,6 +340,20 @@ class RelayClient {
|
||||
_setState(RelayConnectionState.disconnected);
|
||||
}
|
||||
|
||||
/// Budíček od relaye znamená, že nás nevidí jako živé. Spojení, které
|
||||
/// hlásí „připojeno", ale [_staleAfter] nic nepřijalo, je po změně sítě
|
||||
/// nejspíš zombie — zavřít a navázat hned, ne až po ping timeoutu
|
||||
/// (pojistka proti zombie spojení na straně appky). Jinak jen [start].
|
||||
void kick() {
|
||||
if (_state == RelayConnectionState.connected &&
|
||||
DateTime.now().difference(_lastFrameAt) > _staleAfter) {
|
||||
_reconnectDelayS = 1;
|
||||
_onDisconnected();
|
||||
return;
|
||||
}
|
||||
start();
|
||||
}
|
||||
|
||||
Future<void> _teardownChannel() async {
|
||||
await _wsSub?.cancel();
|
||||
_wsSub = null;
|
||||
@@ -312,6 +387,7 @@ class RelayClient {
|
||||
_wsSub = channel.stream.listen(
|
||||
(data) {
|
||||
final frame = pb.ServerFrame.fromBuffer(data as List<int>);
|
||||
_lastFrameAt = DateTime.now();
|
||||
if (!authenticated) {
|
||||
if (frame.hasChallenge()) {
|
||||
final signature = _crypto.sign([
|
||||
@@ -340,6 +416,14 @@ class RelayClient {
|
||||
if (!_receiptController.isClosed) {
|
||||
_receiptController.add(frame.receipt);
|
||||
}
|
||||
} else if (frame.hasTurnCredentials()) {
|
||||
if (!_turnController.isClosed) {
|
||||
_turnController.add(frame.turnCredentials);
|
||||
}
|
||||
} else if (frame.hasPrekeysLow()) {
|
||||
if (!_prekeysLowController.isClosed) {
|
||||
_prekeysLowController.add(frame.prekeysLow.remaining);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: _onDisconnected,
|
||||
@@ -373,27 +457,46 @@ class RelayClient {
|
||||
}
|
||||
|
||||
/// Odešle sealed-sender obálku. Vrací Receipt (STORED_OFFLINE /
|
||||
/// DELIVERED_ONLINE) spárovaný přes msg_uuid.
|
||||
/// DELIVERED_ONLINE, u EPHEMERAL i DROPPED) spárovaný přes msg_uuid.
|
||||
Future<pb.Receipt> sendEnvelope({
|
||||
required Uint8List dstAddress,
|
||||
required Uint8List payload,
|
||||
pb.ContentHint hint = pb.ContentHint.CONTENT_HINT_DEFAULT,
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) async {
|
||||
final msgUuid = randomBytes(16);
|
||||
final receiptFuture = _receiptController.stream
|
||||
.firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid))
|
||||
.timeout(timeout);
|
||||
_send(pb.ClientFrame(
|
||||
envelope: pb.Envelope(
|
||||
dstAddress: dstAddress,
|
||||
msgUuid: msgUuid,
|
||||
contentHint: pb.ContentHint.CONTENT_HINT_DEFAULT,
|
||||
payload: payload,
|
||||
),
|
||||
));
|
||||
try {
|
||||
_send(pb.ClientFrame(
|
||||
envelope: pb.Envelope(
|
||||
dstAddress: dstAddress,
|
||||
msgUuid: msgUuid,
|
||||
contentHint: hint,
|
||||
payload: payload,
|
||||
),
|
||||
));
|
||||
} catch (_) {
|
||||
// Odeslání selhalo (kanál zavřený) — čekající future by za 10 s
|
||||
// hodil neodchycený TimeoutException do zvonku chyb.
|
||||
receiptFuture.ignore();
|
||||
rethrow;
|
||||
}
|
||||
return receiptFuture;
|
||||
}
|
||||
|
||||
/// Dočasné údaje k TURN serveru pro hovor. Relay je vydá jen přihlášenému
|
||||
/// zařízení; bez TURN na relayi (503) doběhne timeout a hovor zkusí jen
|
||||
/// přímé spojení přes STUN.
|
||||
Future<pb.TurnCredentials> requestTurn({
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) {
|
||||
final future = _turnController.stream.first.timeout(timeout);
|
||||
_send(pb.ClientFrame(turnRequest: pb.TurnRequest()));
|
||||
return future;
|
||||
}
|
||||
|
||||
/// Potvrdí zpracování obálky — relay ji smaže z fronty. Volat až PO
|
||||
/// uložení zprávy na disk.
|
||||
void ack(List<int> msgUuid) {
|
||||
@@ -437,6 +540,8 @@ class RelayClient {
|
||||
await stop();
|
||||
_transport.close();
|
||||
await _stateController.close();
|
||||
await _turnController.close();
|
||||
await _prekeysLowController.close();
|
||||
await _deliverController.close();
|
||||
await _receiptController.close();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,15 @@ import 'package:socks5_proxy/socks_client.dart';
|
||||
import 'package:web_socket_channel/io.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.
|
||||
abstract class RelayTransport {
|
||||
/// 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();
|
||||
|
||||
@override
|
||||
WebSocketChannel connectWs(Uri url) => WebSocketChannel.connect(url);
|
||||
WebSocketChannel connectWs(Uri url) =>
|
||||
IOWebSocketChannel.connect(url, pingInterval: kWsPingInterval);
|
||||
|
||||
@override
|
||||
void close() {
|
||||
@@ -84,7 +94,7 @@ class TorTransport implements RelayTransport {
|
||||
@override
|
||||
WebSocketChannel connectWs(Uri 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
|
||||
/// 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_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:sepot/core/calls/call_ringer.dart';
|
||||
import 'package:sepot/core/push/apns_ios.dart';
|
||||
import 'package:sepot/core/push/push_notifier.dart';
|
||||
import 'package:sepot/core/push/push_service.dart';
|
||||
@@ -28,8 +29,14 @@ import 'package:unifiedpush/unifiedpush.dart';
|
||||
/// FCM budíček se zabitou appkou — plugin spouští handler ve vlastním
|
||||
/// background isolate. Stejný model jako UnifiedPush killed-state:
|
||||
/// DB je zamčená, nic nejde dešifrovat → jen generická notifikace.
|
||||
/// Budíček hovoru (`k=call`) místo ní rovnou zazvoní; kdo
|
||||
/// volá, appka zjistí až po odemčení z nabídky ve frontě.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> sepotFcmBackgroundWake(RemoteMessage message) async {
|
||||
if (message.data['k'] == 'call') {
|
||||
await CallRinger.ringFromPush();
|
||||
return;
|
||||
}
|
||||
await Firebase.initializeApp();
|
||||
await PushNotifier.showWakeNotification();
|
||||
}
|
||||
@@ -45,18 +52,32 @@ class PushBootstrap {
|
||||
|
||||
static const _pendingEndpointKey = 'pending_push_endpoint';
|
||||
static const _modeKey = 'push_mode'; // 'off' | 'up' | 'fcm'
|
||||
static const _registrationFailedKey = 'push_registration_failed';
|
||||
static const _store = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
/// Režim pushů žije v OS keystore (NE v SQLCipher DB) — musí být
|
||||
/// čitelný před unlockem, aby main() věděl, zda registrovat FCM.
|
||||
static Future<String> readMode() async =>
|
||||
await _store.read(key: _modeKey) ?? 'off';
|
||||
static Future<String> readMode() async => await readStoredMode() ?? 'off';
|
||||
|
||||
/// Uložený režim, nebo null, když ho uživatel ani appka ještě nevybrali
|
||||
/// — to odlišuje čerstvou instalaci od vědomého „vypnuto".
|
||||
static Future<String?> readStoredMode() => _store.read(key: _modeKey);
|
||||
|
||||
static Future<void> writeMode(String mode) =>
|
||||
_store.write(key: _modeKey, value: mode);
|
||||
|
||||
/// 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 {
|
||||
backgroundStart = args.contains('--unifiedpush-bg');
|
||||
|
||||
@@ -66,10 +87,13 @@ class PushBootstrap {
|
||||
if (Platform.isIOS) {
|
||||
ApnsBridge.onWake = _wake;
|
||||
ApnsBridge.ensureHandler();
|
||||
// Zvonění z VoIP pushe bez nabídky nesmí zvonit celých 45 s.
|
||||
unawaited(CallRinger.expireStalePushRings());
|
||||
} else {
|
||||
await UnifiedPush.initialize(
|
||||
onNewEndpoint: _onNewEndpoint,
|
||||
onRegistrationFailed: (_, _) {},
|
||||
onRegistrationFailed: (_, _) =>
|
||||
unawaited(_setRegistrationFailed(true)),
|
||||
onUnregistered: _onUnregistered,
|
||||
onMessage: _onMessage,
|
||||
);
|
||||
@@ -114,6 +138,7 @@ class PushBootstrap {
|
||||
}
|
||||
|
||||
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
|
||||
unawaited(_setRegistrationFailed(false)); // registrace se povedla
|
||||
final s = _service;
|
||||
if (s != null) {
|
||||
s.handleNewEndpoint(endpoint.url);
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
/// killed-state startu `--unifiedpush-bg` bez UI); tahle třída je
|
||||
/// most bootstrap ↔ messaging + API pro nastavení.
|
||||
///
|
||||
/// FCM „fast mode" (opt-in) je druhá cesta (viz [setModeFcm]); default je
|
||||
/// degooglovaný UnifiedPush.
|
||||
/// Výchozí režim na Androidu vybírá [restoreMode] při prvním startu: s
|
||||
/// UnifiedPush distributorem UnifiedPush, jinak FCM. Bez budíčku Android
|
||||
/// appku na pozadí uspí a zprávy čekají, než ji člověk otevře — to je pro
|
||||
/// messenger horší než token u Googlu (obsah ani odesílatel budíčkem nejde).
|
||||
/// Uživatel může kdykoli přepnout v Nastavení.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
@@ -24,6 +27,7 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:firebase_core/firebase_core.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/push_bootstrap.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.)
|
||||
bool get canWake => _messaging.isStarted;
|
||||
|
||||
void wake() => _messaging.onPushWake();
|
||||
/// Budíček: běžící messaging připojí WS; po auto-locku (zamčená DB) aspoň
|
||||
/// generická notifikace bez obsahu, ať se uživatel o zprávě dozví.
|
||||
void wake() {
|
||||
if (canWake) {
|
||||
_messaging.onPushWake();
|
||||
} else {
|
||||
unawaited(PushNotifier.showWakeNotification());
|
||||
}
|
||||
}
|
||||
|
||||
void handleNewEndpoint(String url) {
|
||||
_messaging.registerPush(_registration(
|
||||
@@ -80,11 +92,25 @@ class PushService {
|
||||
}
|
||||
}
|
||||
|
||||
void _registerApnsToken(String token) {
|
||||
_messaging.registerPush(_registration(
|
||||
String? _apnsToken;
|
||||
|
||||
/// 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,
|
||||
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
|
||||
@@ -112,10 +138,14 @@ class PushService {
|
||||
}
|
||||
|
||||
/// Seznam nainstalovaných distributorů (pro výběr v nastavení).
|
||||
Future<List<String>> distributors() => UnifiedPush.getDistributors();
|
||||
/// UnifiedPush existuje jen na Androidu; jinde by volání pluginu vyhodilo
|
||||
/// UnimplementedError (iOS: dlaždice Push v Nastavení).
|
||||
Future<List<String>> distributors() async =>
|
||||
Platform.isAndroid ? UnifiedPush.getDistributors() : const [];
|
||||
|
||||
/// Aktuálně uložený distributor (null = push vypnutý / nevybraný).
|
||||
Future<String?> currentDistributor() => UnifiedPush.getDistributor();
|
||||
Future<String?> currentDistributor() async =>
|
||||
Platform.isAndroid ? UnifiedPush.getDistributor() : null;
|
||||
|
||||
/// Vybere konkrétního distributora a zaregistruje se u něj.
|
||||
Future<void> useDistributor(String distributor) async {
|
||||
@@ -132,7 +162,7 @@ class PushService {
|
||||
}
|
||||
|
||||
Future<bool> get isEnabled async =>
|
||||
(await UnifiedPush.getDistributor()) != null;
|
||||
Platform.isAndroid && (await UnifiedPush.getDistributor()) != null;
|
||||
|
||||
// ── Režimy (off / up / fcm) ─────────────────────────────────────────
|
||||
|
||||
@@ -141,9 +171,14 @@ class PushService {
|
||||
Future<String> currentMode() async {
|
||||
final mode = await PushBootstrap.readMode();
|
||||
if (mode != 'off') return mode;
|
||||
if (!Platform.isAndroid) return mode;
|
||||
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
await _teardownFcm(deleteToken: true);
|
||||
try {
|
||||
@@ -162,8 +197,8 @@ class PushService {
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// FCM fast mode — OPT-IN: registrační token zařízení jde Googlu
|
||||
/// (obsah budíčků nikdy, ťuk je konstantní `{"v":1}`).
|
||||
/// FCM: registrační token zařízení jde Googlu (obsah budíčků nikdy,
|
||||
/// ťuk je konstantní `{"v":1}`).
|
||||
Future<void> setModeFcm() async {
|
||||
await init();
|
||||
try {
|
||||
@@ -218,6 +253,10 @@ class PushService {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (await PushBootstrap.readStoredMode() == null) {
|
||||
await _chooseDefaultMode();
|
||||
return;
|
||||
}
|
||||
switch (await currentMode()) {
|
||||
case 'up':
|
||||
await enable();
|
||||
@@ -231,4 +270,23 @@ class PushService {
|
||||
await init(); // aspoň attach (killed-state notifikace)
|
||||
}
|
||||
}
|
||||
|
||||
/// První start na Androidu (nebo instalace z doby, kdy byl push
|
||||
/// opt-in a nikdo ho nezapnul). Kdo má nainstalovaný UnifiedPush
|
||||
/// distributor, vybral si degooglovanou cestu sám — dostane ji.
|
||||
/// Ostatním zapneme FCM. Telefon bez Google služeb token nedostane;
|
||||
/// pak zapíšeme „off", ať se to nezkouší při každém startu — seznam
|
||||
/// chatů na chybějící budíčky upozorní sám.
|
||||
Future<void> _chooseDefaultMode() async {
|
||||
try {
|
||||
if ((await currentMode()) == 'up' ||
|
||||
(await distributors()).isNotEmpty) {
|
||||
if (await setModeUnifiedPush()) return;
|
||||
}
|
||||
await setModeFcm();
|
||||
} catch (_) {
|
||||
await init();
|
||||
await PushBootstrap.writeMode('off');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:sepot/core/app_info.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/errors.dart';
|
||||
|
||||
@@ -67,12 +68,16 @@ class ReportService {
|
||||
throw const ReportEmptyDescriptionError();
|
||||
}
|
||||
|
||||
// S build číslem, ne jen „1.1.2" — jinak nejde poznat, ze kterého buildu
|
||||
// hlášení přišlo. Neznámou verzi radši vynechat než poslat vymyšlenou.
|
||||
final verze = AppInfo.versionWithBuild;
|
||||
|
||||
final body = <String, dynamic>{
|
||||
'kategorie': category.wire,
|
||||
'popis': popis,
|
||||
'zdroj': 'app',
|
||||
'platforma': Platform.operatingSystem,
|
||||
'verze': AppConstants.appVersion,
|
||||
'verze': ?verze,
|
||||
if (reportedFingerprint != null && reportedFingerprint.isNotEmpty)
|
||||
'nahlasenyOtisk': reportedFingerprint,
|
||||
if (replyTo != null && replyTo.trim().isNotEmpty)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'dart:isolate';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:sepot/core/services/image_processor.dart';
|
||||
|
||||
/// Strana profilové fotky v pixelech — víc se v appce nikde nezobrazí.
|
||||
const avatarSide = 256;
|
||||
|
||||
/// Vybranou fotku otočí podle EXIF, ořízne na středový čtverec, zmenší
|
||||
/// a znovu zakóduje do JPEG bez metadat (EXIF, GPS, model telefonu).
|
||||
/// Null = obrázek nejde přečíst.
|
||||
Future<Uint8List?> prepareAvatarJpeg(Uint8List input) => Isolate.run(() {
|
||||
final img.Image? decoded;
|
||||
try {
|
||||
decoded = img.decodeImage(input);
|
||||
} on Object {
|
||||
// Dekodéry na poškozených datech háží místo null.
|
||||
return null;
|
||||
}
|
||||
if (decoded == null) return null;
|
||||
final upright = img.bakeOrientation(decoded);
|
||||
final side = math.min(upright.width, upright.height);
|
||||
final square = img.copyCrop(
|
||||
upright,
|
||||
x: (upright.width - side) ~/ 2,
|
||||
y: (upright.height - side) ~/ 2,
|
||||
width: side,
|
||||
height: side,
|
||||
);
|
||||
final small = img.copyResize(square,
|
||||
width: avatarSide,
|
||||
height: avatarSide,
|
||||
interpolation: img.Interpolation.average);
|
||||
ImageProcessor.stripMetadata(small);
|
||||
return Uint8List.fromList(img.encodeJpg(small, quality: 85));
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/// Pravidla správců skupin oddělená od sítě a
|
||||
/// úložiště: drátová podoba `gop`, ověření příchozí operace v pořadí 1–6
|
||||
/// ze specifikace, tvorba odchozí operace s invarianty a podržení operací
|
||||
/// z budoucích epoch.
|
||||
///
|
||||
/// Ověření podpisu se předává jako funkce — produkce volá libsignal
|
||||
/// (`GroupAdminOp.verify`), testy pravidel tak běží i bez nativní knihovny.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:sepot/core/crypto/group_admin.dart';
|
||||
import 'package:sepot/core/errors.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
|
||||
/// Ověří podpis [sig] operace [op] identity klíčem [identityKey].
|
||||
typedef GroupOpSignatureCheck = bool Function(
|
||||
GroupAdminOp op, Uint8List sig, Uint8List identityKey);
|
||||
|
||||
bool _libsignalVerify(GroupAdminOp op, Uint8List sig, Uint8List ik) {
|
||||
try {
|
||||
return op.verify(sig, ik);
|
||||
} catch (_) {
|
||||
// Nedeserializovatelný klíč/podpis z drátu = neplatný podpis, ne pád
|
||||
// příjmové cesty.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Výsledek ověření příchozí operace.
|
||||
enum GopVerdict {
|
||||
/// Platná a navazuje — aplikovat.
|
||||
apply,
|
||||
|
||||
/// `by` nesedí se sealed-sender autorem (krok 1).
|
||||
spoof,
|
||||
|
||||
/// Porušené invarianty nebo cizí skupina — nikdy nepůjde aplikovat.
|
||||
invalid,
|
||||
|
||||
/// Podepsal někdo, kdo u mě není správce (krok 2).
|
||||
notAdmin,
|
||||
|
||||
/// Podpis nesedí nebo neznám identity klíč podepisujícího (krok 3).
|
||||
badSignature,
|
||||
|
||||
/// Epocha přeskakuje — chybí mi mezistav (krok 4, `>`).
|
||||
gap,
|
||||
|
||||
/// Epochu už mám nebo jsem dál (krok 4, `<=`).
|
||||
stale,
|
||||
|
||||
/// Navazuje na jiný předchozí stav než můj (krok 5).
|
||||
fork,
|
||||
}
|
||||
|
||||
/// Drátová podoba `gop`: pole tabulky ze specifikace. Samostatně jako
|
||||
/// `t: 'gop'`, v kompatibilním režimu vnořená pod klíčem `gop` v legacy
|
||||
/// `ginv`/`gmeta` (v1 klient neznámé pole ignoruje a použije stín).
|
||||
class GroupOpMessage {
|
||||
GroupOpMessage({required this.op, required this.sig, this.roster});
|
||||
|
||||
final GroupAdminOp op;
|
||||
final Uint8List sig;
|
||||
|
||||
/// Roster entry `[{f,n,ik,aid,da,v}]` — u přidání nových členů a u
|
||||
/// pozvánky, ať nový člen zná klíče a routing ostatních.
|
||||
final List<Map<String, dynamic>>? roster;
|
||||
|
||||
Map<String, dynamic> toWire() => {
|
||||
'gid': op.gid,
|
||||
'epoch': op.epoch,
|
||||
'op': op.action.name,
|
||||
'by': op.actor,
|
||||
'members': op.members,
|
||||
'admins': op.admins,
|
||||
'gname': op.name,
|
||||
'prev': base64Encode(op.prev),
|
||||
'sig': base64Encode(sig),
|
||||
'roster': ?roster,
|
||||
};
|
||||
|
||||
/// Null = poškozená nebo neznámá podoba (neznámý typ operace z novější
|
||||
/// verze se nedá ověřit, protože jeho index je součást podpisu).
|
||||
static GroupOpMessage? fromWire(Object? raw) {
|
||||
if (raw is! Map) return null;
|
||||
try {
|
||||
final m = raw.cast<String, dynamic>();
|
||||
final action = GroupAdminAction.values
|
||||
.where((a) => a.name == m['op'])
|
||||
.firstOrNull;
|
||||
final epoch = m['epoch'];
|
||||
if (action == null || epoch is! int || epoch < 0) return null;
|
||||
final rosterRaw = m['roster'];
|
||||
return GroupOpMessage(
|
||||
op: GroupAdminOp(
|
||||
gid: m['gid'] as String,
|
||||
epoch: epoch,
|
||||
action: action,
|
||||
actor: m['by'] as String,
|
||||
members: (m['members'] as List).cast<String>().toList(),
|
||||
admins: (m['admins'] as List).cast<String>().toList(),
|
||||
name: m['gname'] as String,
|
||||
prev: base64Decode(m['prev'] as String),
|
||||
),
|
||||
sig: base64Decode(m['sig'] as String),
|
||||
roster: rosterRaw is List
|
||||
? [
|
||||
for (final e in rosterRaw)
|
||||
if (e is Map) e.cast<String, dynamic>(),
|
||||
]
|
||||
: null,
|
||||
);
|
||||
} on Object {
|
||||
// Špatné typy polí (TypeError), vadné base64 (FormatException).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Invarianty stavu, které musí držet každá operace bez ohledu na autora.
|
||||
bool _stateValid(GroupAdminOp op) =>
|
||||
op.gid.isNotEmpty &&
|
||||
op.gid.length <= 64 &&
|
||||
op.name.trim().isNotEmpty &&
|
||||
op.members.length <= Group.maxMembers &&
|
||||
op.admins.isNotEmpty &&
|
||||
op.admins.every(op.members.contains);
|
||||
|
||||
/// Ověří příchozí operaci proti lokálnímu stavu [local] v pořadí kroků 1–6
|
||||
/// (očíslované v těle funkce).
|
||||
///
|
||||
/// [actorIdentityKey] = identity klíč `by` z mých kontaktů (TOFU — nikdy
|
||||
/// z téže zprávy, jinak by si útočník klíč přinesl sám).
|
||||
GopVerdict checkGroupOp({
|
||||
required Group local,
|
||||
required GroupOpMessage msg,
|
||||
required String senderFp,
|
||||
required String me,
|
||||
required Uint8List? actorIdentityKey,
|
||||
GroupOpSignatureCheck verify = _libsignalVerify,
|
||||
}) {
|
||||
final op = msg.op;
|
||||
// 1. Autor = sealed sender.
|
||||
if (op.actor != senderFp) return GopVerdict.spoof;
|
||||
if (op.gid != local.id || !_stateValid(op)) return GopVerdict.invalid;
|
||||
|
||||
// 2. Oprávnění. U odhadnutých správců (viz Group.provisional) stačí, že
|
||||
// podepisující je člen a v podepsaném stavu správcem.
|
||||
final provisional = local.isProvisionalFor(me);
|
||||
final authorized = provisional
|
||||
? local.isMember(op.actor) && op.admins.contains(op.actor)
|
||||
: local.isAdmin(op.actor);
|
||||
if (!authorized) return GopVerdict.notAdmin;
|
||||
|
||||
// 3. Podpis.
|
||||
if (actorIdentityKey == null || !verify(op, msg.sig, actorIdentityKey)) {
|
||||
return GopVerdict.badSignature;
|
||||
}
|
||||
|
||||
// 4. Epocha. Stav od správce (sync) i kotva odhadnuté skupiny nemají na
|
||||
// co navázat — rozhoduje jen to, že posouvají dopředu. Ostrá nerovnost
|
||||
// u sync brání přeskakování mezi dvěma stejně starými větvemi.
|
||||
if (op.action == GroupAdminAction.sync) {
|
||||
return op.epoch > local.epoch ? GopVerdict.apply : GopVerdict.stale;
|
||||
}
|
||||
if (provisional) {
|
||||
return op.epoch >= local.epoch ? GopVerdict.apply : GopVerdict.stale;
|
||||
}
|
||||
if (op.epoch <= local.epoch) return GopVerdict.stale;
|
||||
if (op.epoch > local.epoch + 1) return GopVerdict.gap;
|
||||
|
||||
// 5. Řetěz: `prev` musí být hash mého aktuálního stavu.
|
||||
final mine = hashState(
|
||||
gid: local.id,
|
||||
epoch: local.epoch,
|
||||
members: local.memberIds,
|
||||
admins: local.admins,
|
||||
name: local.name,
|
||||
);
|
||||
if (!_bytesEqual(op.prev, mine)) return GopVerdict.fork;
|
||||
|
||||
// 6. Aplikovat (volající přes applyGroupOp).
|
||||
return GopVerdict.apply;
|
||||
}
|
||||
|
||||
/// Ověří operaci, kterou mě někdo přidává do skupiny, kterou ještě neznám.
|
||||
/// Není s čím řetězit — důvěra je stejná jako u v1 pozvánky (kdo mě zve,
|
||||
/// je člen), navíc musí být podepsaná správcem uvedeným v rosteru.
|
||||
GopVerdict checkJoinOp({
|
||||
required GroupOpMessage msg,
|
||||
required String senderFp,
|
||||
required String me,
|
||||
required Uint8List? actorIdentityKey,
|
||||
GroupOpSignatureCheck verify = _libsignalVerify,
|
||||
}) {
|
||||
final op = msg.op;
|
||||
if (op.actor != senderFp) return GopVerdict.spoof;
|
||||
if (!_stateValid(op) || !op.members.contains(me)) return GopVerdict.invalid;
|
||||
if (!op.admins.contains(op.actor)) return GopVerdict.notAdmin;
|
||||
if (actorIdentityKey == null || !verify(op, msg.sig, actorIdentityKey)) {
|
||||
return GopVerdict.badSignature;
|
||||
}
|
||||
return GopVerdict.apply;
|
||||
}
|
||||
|
||||
/// Přepíše lokální stav výsledkem ověřené operace. Když ve výsledku
|
||||
/// nejsem, správce mě odebral — skupina se chová jako opuštěná.
|
||||
void applyGroupOp(Group group, GroupAdminOp op, String me) {
|
||||
group
|
||||
..memberIds = [...op.members]
|
||||
..admins = [...op.admins]
|
||||
..name = op.name
|
||||
..epoch = op.epoch
|
||||
..provisional = false;
|
||||
if (!op.members.contains(me)) group.left = true;
|
||||
}
|
||||
|
||||
/// Hash aktuálního stavu skupiny (vstupuje jako `prev` další operace).
|
||||
Uint8List currentStateHash(Group g) => hashState(
|
||||
gid: g.id,
|
||||
epoch: g.epoch,
|
||||
members: g.memberIds,
|
||||
admins: g.admins,
|
||||
name: g.name,
|
||||
);
|
||||
|
||||
/// Postaví odchozí operaci s kontrolou oprávnění a invariantů. Hází
|
||||
/// [NotGroupAdminError] / [LastGroupAdminError] dřív, než cokoli odejde.
|
||||
GroupAdminOp buildGroupOp({
|
||||
required Group group,
|
||||
required String me,
|
||||
required GroupAdminAction action,
|
||||
List<String>? members,
|
||||
List<String>? admins,
|
||||
String? name,
|
||||
}) {
|
||||
if (!group.isAdmin(me)) throw const NotGroupAdminError();
|
||||
final newMembers = members ?? group.memberIds;
|
||||
final newAdmins = admins ?? group.admins;
|
||||
if (newMembers.toSet().length > Group.maxMembers) {
|
||||
throw const GroupTooLargeError(Group.maxMembers);
|
||||
}
|
||||
// Poslední správce se nesmí odebrat ani zbavit role — skupina by zůstala
|
||||
// bez nikoho, kdo smí podepsat další změnu.
|
||||
if (newAdmins.isEmpty) throw const LastGroupAdminError();
|
||||
if (!newAdmins.every(newMembers.contains)) {
|
||||
throw StateError('group admins must be members');
|
||||
}
|
||||
final isSync = action == GroupAdminAction.sync;
|
||||
return GroupAdminOp(
|
||||
gid: group.id,
|
||||
epoch: isSync ? group.epoch : group.epoch + 1,
|
||||
action: action,
|
||||
actor: me,
|
||||
members: newMembers,
|
||||
admins: newAdmins,
|
||||
name: name ?? group.name,
|
||||
// Sync nic neřetězí, jen potvrzuje stav — viz checkGroupOp krok 4.
|
||||
prev: isSync ? Uint8List(0) : currentStateHash(group),
|
||||
);
|
||||
}
|
||||
|
||||
/// Podrží operaci na později. Při přetečení se zahodí ty z nejvzdálenější
|
||||
/// budoucnosti — podvržené operace s absurdní epochou tak nevytlačí
|
||||
/// skutečně chybějící mezistav.
|
||||
void holdGroupOp(Group group, PendingGroupOp pending) {
|
||||
final sig = pending.wire['sig'];
|
||||
if (group.pendingOps.any((p) => p.wire['sig'] == sig)) return;
|
||||
group.pendingOps = [...group.pendingOps, pending]
|
||||
..sort((a, b) => a.epoch.compareTo(b.epoch));
|
||||
if (group.pendingOps.length > Group.maxPendingOps) {
|
||||
group.pendingOps = group.pendingOps.sublist(0, Group.maxPendingOps);
|
||||
}
|
||||
}
|
||||
|
||||
bool _bytesEqual(List<int> a, List<int> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -43,6 +43,19 @@ class ImageProcessor {
|
||||
/// sealed-sender obálku) — držet výrazně pod tím.
|
||||
static const int thumbMaxBytes = 48 * 1024;
|
||||
|
||||
/// Jestli z obrázku umíme odstranit metadata. Formát, který knihovna
|
||||
/// nepřečte (HEIC z galerie odeslaný jako dokument, RAW…), by odešel
|
||||
/// v originále včetně GPS — UI se proto nejdřív zeptá uživatele.
|
||||
static Future<bool> canStripMetadata(Uint8List bytes) {
|
||||
return Isolate.run(() {
|
||||
try {
|
||||
return img.decodeImage(bytes) != null;
|
||||
} on Object {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Zpracuje obrázek v izolátu (dekódování megapixelů by blokovalo UI).
|
||||
/// Vrací null, když bajty nejsou dekódovatelný obrázek — volající je
|
||||
/// pak pošle jako dokument.
|
||||
@@ -51,7 +64,13 @@ class ImageProcessor {
|
||||
String? mimeHint,
|
||||
}) {
|
||||
return Isolate.run(() {
|
||||
final decoded = img.decodeImage(original);
|
||||
final img.Image? decoded;
|
||||
try {
|
||||
decoded = img.decodeImage(original);
|
||||
} on Object {
|
||||
// Dekodéry na poškozených datech háží místo null.
|
||||
return null;
|
||||
}
|
||||
if (decoded == null) return null;
|
||||
|
||||
// GIF beze změny — re-encode by zabil animaci
|
||||
@@ -77,6 +96,7 @@ class ImageProcessor {
|
||||
// Samotný alfa kanál nestačí — plně krytý RGBA PNG (screenshot,
|
||||
// canvas render z editoru fotek) je jako JPEG několikanásobně menší.
|
||||
final keepPng = mimeHint == 'image/png' && _hasTransparency(decoded);
|
||||
stripMetadata(image);
|
||||
final bytes = keepPng
|
||||
? Uint8List.fromList(img.encodePng(image))
|
||||
: Uint8List.fromList(img.encodeJpg(image, quality: jpegQuality));
|
||||
@@ -101,6 +121,15 @@ class ImageProcessor {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Zahodí EXIF (GPS, model telefonu, čas focení) a textová metadata.
|
||||
/// Knihovna je při ořezu i zmenšení kopíruje a encodeJpg/encodePng je
|
||||
/// znovu zapíše — re-encode sám o sobě metadata NEodstraní.
|
||||
static void stripMetadata(img.Image image) {
|
||||
image
|
||||
..exif = img.ExifData()
|
||||
..textData = null;
|
||||
}
|
||||
|
||||
static Uint8List _thumbnail(img.Image source) {
|
||||
var thumb = max(source.width, source.height) > thumbDimension
|
||||
? (source.width >= source.height
|
||||
@@ -114,6 +143,7 @@ class ImageProcessor {
|
||||
img.compositeImage(flat, thumb);
|
||||
thumb = flat;
|
||||
}
|
||||
stripMetadata(thumb);
|
||||
var quality = 70;
|
||||
var encoded = Uint8List.fromList(img.encodeJpg(thumb, quality: quality));
|
||||
// Náhled je jen placeholder, než dorazí ostrá fotka — kvalitu držíme
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Kdo právě píše: konverzace → otisky pisatelů.
|
||||
///
|
||||
/// Indikátor nemá „konec psaní“ — odesílatel posílá „píšu“ nejvýš jednou
|
||||
/// za pár sekund a tady každý záznam sám vyprší. Zpráva, která pak dorazí,
|
||||
/// ho smaže hned ([clear]).
|
||||
class TypingTracker {
|
||||
TypingTracker({DateTime Function()? clock}) : _now = clock ?? DateTime.now;
|
||||
|
||||
/// Jak dlouho po posledním „píšu“ indikátor svítí. Musí být delší než
|
||||
/// interval, ve kterém odesílatel „píšu“ opakuje (4 s), jinak by blikal.
|
||||
static const showFor = Duration(seconds: 6);
|
||||
|
||||
final DateTime Function() _now;
|
||||
final _expires = <String, Map<String, DateTime>>{};
|
||||
final _changes = StreamController<Map<String, Set<String>>>.broadcast();
|
||||
Timer? _sweep;
|
||||
|
||||
Stream<Map<String, Set<String>>> get changes => _changes.stream;
|
||||
|
||||
/// Aktuální pisatelé po konverzacích (bez vypršelých).
|
||||
Map<String, Set<String>> get current {
|
||||
final now = _now();
|
||||
return {
|
||||
for (final e in _expires.entries)
|
||||
if (e.value.values.any((t) => t.isAfter(now)))
|
||||
e.key: {
|
||||
for (final w in e.value.entries)
|
||||
if (w.value.isAfter(now)) w.key,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void mark(String convId, String writer) {
|
||||
(_expires[convId] ??= {})[writer] = _now().add(showFor);
|
||||
_emit();
|
||||
_sweep ??= Timer.periodic(const Duration(seconds: 1), (_) => prune());
|
||||
}
|
||||
|
||||
void clear(String convId, String writer) {
|
||||
final writers = _expires[convId];
|
||||
if (writers?.remove(writer) == null) return;
|
||||
if (writers!.isEmpty) _expires.remove(convId);
|
||||
_emit();
|
||||
}
|
||||
|
||||
/// Zahodí vypršelé záznamy; když už nikdo nepíše, zastaví časovač.
|
||||
void prune() {
|
||||
final now = _now();
|
||||
var changed = false;
|
||||
_expires.removeWhere((_, writers) {
|
||||
final before = writers.length;
|
||||
writers.removeWhere((_, t) => !t.isAfter(now));
|
||||
changed |= writers.length != before;
|
||||
return writers.isEmpty;
|
||||
});
|
||||
if (changed) _emit();
|
||||
if (_expires.isEmpty) {
|
||||
_sweep?.cancel();
|
||||
_sweep = null;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_sweep?.cancel();
|
||||
_sweep = null;
|
||||
_expires.clear();
|
||||
}
|
||||
|
||||
void _emit() {
|
||||
if (!_changes.isClosed) _changes.add(current);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,16 @@ import 'package:sepot/models/message.dart';
|
||||
/// při onboardingu, který DB rovnou otevře).
|
||||
|
||||
/// Nátlakový PIN je shodný se skutečným — nelze nastavit.
|
||||
/// Příliš mnoho špatných PINů za sebou — další pokus až za [remaining].
|
||||
class PinLockedException implements Exception {
|
||||
const PinLockedException(this.remaining);
|
||||
|
||||
final Duration remaining;
|
||||
|
||||
@override
|
||||
String toString() => 'PIN locked for ${remaining.inSeconds}s';
|
||||
}
|
||||
|
||||
class DecoyPinConflict implements Exception {
|
||||
const DecoyPinConflict();
|
||||
@override
|
||||
@@ -51,6 +61,13 @@ class SecureStorageService {
|
||||
static const _legacyPinHashKey = 'pin_hash';
|
||||
|
||||
static const _decoyWrappedDekKey = 'wrapped_dek_decoy';
|
||||
// Brzda hádání PINu: počet chyb za sebou a čas, do kdy je zámek uzamčený.
|
||||
// V keystore (ne v DB) — DB je právě to, co se hádáním otevírá.
|
||||
static const _pinFailCountKey = 'pin_fail_count';
|
||||
static const _pinLockUntilKey = 'pin_lock_until';
|
||||
static const _pinFreeAttempts = 5;
|
||||
static const _pinLockBase = Duration(seconds: 30);
|
||||
static const _pinLockMax = Duration(minutes: 15);
|
||||
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
final String? _dbPathOverride;
|
||||
@@ -60,12 +77,24 @@ class SecureStorageService {
|
||||
bool _decoyMode = false;
|
||||
|
||||
/// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB.
|
||||
/// V tomhle režimu úložiště nevydá skutečnou identitu ani frázi,
|
||||
/// nestartuje síť a nic z keystore nemění (viz jednotlivé metody).
|
||||
bool get isDecoyMode => _decoyMode;
|
||||
|
||||
/// Identita ukazovaná v nátlakové relaci: náhodná, jen v paměti.
|
||||
LocalIdentity? _decoyIdentity;
|
||||
|
||||
void _requireRealSession() {
|
||||
if (_decoyMode) throw StateError('Not available in a duress session');
|
||||
}
|
||||
|
||||
SecureStorageService({String? dbPath})
|
||||
: _secureStorage = const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
// this_device: identita a klíče nesmí putovat v iCloud Keychain
|
||||
// ani v záloze na jiný telefon — obnova jde přes 24 slov.
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device),
|
||||
),
|
||||
_dbPathOverride = dbPath;
|
||||
|
||||
@@ -98,21 +127,59 @@ class SecureStorageService {
|
||||
if (wrapped == null) {
|
||||
throw StateError('No PIN set — use unlockWithoutPin');
|
||||
}
|
||||
final locked = await pinLockRemaining();
|
||||
if (locked != null) throw PinLockedException(locked);
|
||||
|
||||
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
|
||||
try {
|
||||
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: pin);
|
||||
await _openDb(dek);
|
||||
// Oba bloby jednou derivací (sdílená sůl) — čas odemčení neprozradí,
|
||||
// který PIN padl.
|
||||
final (index, dek) = await DekWrap.unwrapFirstMatching(
|
||||
wrapped: [wrapped, ?decoy],
|
||||
pin: pin,
|
||||
);
|
||||
await _openDb(dek, decoy: index == 1);
|
||||
await _resetPinFailures();
|
||||
} on WrongPinException {
|
||||
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
|
||||
if (decoy == null) rethrow;
|
||||
// Když ani decoy nesedí, unwrap vyhodí WrongPinException dál.
|
||||
final dek = await DekWrap.unwrap(wrapped: decoy, pin: pin);
|
||||
await _openDb(dek, decoy: true);
|
||||
await _notePinFailure();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Zbývající zámek po sérii špatných PINů; null = volno.
|
||||
Future<Duration?> pinLockRemaining() async {
|
||||
final raw = await _secureStorage.read(key: _pinLockUntilKey);
|
||||
final until = raw == null ? null : int.tryParse(raw);
|
||||
if (until == null) return null;
|
||||
final remaining = DateTime.fromMillisecondsSinceEpoch(until)
|
||||
.difference(DateTime.now());
|
||||
return remaining > Duration.zero ? remaining : null;
|
||||
}
|
||||
|
||||
Future<void> _notePinFailure() async {
|
||||
final raw = await _secureStorage.read(key: _pinFailCountKey);
|
||||
final failures = (raw == null ? 0 : int.tryParse(raw) ?? 0) + 1;
|
||||
await _secureStorage.write(key: _pinFailCountKey, value: '$failures');
|
||||
if (failures < _pinFreeAttempts) return;
|
||||
// 30 s po páté chybě, pak dvojnásobně až do 15 minut: šest číslic
|
||||
// (10⁶) tak trvá roky, Argon2id sám by brzdil jen ~2 s na pokus.
|
||||
var lock = _pinLockBase * (1 << (failures - _pinFreeAttempts));
|
||||
if (lock > _pinLockMax) lock = _pinLockMax;
|
||||
await _secureStorage.write(
|
||||
key: _pinLockUntilKey,
|
||||
value: '${DateTime.now().add(lock).millisecondsSinceEpoch}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _resetPinFailures() async {
|
||||
await _secureStorage.delete(key: _pinFailCountKey);
|
||||
await _secureStorage.delete(key: _pinLockUntilKey);
|
||||
}
|
||||
|
||||
/// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže.
|
||||
/// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře.
|
||||
Future<void> enablePin(String pin) async {
|
||||
_requireRealSession();
|
||||
final dek = _dek ?? await _getOrCreateRawDek();
|
||||
final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
|
||||
await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
|
||||
@@ -124,6 +191,7 @@ class SecureStorageService {
|
||||
|
||||
/// Vypne PIN: DEK se vrátí raw do OS keystore.
|
||||
Future<void> disablePin() async {
|
||||
_requireRealSession();
|
||||
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
||||
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
|
||||
await _secureStorage.delete(key: _wrappedDekKey);
|
||||
@@ -151,6 +219,7 @@ class SecureStorageService {
|
||||
required String oldPin,
|
||||
required String newPin,
|
||||
}) async {
|
||||
_requireRealSession();
|
||||
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (wrapped == null) throw StateError('No PIN set');
|
||||
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin);
|
||||
@@ -165,7 +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);
|
||||
}
|
||||
|
||||
@@ -176,6 +249,7 @@ class SecureStorageService {
|
||||
/// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy
|
||||
/// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit.
|
||||
Future<void> enableDecoyPin(String decoyPin) async {
|
||||
_requireRealSession();
|
||||
final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (realWrapped == null) {
|
||||
throw StateError('Set a regular PIN first');
|
||||
@@ -193,7 +267,9 @@ class SecureStorageService {
|
||||
// Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB
|
||||
final decoyPath = await _dbPath(decoy: true);
|
||||
CryptoDb.open(path: decoyPath, dek: decoyDek).close();
|
||||
final wrapped = await DekWrap.wrap(dek: decoyDek, pin: decoyPin);
|
||||
// Stejná sůl jako skutečný blob → jedna Argon2id derivace pro oba PINy.
|
||||
final wrapped = await DekWrap.wrap(
|
||||
dek: decoyDek, pin: decoyPin, salt: DekWrap.saltOf(realWrapped));
|
||||
await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped);
|
||||
}
|
||||
|
||||
@@ -203,11 +279,20 @@ class SecureStorageService {
|
||||
|
||||
/// Zruší nátlakový PIN a smaže decoy DB.
|
||||
Future<void> disableDecoyPin() async {
|
||||
_requireRealSession();
|
||||
await _secureStorage.delete(key: _decoyWrappedDekKey);
|
||||
try {
|
||||
final f = File(await _dbPath(decoy: true));
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
} catch (_) {}
|
||||
await _deleteDbFiles(await _dbPath(decoy: true));
|
||||
}
|
||||
|
||||
Future<void> _deleteDbFiles(String path) async {
|
||||
for (final suffix in ['', '-wal', '-shm']) {
|
||||
try {
|
||||
final f = File('$path$suffix');
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
} catch (_) {
|
||||
// best-effort úklid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme
|
||||
@@ -222,6 +307,8 @@ class SecureStorageService {
|
||||
_db?.close();
|
||||
_db = null;
|
||||
_dek = null;
|
||||
_decoyMode = false;
|
||||
_decoyIdentity = null;
|
||||
}
|
||||
|
||||
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
|
||||
@@ -293,6 +380,11 @@ class SecureStorageService {
|
||||
|
||||
/// Načti lokální identitu
|
||||
Future<LocalIdentity?> loadIdentity() async {
|
||||
if (_decoyMode) {
|
||||
// Nátlaková relace ukazuje náhodnou identitu jen z paměti — skutečný
|
||||
// klíč z keystore se do ní nikdy nedostane (nátlakový PIN musí být izolovaný).
|
||||
return _decoyIdentity ??= await LocalIdentity.create('');
|
||||
}
|
||||
final json = await _secureStorage.read(key: _identityKey);
|
||||
if (json == null) return null;
|
||||
try {
|
||||
@@ -320,6 +412,7 @@ class SecureStorageService {
|
||||
}
|
||||
|
||||
Future<List<String>?> loadSeedPhrase() async {
|
||||
if (_decoyMode) return null;
|
||||
final json = await _secureStorage.read(key: _seedPhraseKey);
|
||||
if (json == null) return null;
|
||||
return (jsonDecode(json) as List).cast<String>();
|
||||
@@ -331,6 +424,7 @@ class SecureStorageService {
|
||||
/// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings).
|
||||
/// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním.
|
||||
Future<void> enableBiometrics() async {
|
||||
_requireRealSession();
|
||||
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
||||
await _secureStorage.write(
|
||||
key: _biometricDekKey,
|
||||
@@ -342,6 +436,7 @@ class SecureStorageService {
|
||||
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
|
||||
/// PIN-only model: DEK existuje jen za Argon2id).
|
||||
Future<void> disableBiometrics() async {
|
||||
_requireRealSession();
|
||||
await _secureStorage.delete(key: _biometricDekKey);
|
||||
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
|
||||
}
|
||||
@@ -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);
|
||||
conv ??= Conversation(contactId: contactId);
|
||||
conv.addMessage(message);
|
||||
if (!conv.addMessage(message)) return false;
|
||||
await saveConversation(conv);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Založí prázdnou konverzaci, pokud ještě není (nová skupina musí být
|
||||
@@ -471,24 +570,29 @@ class SecureStorageService {
|
||||
if (!isUnlocked) return false;
|
||||
var changed = false;
|
||||
final now = DateTime.now();
|
||||
for (final conv in await loadConversations()) {
|
||||
final expired = conv.messages
|
||||
.where((m) =>
|
||||
m.disappearAfterSeconds != null &&
|
||||
m.timestamp
|
||||
.add(Duration(seconds: m.disappearAfterSeconds!))
|
||||
.isBefore(now))
|
||||
.toList();
|
||||
bool isExpired(Message m) =>
|
||||
m.disappearAfterSeconds != null &&
|
||||
m.timestamp
|
||||
.add(Duration(seconds: m.disappearAfterSeconds!))
|
||||
.isBefore(now);
|
||||
for (final snapshot in await loadConversations()) {
|
||||
if (!snapshot.messages.any(isExpired)) continue;
|
||||
// Konverzace se před zápisem načte znovu: mezi snímkem a zápisem
|
||||
// běží event loop (mazání blobů, příjem zpráv) a starý snímek by
|
||||
// přepsal zprávu přijatou mezitím — už ACKnutou, tedy navždy pryč.
|
||||
final conv = await loadConversation(snapshot.contactId);
|
||||
if (conv == null) continue;
|
||||
final expired = conv.messages.where(isExpired).toList();
|
||||
if (expired.isEmpty) continue;
|
||||
changed = true;
|
||||
conv.messages.removeWhere(isExpired);
|
||||
await saveConversation(conv);
|
||||
for (final m in expired) {
|
||||
final blobId = m.attachment?.blobId;
|
||||
if (blobId != null) {
|
||||
await AttachmentStore().delete(blobId);
|
||||
}
|
||||
conv.messages.remove(m);
|
||||
}
|
||||
await saveConversation(conv);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
@@ -508,12 +612,19 @@ class SecureStorageService {
|
||||
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
|
||||
/// i lokální bloby příloh.
|
||||
Future<void> deleteAll() async {
|
||||
final path = await _dbPath();
|
||||
lock();
|
||||
for (final suffix in ['', '-wal', '-shm']) {
|
||||
final f = File('$path$suffix');
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
if (_decoyMode) {
|
||||
// Nátlaková relace: „smazat" smí jen prázdnou decoy DB — skutečná
|
||||
// data a keystore zůstávají (dřív to smazalo reálný účet).
|
||||
final decoyPath = await _dbPath(decoy: true);
|
||||
lock();
|
||||
await _deleteDbFiles(decoyPath);
|
||||
return;
|
||||
}
|
||||
final path = await _dbPath();
|
||||
final decoyPath = await _dbPath(decoy: true);
|
||||
lock();
|
||||
await _deleteDbFiles(path);
|
||||
await _deleteDbFiles(decoyPath);
|
||||
await AttachmentStore().deleteAll();
|
||||
await _secureStorage.deleteAll();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ class SepotColors {
|
||||
// Text
|
||||
static const Color textPrimary = Color(0xFFF0EEE8); // mléko
|
||||
static const Color textSecondary = Color(0xFF9AA0B5); // mlha
|
||||
static const Color textEmphasis = Color(0xFFC4C8D6); // mlha, zvýrazněná
|
||||
static const Color textMuted = Color(0xFF626880);
|
||||
|
||||
// Akcentní barvy
|
||||
@@ -39,12 +40,57 @@ class SepotColors {
|
||||
// Bubliny zpráv — odeslaná teplá (ember), přijatá chladná (povrch)
|
||||
static const Color bubbleSent = Color(0xFFF0795A);
|
||||
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
|
||||
class 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 {
|
||||
return ThemeData(
|
||||
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:local_auth/local_auth.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/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/messaging_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/widgets/sepot_logo.dart';
|
||||
|
||||
@@ -41,7 +43,10 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
||||
try {
|
||||
final ok = await LocalAuthentication().authenticate(
|
||||
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
|
||||
await storage.unlockWithBiometrics();
|
||||
@@ -222,6 +227,9 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
||||
void _restartMessagingAfterUnlock() {
|
||||
ref.invalidate(messagingStartProvider);
|
||||
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 {
|
||||
@@ -234,12 +242,22 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
||||
_restartMessagingAfterUnlock();
|
||||
if (mounted) context.go('/chats');
|
||||
} on WrongPinException {
|
||||
final locked = await ref.read(storageProvider).pinLockRemaining();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = context.l10n.pinWrong2;
|
||||
_error = locked == null
|
||||
? context.l10n.pinWrong2
|
||||
: context.l10n.pinLockedFor(locked.inSeconds);
|
||||
_pin = '';
|
||||
_isVerifying = false;
|
||||
});
|
||||
HapticFeedback.heavyImpact();
|
||||
} on PinLockedException catch (e) {
|
||||
setState(() {
|
||||
_error = context.l10n.pinLockedFor(e.remaining.inSeconds);
|
||||
_pin = '';
|
||||
_isVerifying = false;
|
||||
});
|
||||
} on StateError {
|
||||
// Wrapped DEK neexistuje — nekonzistentní stav, zpět na 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';
|
||||
}
|
||||