Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8780486eae | |||
| 10ca35e352 | |||
| 45f169c20d | |||
| 5222c301ff | |||
| 830d6290ca | |||
| cbb09afbb1 | |||
| 59992af778 | |||
| 717aeed4ff | |||
| 2f6b542f24 | |||
| 5b237c82f2 | |||
| d2eb0d4f8c | |||
| ae127decd9 | |||
| ab0e35ddf4 | |||
| d431501b74 | |||
| 6f9b08e844 | |||
| 09ef54ef69 | |||
| bfea7201f7 | |||
| 05eeccd83b | |||
| d13b88aebe | |||
| b824f62878 | |||
| 00739ac8a0 | |||
| 94cfd72690 | |||
| e3a2378c34 | |||
| 8b452a5593 |
@@ -4,6 +4,95 @@ Všechny podstatné změny v aplikaci Šepot. Formát dle
|
||||
[Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
|
||||
verzování dle [ajtak standardu](#verzování).
|
||||
|
||||
## [Nevydáno]
|
||||
|
||||
## [1.2.3] – 2026-10-23
|
||||
|
||||
### Opraveno (iOS, build 27)
|
||||
- **Na iOS 27 appka hned po spuštění spadla.** Build 26 vznikl novým
|
||||
Xcode (SDK iOS 27) a ten u appek bez scénového životního cyklu (UIScene)
|
||||
start zastaví. Appka teď scény používá; Flutter běží od startu procesu,
|
||||
takže příchozí hovor na pozadí dál zazvoní i bez otevřeného okna.
|
||||
|
||||
### Bezpečnost
|
||||
- **24 slov jen po čerstvém ověření.** Zálohovací fráze se z odemčené appky
|
||||
ukáže až po zadání PINu (nebo otisku/obličeje, je-li zapnutý). Totéž platí
|
||||
pro export a obnovu zálohy, nastavení nátlakového PINu, prodloužení
|
||||
automatického zámku, vypnutí ochrany obrazovky, zapnutí biometrie
|
||||
a smazání účtu. Kdo vezme odemčený telefon, k frázi se nedostane.
|
||||
V nátlakové relaci se fráze nezobrazí nikdy. Ověření PINu podléhá stejnému
|
||||
zpomalení po chybách jako odemykání.
|
||||
- **Cizí klíč se jménem kontaktu už nesplyne s kontaktem.** Zpráva od
|
||||
neznámého klíče je „žádost o zprávu“ se jménem, které si odesílatel zvolil
|
||||
sám, a s označením „(neznámý)“. Dokud ji nepřijmete, nejde odpovědět,
|
||||
nechodí od vás potvrzení přečtení ani psaní, nezvoní hovory a notifikace
|
||||
neukazuje jméno. Když se jméno plete s existujícím kontaktem (i přes
|
||||
cyrilici, diakritiku nebo neviditelné znaky), ukáže se varování i v chatu
|
||||
skutečného kontaktu.
|
||||
- **Změna bezpečnostního kódu.** Když se pod účtem známého kontaktu objeví
|
||||
jiný klíč, chat ukáže „Bezpečnostní kód kontaktu … se změnil“, ověření
|
||||
kontaktu se zruší a odpověď neodejde na nový klíč. Nový klíč přijde jako
|
||||
samostatná žádost o zprávu.
|
||||
|
||||
### Opraveno
|
||||
- **Na iPhonu nechodily budíčky ani hovory na pozadí.** Appka relayi vůbec
|
||||
neposlala token pro zprávy (APNs) ani pro hovory (VoIP): nastavení
|
||||
notifikací na iOS skončilo chybou, ta zastavila celou registraci a appka
|
||||
ji tiše spolkla. Registrace u Apple teď proběhne i bez povolených
|
||||
notifikací, token pro hovory jde zvlášť a případné selhání ukáže seznam
|
||||
chatů.
|
||||
|
||||
## [1.2.2] – 2026-10-11
|
||||
|
||||
### Opraveno
|
||||
- **Přečtený chat se dál ukazoval jako nepřečtený.** Zpráva, která přišla do
|
||||
otevřeného chatu, zůstala po návratu do seznamu tučně s odznakem; teď se
|
||||
přečte hned, když je chat na obrazovce (a appka v popředí). Stažení přílohy
|
||||
nebo dodatečné doručení už nevrátí přečtenou zprávu mezi nepřečtené.
|
||||
- Gesto „odpověď tahem“ při zavírání chatu v ladicím buildu padalo na
|
||||
vytvoření animace nad zrušeným prvkem.
|
||||
- Na iPhonu hlásila dlaždice Push v Nastavení chybu (volala UnifiedPush,
|
||||
který existuje jen na Androidu).
|
||||
|
||||
### Změněno
|
||||
- **Nová ikona aplikace:** Š, které se ve spodní části rozpadá do teček.
|
||||
|
||||
## [1.2.1] – 2026-10-09
|
||||
|
||||
### Opraveno
|
||||
- **Hovory se nedaly přijmout.** Příchozí signalizace hovoru se zahazovala dřív,
|
||||
než se dostala k obsluze hovoru; volaný jen „zazvonil“ z budíčku a pak nic.
|
||||
- **Obrazovka hovoru po zavěšení:** avatar a jméno uskakovaly vlevo nahoru.
|
||||
- **Obnova ze 24 slov na novém telefonu** končila nekonečným čekáním — server
|
||||
odmítal už existující identitu. Nové zařízení ji teď převezme podpisem.
|
||||
- **Tichá ztráta zpráv:** úklid mizících zpráv mohl přepsat čerstvě přijatou
|
||||
zprávu; automatický zámek během zpracování zprávy ji mohl nenávratně zahodit;
|
||||
souběžné šifrování pro téhož příjemce dávalo obálky, které druhá strana
|
||||
zahodila. Všechny tři cesty opraveny.
|
||||
- **Velká fronta po delším výpadku** (stovky zpráv) se už neodpojuje v půlce
|
||||
a server ji po osmi pokusech nemaže.
|
||||
- **Nátlakový PIN** otevíral relaci se skutečnou identitou, frází i přístupem ke
|
||||
smazání účtu. Teď je izolovaná: náhodná identita, žádná síť, žádný zásah do
|
||||
klíčů; smazání v ní nechá skutečný účet být.
|
||||
- Zvonění z budíčku bez skutečné nabídky končí po 12 s; server budíčky hovoru
|
||||
omezuje na cíl, takže cizí člověk se znalostí adresy telefon nerozezvoní
|
||||
pořád dokola.
|
||||
- Dodatečné odeslání z fronty neztrácí anketu ani zmínky; budíček při
|
||||
„připojeno“ bez provozu obnoví spojení.
|
||||
|
||||
### Bezpečnost
|
||||
- Hádání PINu: po pěti chybách prodleva 30 s, dál dvojnásobná až 15 minut.
|
||||
- Nátlakový a skutečný PIN se ověřují jednou derivací — čas odemčení neprozradí,
|
||||
který byl zadán.
|
||||
- Biometrie jen biometricky (zámek telefonu appku neodemkne); zabezpečení
|
||||
obrazovky je výchozí zapnuté a po odemčení skutečně platí; na iOS klíče
|
||||
nepřecházejí do iCloud Keychain ani záloh; filtr proti dotykům přes překryvná okna.
|
||||
- Server: doplňování jednorázových klíčů a rotace podepsaných klíčů
|
||||
(`POST /v1/keys`), omezení výdeje jednorázových klíčů na adresu, chyby v logu
|
||||
bez adres push endpointů, připnutí DNS u budíčků, globální strop příloh,
|
||||
limity délek, `/v1/ready`, uvolnění spojení při vypnutí, revize a zdroj
|
||||
v `/v1/server-info`.
|
||||
|
||||
## [1.2.0] – 2026-10-01
|
||||
|
||||
### Nový vzhled
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
|
||||
**Šepot** is an end-to-end encrypted messenger with anonymous, on-device identity. There is no phone number, e-mail, or password: an account is a keypair generated on the device, your ID is the fingerprint of your public key, and the only backup is a mnemonic phrase.
|
||||
|
||||
The design goal is **metadata minimization with a public threat model** — never "zero metadata". The relay is deliberately dumb: it cannot read message content, does not learn who sent a message, and does not log IP addresses. What it *does* see is documented honestly below.
|
||||
The design goal is **metadata minimization with a public threat model** — never "zero metadata". The relay is deliberately dumb: it cannot read message content, envelopes carry no sender, and it neither stores a sender–recipient link nor logs IP addresses. What it *does* see while a connection is open is documented honestly below.
|
||||
|
||||
> Status: pre-1.0. The Android client is the primary target; iOS builds run through TestFlight. See [`CHANGELOG.md`](CHANGELOG.md).
|
||||
> Status: 1.2.x. Android is on [Google Play](https://play.google.com/store/apps/details?id=cz.sepot.sepot); iOS is on the App Store outside the EU, with EU availability pending Apple's trader verification under the Digital Services Act. See [`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
## Features
|
||||
|
||||
- **Signal-grade cryptography** via the official Rust `libsignal`: X3DH + post-quantum PQXDH (Kyber-1024) key agreement, Double Ratchet, and sealed sender.
|
||||
- **End-to-end encryption** via the open-source Rust `libsignal` library: X3DH + post-quantum PQXDH (Kyber-1024) key agreement, Double Ratchet, and sealed sender.
|
||||
- **Anonymous identity** — no phone/e-mail/password. Recovery through a 24-word mnemonic that encodes the identity key itself (not a KDF seed).
|
||||
- **Encrypted at rest** — all local data lives in SQLCipher, unlocked by a PIN (Argon2id-derived key) or biometrics. Optional **duress PIN** opens a separate empty database.
|
||||
- **Group chats** — up to 128 members, pairwise fan-out so every member gets full forward secrecy; the relay never learns a group exists.
|
||||
- **Group chats** — up to 128 members, pairwise fan-out so every member gets full forward secrecy; groups are not a server-side object and the relay keeps no member list.
|
||||
- **Attachments** — images and files are encrypted client-side (AES-256-GCM) and uploaded as opaque blobs with a TTL; image metadata (EXIF/GPS) is stripped on send.
|
||||
- **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers).
|
||||
- **Push without Google by default** — UnifiedPush/ntfy; FCM (Android) and APNs (iOS) are opt-in. A push wake-up never carries content, sender, or count.
|
||||
- **Push with or without Google** — UnifiedPush/ntfy when a distributor is installed, otherwise FCM on Android (default since 1.2.0, can be switched off) and APNs on iOS. A push wake-up never carries content, sender, or count.
|
||||
- **Tor transport** (Android, via Orbot) — routes relay traffic over a `.onion` to hide the client IP. Off by default.
|
||||
- **Five languages** — Czech, English, German, Russian, Italian. The UI follows the system language and can be overridden in Settings.
|
||||
|
||||
@@ -29,9 +29,11 @@ The design goal is **metadata minimization with a public threat model** — neve
|
||||
SQLCipher at rest opaque attachment blobs
|
||||
```
|
||||
|
||||
Every message is encrypted end-to-end with libsignal and wrapped in a sealed-sender envelope, so the relay routes by recipient delivery address without learning the sender. Undelivered messages sit in a per-recipient queue with a fixed TTL and are hard-deleted on acknowledgement.
|
||||
Every message is encrypted end-to-end with libsignal and wrapped in a sealed-sender envelope, so the envelope itself carries no sender and the relay routes it by the recipient's delivery address. Undelivered messages sit in a per-recipient queue with a fixed TTL and are hard-deleted on acknowledgement.
|
||||
|
||||
**What the relay can see:** the recipient's delivery address, message size, and timing. **What it cannot see:** message content, the sender's identity, group membership, or (with logging disabled as shipped) client IP addresses. The full model — including where the trust boundary sits — is in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md) and the crypto facade `app/lib/core/crypto/`.
|
||||
**What the relay can see:** the recipient's delivery address, envelope size and timing, push tokens, and — while a connection is open — the client IP address. Envelopes are submitted over an authenticated WebSocket, so at runtime the relay also knows which connected device hands an envelope to which delivery address; it needs that to deliver. **What it does not see or keep:** message content, names, contacts or group membership. It stores no sender–recipient link and no IP addresses, and writes neither to its logs. Someone who took over the relay could start recording runtime metadata; they still could not read messages. See [`SECURITY.md`](SECURITY.md) and the crypto facade `app/lib/core/crypto/`.
|
||||
|
||||
**Where data lives:** messages, accounts and keys are stored only on servers in the EU (Hetzner, Germany). Message content never leaves the device unencrypted. Nothing leaves the EU except an anonymous wake-up push via Google (FCM) or Apple (APNs), which carries no message content or sender; the website runs behind Cloudflare.
|
||||
|
||||
## Repository layout
|
||||
|
||||
@@ -71,10 +73,10 @@ UI strings live in `app/lib/l10n/app_*.arb` (`cs`, `en`, `de`, `ru`, `it`) and a
|
||||
|
||||
## Security
|
||||
|
||||
Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **bezpecnost@sepot.cz** rather than opening a public issue.
|
||||
Cryptography is implemented by the open-source `libsignal` library, not by hand-rolled primitives. Šepot is not affiliated with Signal Messenger LLC; Signal is a trademark of its owner. The project runs regular internal security audits and penetration tests (August, September and October 2026); no independent third-party audit has taken place yet. If you find a vulnerability, please report it privately to **security@sepot.cz** rather than opening a public issue — see [`SECURITY.md`](SECURITY.md) and [`/.well-known/security.txt`](https://sepot.cz/.well-known/security.txt).
|
||||
|
||||
## License
|
||||
|
||||
Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](LICENSE).
|
||||
|
||||
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository, F-Droid, direct APK). That permission covers only Šepot's own code. The bundled Signal `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
|
||||
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository and the direct APK releases). That permission covers only Šepot's own code. The bundled `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report security issues privately — do not open a public issue.
|
||||
|
||||
- E-mail: **security@sepot.cz**
|
||||
- Web form and policy: <https://sepot.cz/zodpovedne-hlaseni>
|
||||
- Machine-readable contact: <https://sepot.cz/.well-known/security.txt>
|
||||
|
||||
You will get an acknowledgement within 72 hours and a status update at least
|
||||
every 14 days until the issue is resolved. We aim to ship a fix within 30 days
|
||||
for issues that affect message confidentiality, authentication, or the relay's
|
||||
metadata guarantees, and we credit reporters in the changelog unless they
|
||||
prefer otherwise.
|
||||
|
||||
## Scope
|
||||
|
||||
- the Flutter client (`app/`), including the Signal protocol integration,
|
||||
local storage (SQLCipher, PIN/biometrics, duress session) and calls;
|
||||
- the relay (`rust/sepot-relay/`): authentication, routing, push wake-ups,
|
||||
attachments, rate limiting, TURN credentials;
|
||||
- the public relay at `relay.sepot.cz` and the website `sepot.cz`.
|
||||
|
||||
Please do not run denial-of-service tests, mass registrations or scans
|
||||
against the public relay; a local instance (`deploy/relay/`) is enough to
|
||||
reproduce almost everything.
|
||||
|
||||
## What the relay can and cannot see
|
||||
|
||||
The threat model is documented in the README. In short: envelopes carry no
|
||||
sender (sealed sender) and the relay never sees content. It does see routing
|
||||
tokens, envelope sizes, timing and push tokens, and while a connection is
|
||||
open the client IP and which authenticated device submits an envelope to
|
||||
which delivery address. It stores no sender–recipient link and no IP
|
||||
addresses. Reports that demonstrate the relay learning or keeping more than
|
||||
that are the ones we care about most.
|
||||
|
||||
## Audits
|
||||
|
||||
We run regular internal security audits and penetration tests (August,
|
||||
September and October 2026); fixes are listed in `CHANGELOG.md`. No
|
||||
independent third-party audit has taken place yet.
|
||||
@@ -26,7 +26,7 @@ their app-store status is documented in detail below.
|
||||
|
||||
Šepot relies on that stated position and, as an AGPL work, keeps its
|
||||
complete corresponding source available under the AGPL through channels
|
||||
without app-store restrictions (this public repository, F-Droid, direct
|
||||
without app-store restrictions (this public repository, direct
|
||||
APK). A written clarification for the current Rust `libsignal` has been
|
||||
requested from Signal (tracking: <https://github.com/signalapp/libsignal/issues/684>).
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cz.sepot.sepot
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
@@ -7,10 +8,20 @@ import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
)
|
||||
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací.
|
||||
// Výchozí stav; Dart přepínač „Zabezpečení obrazovky" ho může po
|
||||
// odemčení vypnout (default zapnuto, jako u Signalu).
|
||||
// Debug build: screenshoty povolené kvůli QA na emulátoru (adb screencap
|
||||
// by u FLAG_SECURE okna selhal); release vždy chráněný.
|
||||
val debuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
if (!debuggable) {
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
)
|
||||
}
|
||||
// Tapjacking: dotyky přes překryvné okno jiné appky (částečně
|
||||
// průhledný overlay nad PIN klávesnicí) se zahodí.
|
||||
window.decorView.filterTouchesWhenObscured = true
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 7.6 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0a0e17</color>
|
||||
<color name="ic_launcher_background">#0B0C12</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 575 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 27 KiB |
@@ -1,9 +1,9 @@
|
||||
# Launcher ikony — kandidát 1 „Š s tečkami" (nokturno-ikona-B2, ember paleta).
|
||||
# Zdroj: sepot-web/koncept/logo/nokturno-ikona-B2.png; regenerace:
|
||||
# Launcher icons: the "Š that fades into dots" mark (variant B, 2026-10).
|
||||
# Source: assets/icon/icon-1024.png (full) and maskable-fg-512.png (adaptive foreground); regenerate with:
|
||||
# dart run flutter_launcher_icons
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: assets/icon/icon-1024.png
|
||||
adaptive_icon_background: "#0a0e17"
|
||||
adaptive_icon_background: "#0B0C12"
|
||||
adaptive_icon_foreground: assets/icon/maskable-fg-512.png
|
||||
|
||||
@@ -44,6 +44,9 @@ post_install do |installer|
|
||||
# staví jen pro arm64 (Apple Silicon Mac). Bez tohoto Xcode linkuje
|
||||
# i x86_64 slice, který dylib nemá → „Undefined symbols for x86_64".
|
||||
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'x86_64'
|
||||
# Xcode se SDK 27 odmítá pody s deployment targetem pod 15.0 (archive
|
||||
# skončí chybou místo varování); appka sama minimum 15.0 má.
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
AA00000000000000000000A2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A1 /* PrivacyInfo.xcprivacy */; };
|
||||
AB10000000000000000000B1 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AB10000000000000000000C0 /* InfoPlist.strings */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
AA00000000000000000000B2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000B1 /* SceneDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
@@ -49,6 +50,7 @@
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
AA00000000000000000000B1 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -126,6 +128,7 @@
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
AA00000000000000000000B1 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
path = Runner;
|
||||
@@ -285,6 +288,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
AA00000000000000000000B2 /* SceneDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
@@ -7,6 +7,12 @@ import flutter_callkit_incoming
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
|
||||
// Engine startuje s procesem, ne se scénou. VoIP push umí appku spustit na
|
||||
// pozadí, aniž by iOS připojil scénu (a tím vytvořil FlutterViewController).
|
||||
// CallKit plugin musí být zaregistrovaný dřív, než push dorazí, a Dart musí
|
||||
// běžet, aby vyzvedl nabídku hovoru z relaye. SceneDelegate tenhle engine
|
||||
// jen zobrazí.
|
||||
let flutterEngine = FlutterEngine(name: "sepot")
|
||||
// PushKit registry musí žít po celou dobu běhu appky, jinak VoIP pushe nechodí.
|
||||
private var voipRegistry: PKPushRegistry?
|
||||
private var apnsChannel: FlutterMethodChannel?
|
||||
@@ -17,14 +23,16 @@ import flutter_callkit_incoming
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
let controller = window?.rootViewController as! FlutterViewController
|
||||
flutterEngine.run()
|
||||
GeneratedPluginRegistrant.register(with: flutterEngine)
|
||||
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "cz.sepot.sepot/apns", binaryMessenger: controller.binaryMessenger)
|
||||
name: "cz.sepot.sepot/apns", binaryMessenger: flutterEngine.binaryMessenger)
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
switch call.method {
|
||||
case "register":
|
||||
// Nativní APNs registrace (bez Firebase) — token přijde v callbacku
|
||||
application.registerForRemoteNotifications()
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
result(nil)
|
||||
case "getToken":
|
||||
result(self?.pendingToken)
|
||||
@@ -34,8 +42,6 @@ import flutter_callkit_incoming
|
||||
}
|
||||
apnsChannel = channel
|
||||
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
|
||||
// Hovory: VoIP push z relaye → CallKit.
|
||||
let registry = PKPushRegistry(queue: DispatchQueue.main)
|
||||
registry.delegate = self
|
||||
|
||||
|
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 |
@@ -48,8 +48,25 @@
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
// SDK iOS 27 bez scénového životního cyklu appku nespustí (TN3187). Scéna jen
|
||||
// ukáže engine, který AppDelegate spustil při startu procesu — viz komentář
|
||||
// u `AppDelegate.flutterEngine`.
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
override func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
guard let windowScene = scene as? UIWindowScene,
|
||||
let appDelegate = UIApplication.shared.delegate as? AppDelegate
|
||||
else { return }
|
||||
|
||||
let window = UIWindow(windowScene: windowScene)
|
||||
window.rootViewController = FlutterViewController(
|
||||
engine: appDelegate.flutterEngine, nibName: nil, bundle: nil)
|
||||
self.window = window
|
||||
window.makeKeyAndVisible()
|
||||
|
||||
// Super podle okna scény přihlásí engine k událostem scény pro pluginy.
|
||||
// Musí běžet dřív, než okno dostane i delegát aplikace: našel-li by ho
|
||||
// tam, Flutter by ho bral jako starý způsob startu a přesunul do nového okna.
|
||||
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||
|
||||
// Pluginy, které okno hledají přes delegáta aplikace (local_auth_darwin
|
||||
// u dialogu „biometrie není nastavená“), by ho jinak nenašly.
|
||||
appDelegate.window = window
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -236,9 +236,15 @@ class CallController extends Notifier<CallState> {
|
||||
_ringId = pushRing.id;
|
||||
if (pushRing.accepted) {
|
||||
await accept();
|
||||
} else if (CallRinger.declinedEarly.remove(pushRing.id)) {
|
||||
await reject();
|
||||
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 =
|
||||
@@ -249,6 +255,15 @@ class CallController extends Notifier<CallState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
@@ -389,11 +404,19 @@ class CallController extends Notifier<CallState> {
|
||||
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 [];
|
||||
if (turn == null || turn.urls.isEmpty) return const [_stunFallback];
|
||||
return [
|
||||
{
|
||||
'urls': turn.urls,
|
||||
|
||||
@@ -22,6 +22,13 @@ class CallRinger {
|
||||
/// 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.
|
||||
@@ -65,12 +72,41 @@ class CallRinger {
|
||||
_params(id: callId, name: name, video: video));
|
||||
|
||||
/// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme.
|
||||
static Future<void> ringFromPush() => FlutterCallkitIncoming
|
||||
.showCallkitIncoming(_params(
|
||||
id: const Uuid().v4(),
|
||||
name: _appName,
|
||||
video: false,
|
||||
extra: {pushMarker: true}));
|
||||
/// 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.
|
||||
@@ -84,6 +120,14 @@ class CallRinger {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -81,6 +81,32 @@ class SepotSealedMessage {
|
||||
}
|
||||
|
||||
/// Jeden one-time prekey k publikaci na relay.
|
||||
/// Nový signed prekey k nahrání na relay (rotace).
|
||||
class SepotSignedPreKey {
|
||||
SepotSignedPreKey({
|
||||
required this.id,
|
||||
required this.publicKey,
|
||||
required this.signature,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final Uint8List publicKey;
|
||||
final Uint8List signature;
|
||||
}
|
||||
|
||||
/// Nový last-resort Kyber prekey k nahrání na relay.
|
||||
class SepotKyberPreKey {
|
||||
SepotKyberPreKey({
|
||||
required this.id,
|
||||
required this.publicKey,
|
||||
required this.signature,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final Uint8List publicKey;
|
||||
final Uint8List signature;
|
||||
}
|
||||
|
||||
class SepotOneTimePreKey {
|
||||
SepotOneTimePreKey({required this.id, required this.publicKey});
|
||||
|
||||
@@ -305,6 +331,18 @@ class SepotCrypto {
|
||||
Future<bool> hasSession(ProtocolAddress remote) =>
|
||||
_stores.session.containsSession(remote);
|
||||
|
||||
/// Identity klíč, pod kterým naposledy vznikla relace s [remote]
|
||||
/// (zapisuje ho libsignal při X3DH i při příjmu PreKey zprávy).
|
||||
/// Null = zatím žádný.
|
||||
Future<Uint8List?> sessionIdentity(ProtocolAddress remote) async {
|
||||
final key = await _stores.identity.getIdentity(remote);
|
||||
return key == null ? null : Uint8List.fromList(key.serialize().toList());
|
||||
}
|
||||
|
||||
/// Zahodí relaci s [remote]; další zpráva založí novou z bundle.
|
||||
Future<void> dropSession(ProtocolAddress remote) =>
|
||||
_stores.session.deleteSession(remote);
|
||||
|
||||
/// X3DH/PQXDH: zpracuje bundle protistrany a založí session.
|
||||
Future<void> processRemoteBundle(
|
||||
ProtocolAddress remote,
|
||||
@@ -420,4 +458,64 @@ class SepotCrypto {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Nový signed prekey (rotace). Starý zůstává ve store — relace založené
|
||||
/// z bundle těsně před rotací ho ještě potřebují.
|
||||
Future<SepotSignedPreKey> generateSignedPreKey({
|
||||
required int id,
|
||||
required int timestampMs,
|
||||
}) async {
|
||||
final identityPrivate = PrivateKey.deserialize(
|
||||
bytes: _identity.privateKey.toList(),
|
||||
);
|
||||
final private = PrivateKey.generate();
|
||||
final public = private.getPublicKey();
|
||||
final signature = identityPrivate.sign(
|
||||
message: public.serialize().toList(),
|
||||
);
|
||||
await _stores.signedPreKey.storeSignedPreKey(
|
||||
id,
|
||||
SignedPreKeyRecord(
|
||||
id: id,
|
||||
publicKey: public,
|
||||
privateKey: private,
|
||||
signature: signature.toList(),
|
||||
timestamp: BigInt.from(timestampMs),
|
||||
),
|
||||
);
|
||||
return SepotSignedPreKey(
|
||||
id: id,
|
||||
publicKey: Uint8List.fromList(public.serialize().toList()),
|
||||
signature: Uint8List.fromList(signature.toList()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Nový last-resort Kyber prekey (rotace PQXDH kotvy).
|
||||
Future<SepotKyberPreKey> generateKyberPreKey({
|
||||
required int id,
|
||||
required int timestampMs,
|
||||
}) async {
|
||||
final identityPrivate = PrivateKey.deserialize(
|
||||
bytes: _identity.privateKey.toList(),
|
||||
);
|
||||
final keyPair = KyberKeyPair.generate();
|
||||
final public = keyPair.getPublicKey();
|
||||
final signature = identityPrivate.sign(
|
||||
message: public.serialize().toList(),
|
||||
);
|
||||
await _stores.kyberPreKey.storeKyberPreKey(
|
||||
id,
|
||||
KyberPreKeyRecord.create(
|
||||
id: id,
|
||||
keyPair: keyPair,
|
||||
signature: signature.toList(),
|
||||
timestamp: BigInt.from(timestampMs),
|
||||
),
|
||||
);
|
||||
return SepotKyberPreKey(
|
||||
id: id,
|
||||
publicKey: Uint8List.fromList(public.serialize().toList()),
|
||||
signature: Uint8List.fromList(signature.toList()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
|
||||
/// Jméno kontaktu pro UI. U žádosti o zprávu s viditelnou značkou, že si
|
||||
/// jméno zvolil odesílatel sám: „Máma (neznámý)“ se nesmí na pohled
|
||||
/// shodovat s přijatou „Mámou“ v seznamu, hlavičce ani ve skupině.
|
||||
String contactName(AppLocalizations l10n, Contact contact) {
|
||||
if (contact.displayName.isEmpty) return l10n.commonUnknownContact;
|
||||
return contact.accepted
|
||||
? contact.displayName
|
||||
: l10n.contactRequestName(contact.displayName);
|
||||
}
|
||||
@@ -64,9 +64,17 @@ class RelayClient {
|
||||
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;
|
||||
|
||||
@@ -100,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,
|
||||
@@ -144,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
|
||||
@@ -280,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;
|
||||
@@ -313,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([
|
||||
@@ -345,6 +420,10 @@ class RelayClient {
|
||||
if (!_turnController.isClosed) {
|
||||
_turnController.add(frame.turnCredentials);
|
||||
}
|
||||
} else if (frame.hasPrekeysLow()) {
|
||||
if (!_prekeysLowController.isClosed) {
|
||||
_prekeysLowController.add(frame.prekeysLow.remaining);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: _onDisconnected,
|
||||
@@ -389,14 +468,21 @@ class RelayClient {
|
||||
final receiptFuture = _receiptController.stream
|
||||
.firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid))
|
||||
.timeout(timeout);
|
||||
_send(pb.ClientFrame(
|
||||
envelope: pb.Envelope(
|
||||
dstAddress: dstAddress,
|
||||
msgUuid: msgUuid,
|
||||
contentHint: hint,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -455,6 +541,7 @@ class RelayClient {
|
||||
_transport.close();
|
||||
await _stateController.close();
|
||||
await _turnController.close();
|
||||
await _prekeysLowController.close();
|
||||
await _deliverController.close();
|
||||
await _receiptController.close();
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ class PushBootstrap {
|
||||
static Future<bool> registrationFailed() async =>
|
||||
await _store.read(key: _registrationFailedKey) == '1';
|
||||
|
||||
static Future<void> _setRegistrationFailed(bool failed) => failed
|
||||
static Future<void> setRegistrationFailed(bool failed) => failed
|
||||
? _store.write(key: _registrationFailedKey, value: '1')
|
||||
: _store.delete(key: _registrationFailedKey);
|
||||
|
||||
@@ -87,11 +87,13 @@ class PushBootstrap {
|
||||
if (Platform.isIOS) {
|
||||
ApnsBridge.onWake = _wake;
|
||||
ApnsBridge.ensureHandler();
|
||||
// Zvonění z VoIP pushe bez nabídky nesmí zvonit celých 45 s.
|
||||
unawaited(CallRinger.expireStalePushRings());
|
||||
} else {
|
||||
await UnifiedPush.initialize(
|
||||
onNewEndpoint: _onNewEndpoint,
|
||||
onRegistrationFailed: (_, _) =>
|
||||
unawaited(_setRegistrationFailed(true)),
|
||||
unawaited(setRegistrationFailed(true)),
|
||||
onUnregistered: _onUnregistered,
|
||||
onMessage: _onMessage,
|
||||
);
|
||||
@@ -136,7 +138,7 @@ class PushBootstrap {
|
||||
}
|
||||
|
||||
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
|
||||
unawaited(_setRegistrationFailed(false)); // registrace se povedla
|
||||
unawaited(setRegistrationFailed(false)); // registrace se povedla
|
||||
final s = _service;
|
||||
if (s != null) {
|
||||
s.handleNewEndpoint(endpoint.url);
|
||||
|
||||
@@ -27,6 +27,14 @@ class PushNotifier {
|
||||
await _plugin.initialize(
|
||||
InitializationSettings(
|
||||
android: const AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
// Bez iOS nastavení plugin hodí ArgumentError a s ním padala celá
|
||||
// APNs registrace — iPhony relayi neposlaly token vůbec. O oprávnění
|
||||
// se žádá až v [requestPermission], ne při první notifikaci.
|
||||
iOS: const DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
),
|
||||
linux: LinuxInitializationSettings(
|
||||
defaultActionName: l10n.pushActionOpen,
|
||||
),
|
||||
@@ -48,17 +56,23 @@ class PushNotifier {
|
||||
priority: Priority.high,
|
||||
category: AndroidNotificationCategory.message,
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(),
|
||||
linux: const LinuxNotificationDetails(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Android 13+ runtime permission — volat při zapnutí pushe v nastavení.
|
||||
/// Oprávnění k viditelným notifikacím (Android 13+, iOS) — volat při
|
||||
/// zapnutí pushe. Tichý budíček na iOS ho nepotřebuje.
|
||||
static Future<void> requestPermission() async {
|
||||
await _ensureInit();
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true);
|
||||
}
|
||||
|
||||
/// Notifikace o přijaté zprávě, když appka běží, ale není v popředí.
|
||||
@@ -75,6 +89,19 @@ class PushNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
/// Zpráva od neznámého odesílatele (žádost o zprávu): bez jména, které
|
||||
/// si zvolil sám.
|
||||
static Future<void> showMessageRequestNotification() async {
|
||||
await _ensureInit();
|
||||
final l10n = l10nFor(await LocaleNotifier.readStored());
|
||||
await _plugin.show(
|
||||
_notificationId,
|
||||
l10n.commonAppName,
|
||||
l10n.pushMessageRequest,
|
||||
_details(l10n),
|
||||
);
|
||||
}
|
||||
|
||||
/// Generická notifikace bez obsahu. Klepnutí otevře appku (unlock →
|
||||
/// WS připojení → fronta se vyleje normální cestou).
|
||||
static Future<void> showWakeNotification() async {
|
||||
|
||||
@@ -27,6 +27,7 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
|
||||
import 'package:sepot/core/calls/call_ringer.dart';
|
||||
import 'package:sepot/core/push/apns_ios.dart';
|
||||
import 'package:sepot/core/push/push_bootstrap.dart';
|
||||
@@ -35,10 +36,27 @@ import 'package:sepot/core/services/messaging_service.dart';
|
||||
import 'package:sepot/proto/sepot.pb.dart' as pb;
|
||||
import 'package:unifiedpush/unifiedpush.dart';
|
||||
|
||||
/// Nativní strana iOS pushů. Oddělená od [PushService], aby šlo pořadí
|
||||
/// kroků a skládání registrace ověřit testem bez iPhonu.
|
||||
class IosPushPlatform {
|
||||
const IosPushPlatform();
|
||||
|
||||
bool get isIOS => Platform.isIOS;
|
||||
Future<void> registerApns() => ApnsBridge.register();
|
||||
Future<String?> apnsToken() => ApnsBridge.currentToken();
|
||||
Future<String?> voipToken() => CallRinger.voipToken();
|
||||
Future<void> requestPermission() => PushNotifier.requestPermission();
|
||||
Future<void> writeMode(String mode) => PushBootstrap.writeMode(mode);
|
||||
Future<void> setRegistrationFailed(bool failed) =>
|
||||
PushBootstrap.setRegistrationFailed(failed);
|
||||
}
|
||||
|
||||
class PushService {
|
||||
PushService(this._messaging);
|
||||
PushService(this._messaging, {IosPushPlatform ios = const IosPushPlatform()})
|
||||
: _ios = ios;
|
||||
|
||||
final MessagingService _messaging;
|
||||
final IosPushPlatform _ios;
|
||||
StreamSubscription<String>? _fcmTokenSub;
|
||||
StreamSubscription<RemoteMessage>? _fcmMessageSub;
|
||||
|
||||
@@ -52,7 +70,15 @@ class PushService {
|
||||
/// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.)
|
||||
bool get canWake => _messaging.isStarted;
|
||||
|
||||
void wake() => _messaging.onPushWake();
|
||||
/// Budíček: běžící messaging připojí WS; po auto-locku (zamčená DB) aspoň
|
||||
/// generická notifikace bez obsahu, ať se uživatel o zprávě dozví.
|
||||
void wake() {
|
||||
if (canWake) {
|
||||
_messaging.onPushWake();
|
||||
} else {
|
||||
unawaited(PushNotifier.showWakeNotification());
|
||||
}
|
||||
}
|
||||
|
||||
void handleNewEndpoint(String url) {
|
||||
_messaging.registerPush(_registration(
|
||||
@@ -86,34 +112,66 @@ class PushService {
|
||||
|
||||
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)),
|
||||
/// Registrace iOS pushů pro relay: APNs token pro zprávy + VoIP token pro
|
||||
/// hovory (relay drží obojí u zařízení). VoIP token jde i sám, s prázdným
|
||||
/// endpointem — PushKit ho vydá nezávisle na APNs a hovory nesmí čekat,
|
||||
/// až Apple vydá i token pro zprávy. Null = zatím není co poslat.
|
||||
@visibleForTesting
|
||||
static pb.RegisterPush? apnsRegistration({
|
||||
String? apnsToken,
|
||||
String? voipToken,
|
||||
}) {
|
||||
if (apnsToken == null && voipToken == null) return null;
|
||||
final reg = pb.RegisterPush(
|
||||
platform: pb.RegisterPush_Platform.PLATFORM_APNS,
|
||||
endpoint: apnsToken == null
|
||||
? Uint8List(0)
|
||||
: Uint8List.fromList(utf8.encode(apnsToken)),
|
||||
);
|
||||
if (voip != null) reg.voipToken = utf8.encode(voip);
|
||||
_messaging.registerPush(reg);
|
||||
if (voipToken != null) reg.voipToken = utf8.encode(voipToken);
|
||||
return 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);
|
||||
Future<void> _sendApnsRegistration() async {
|
||||
final reg = apnsRegistration(
|
||||
apnsToken: _apnsToken,
|
||||
voipToken: await _ios.voipToken(),
|
||||
);
|
||||
if (reg != null) _messaging.registerPush(reg);
|
||||
}
|
||||
|
||||
Future<void> _registerApnsToken(String token) async {
|
||||
_apnsToken = token;
|
||||
await _sendApnsRegistration();
|
||||
}
|
||||
|
||||
/// PushKit vydal (nový) VoIP token — poslat ho relayi, i když APNs token
|
||||
/// pro zprávy ještě nedorazil.
|
||||
Future<void> refreshVoipToken() => _sendApnsRegistration();
|
||||
|
||||
/// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
|
||||
/// asynchronně a sám se pošle relayi. Bez Googlu.
|
||||
///
|
||||
/// Registrace u Apple jde PŘED žádostí o oprávnění: tichý budíček
|
||||
/// oprávnění nepotřebuje, takže odmítnutý (nebo selhaný) dialog nesmí
|
||||
/// zastavit doručování zpráv.
|
||||
Future<void> enableApns() async {
|
||||
await init();
|
||||
await PushNotifier.requestPermission();
|
||||
await ApnsBridge.register();
|
||||
final existing = await ApnsBridge.currentToken();
|
||||
if (existing != null) _registerApnsToken(existing);
|
||||
await PushBootstrap.writeMode('apns');
|
||||
await _ios.registerApns();
|
||||
await _ios.writeMode('apns');
|
||||
final existing = await _ios.apnsToken();
|
||||
if (existing != null) {
|
||||
await _registerApnsToken(existing);
|
||||
} else {
|
||||
// VoIP token mohl přijít dřív než APNs — hovory ať zvoní hned.
|
||||
await _sendApnsRegistration();
|
||||
}
|
||||
try {
|
||||
await _ios.requestPermission();
|
||||
} catch (e, s) {
|
||||
// Bez oprávnění chodí budíčky dál, jen bez viditelné notifikace.
|
||||
debugPrint('Oprávnění k notifikacím selhalo: $e\n$s');
|
||||
}
|
||||
}
|
||||
|
||||
/// Zapne push: použije uloženého/výchozího distributora a zaregistruje se.
|
||||
@@ -130,10 +188,14 @@ class PushService {
|
||||
}
|
||||
|
||||
/// Seznam nainstalovaných distributorů (pro výběr v nastavení).
|
||||
Future<List<String>> distributors() => UnifiedPush.getDistributors();
|
||||
/// UnifiedPush existuje jen na Androidu; jinde by volání pluginu vyhodilo
|
||||
/// UnimplementedError (iOS: dlaždice Push v Nastavení).
|
||||
Future<List<String>> distributors() async =>
|
||||
Platform.isAndroid ? UnifiedPush.getDistributors() : const [];
|
||||
|
||||
/// Aktuálně uložený distributor (null = push vypnutý / nevybraný).
|
||||
Future<String?> currentDistributor() => UnifiedPush.getDistributor();
|
||||
Future<String?> currentDistributor() async =>
|
||||
Platform.isAndroid ? UnifiedPush.getDistributor() : null;
|
||||
|
||||
/// Vybere konkrétního distributora a zaregistruje se u něj.
|
||||
Future<void> useDistributor(String distributor) async {
|
||||
@@ -150,7 +212,7 @@ class PushService {
|
||||
}
|
||||
|
||||
Future<bool> get isEnabled async =>
|
||||
(await UnifiedPush.getDistributor()) != null;
|
||||
Platform.isAndroid && (await UnifiedPush.getDistributor()) != null;
|
||||
|
||||
// ── Režimy (off / up / fcm) ─────────────────────────────────────────
|
||||
|
||||
@@ -159,6 +221,7 @@ class PushService {
|
||||
Future<String> currentMode() async {
|
||||
final mode = await PushBootstrap.readMode();
|
||||
if (mode != 'off') return mode;
|
||||
if (!Platform.isAndroid) return mode;
|
||||
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
|
||||
}
|
||||
|
||||
@@ -232,10 +295,15 @@ class PushService {
|
||||
Future<void> restoreMode() async {
|
||||
// iOS: jediná smysluplná cesta je APNs (UnifiedPush je Android-only).
|
||||
// Zapínáme ho automaticky — pro iOS messenger jsou notifikace základ.
|
||||
if (Platform.isIOS) {
|
||||
if (_ios.isIOS) {
|
||||
try {
|
||||
await enableApns();
|
||||
} catch (_) {
|
||||
await _ios.setRegistrationFailed(false);
|
||||
} catch (e, s) {
|
||||
// Dřív se chyba spolkla a iPhone tiše zůstal bez budíčků. Teď ji
|
||||
// uvidí stav pushů v chat listu (PushHealth.failed).
|
||||
debugPrint('APNs registrace selhala: $e\n$s');
|
||||
await _ios.setRegistrationFailed(true);
|
||||
await init();
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/// Rozpoznání jmen, která se pletou s existujícím kontaktem.
|
||||
///
|
||||
/// Jméno v Šepotu si každý volí sám, takže „Máma“ od cizího klíče je
|
||||
/// nejlevnější phishing. Porovnává se „kostra“ jména: bez velikosti
|
||||
/// písmen, diakritiky, mezer, interpunkce a neviditelných znaků, s cyrilicí
|
||||
/// a řečtinou převedenou na latinku. „MAMA“, „Мáма“ (cyrilské М) i
|
||||
/// „Máma“ s neviditelnou mezerou uprostřed tak padnou na totéž jako „Máma“.
|
||||
library;
|
||||
|
||||
import 'package:sepot/core/utils/text_fold.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
|
||||
// Znaky, které na displeji vypadají jako latinka. Jen ty, co se v malých
|
||||
// písmenech opravdu pletou; ne celý Unicode confusables seznam — falešná
|
||||
// shoda tu nic nerozbije, jen přidá varování navíc.
|
||||
const _confusables = {
|
||||
'а': 'a', 'в': 'b', 'е': 'e', 'ё': 'e', 'к': 'k', 'м': 'm', 'н': 'h',
|
||||
'о': 'o', 'р': 'p', 'с': 'c', 'т': 't', 'у': 'y', 'х': 'x', 'і': 'l',
|
||||
'ї': 'l', 'ј': 'j', 'ѕ': 's', 'ԁ': 'd', 'ɡ': 'g', 'α': 'a', 'β': 'b',
|
||||
'ε': 'e', 'ι': 'l', 'κ': 'k', 'ν': 'v', 'ο': 'o', 'ρ': 'p', 'τ': 't',
|
||||
'υ': 'u', 'χ': 'x', 'ү': 'y', '0': 'o', '1': 'l', 'i': 'l', '|': 'l',
|
||||
// Latinská diakritika mimo češtinu a slovenštinu (tu skládá už fold).
|
||||
'à': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'ā': 'a', 'ą': 'a', 'ç': 'c',
|
||||
'ć': 'c', 'è': 'e', 'ê': 'e', 'ē': 'e', 'ę': 'e', 'ì': 'l', 'î': 'l',
|
||||
'ï': 'l', 'ł': 'l', 'ñ': 'n', 'ń': 'n', 'ò': 'o', 'õ': 'o', 'ø': 'o',
|
||||
'ő': 'o', 'ś': 's', 'ù': 'u', 'û': 'u', 'ű': 'u', 'ÿ': 'y', 'ź': 'z',
|
||||
'ż': 'z',
|
||||
};
|
||||
|
||||
final _invisible = RegExp(
|
||||
'[\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180E'
|
||||
'\u200B-\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0F\uFEFF]');
|
||||
final _combining = RegExp('[\u0300-\u036F]');
|
||||
final _separators = RegExp(r'[\s\p{P}\p{S}]', unicode: true);
|
||||
|
||||
/// Kostra jména pro porovnání. Prázdný výsledek = jméno nic nenese.
|
||||
String nameSkeleton(String name) {
|
||||
final folded = fold(name
|
||||
.replaceAll(_invisible, '')
|
||||
.replaceAll(_combining, '')
|
||||
.replaceAll(_separators, ''));
|
||||
final out = StringBuffer();
|
||||
for (final ch in folded.split('')) {
|
||||
out.write(_confusables[ch] ?? ch);
|
||||
}
|
||||
// „rn“ se v bezpatkovém písmu čte jako „m“.
|
||||
return out.toString().replaceAll('rn', 'm');
|
||||
}
|
||||
|
||||
/// Přijatý kontakt, se kterým se [name] plete (null = nic podobného).
|
||||
/// [exceptId] = sám odesílatel, ten se se sebou neporovnává.
|
||||
Contact? lookalikeContact(
|
||||
String name,
|
||||
Iterable<Contact> contacts, {
|
||||
String? exceptId,
|
||||
}) {
|
||||
final skeleton = nameSkeleton(name);
|
||||
if (skeleton.isEmpty) return null;
|
||||
for (final c in contacts) {
|
||||
if (!c.accepted || c.id == exceptId) continue;
|
||||
if (nameSkeleton(c.displayName) == skeleton) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import 'package:sepot/core/errors.dart';
|
||||
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
||||
import 'package:sepot/core/network/relay_client.dart';
|
||||
import 'package:sepot/core/network/relay_transport.dart';
|
||||
import 'package:sepot/core/safety/lookalike_names.dart';
|
||||
import 'package:sepot/core/services/group_admin_policy.dart';
|
||||
import 'package:sepot/core/services/image_processor.dart';
|
||||
import 'package:sepot/core/services/typing_tracker.dart';
|
||||
@@ -107,6 +108,56 @@ class MessagingService {
|
||||
StreamSubscription<RelayConnectionState>? _stateSub;
|
||||
Timer? _registerRetryTimer;
|
||||
Timer? _purgeTimer;
|
||||
StreamSubscription<int>? _prekeysLowSub;
|
||||
|
||||
/// Typy E2E obsahu, které tahle verze umí zpracovat. Cokoli jiného se
|
||||
/// ACKne a zahodí (po upgradu už nejde dešifrovat znovu). Veřejná
|
||||
/// konstanta kvůli testu: v 1.2.0 tu chyběl `call` a každá nabídka
|
||||
/// hovoru se tiše zahodila — hovory vůbec nešly přijmout.
|
||||
static const knownContentTypes = {
|
||||
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote', 'call',
|
||||
..._groupControlTypes,
|
||||
};
|
||||
|
||||
/// Prodleva dalšího pokusu o registraci; roste do [_registerRetryMax].
|
||||
Duration _registerRetryDelay = const Duration(seconds: 30);
|
||||
static const _registerRetryMax = Duration(minutes: 10);
|
||||
|
||||
/// Relay odmítl registraci jako konflikt identity (409) — typicky starší
|
||||
/// relay bez převzetí identity po obnově ze 24 slov. UI může vysvětlit.
|
||||
bool _registrationConflict = false;
|
||||
bool get registrationConflict => _registrationConflict;
|
||||
|
||||
/// Rozpracované dodávky: `stop()` (auto-lock) na ně počká, jinak by DB
|
||||
/// zmizela mezi dešifrováním (ratchet už postoupil) a zápisem a zpráva
|
||||
/// by byla nenávratně pryč.
|
||||
int _inFlight = 0;
|
||||
Completer<void>? _drained;
|
||||
|
||||
/// Operace nad ratchet stavem jdou postupně. libsignal načte relaci,
|
||||
/// šifruje a uloží ji zpět s `await` na Dart store uprostřed — dva
|
||||
/// souběžné běhy nad touž relací by odvodily stejný message key a druhá
|
||||
/// obálka by u příjemce propadla. Doručenky, „píše…“ i outbox běží
|
||||
/// `unawaited` vedle uživatelského odeslání, takže zámek je nutný.
|
||||
Future<void> _chain = Future.value();
|
||||
|
||||
Future<T> _serial<T>(Future<T> Function() op) {
|
||||
final next = _chain.then((_) => op());
|
||||
_chain = next.then((_) {}, onError: (_) {});
|
||||
return next;
|
||||
}
|
||||
|
||||
/// Nejvyšší počet znaků jména kontaktu/profilu z drátu nebo odkazu.
|
||||
static const maxDisplayNameLength = 64;
|
||||
|
||||
static String clipName(String name) => name.length > maxDisplayNameLength
|
||||
? name.substring(0, maxDisplayNameLength)
|
||||
: name;
|
||||
|
||||
/// Kolik one-time prekeys doplnit, když relay hlásí nedostatek.
|
||||
static const _otpBatch = 50;
|
||||
static const _spkRotation = Duration(days: 7);
|
||||
static const _kyberRotation = Duration(days: 30);
|
||||
|
||||
/// Běží právě dodatečné odeslání zaseklých zpráv? Reconnectů může přijít
|
||||
/// několik rychle za sebou a dva souběžné průchody by tutéž zprávu poslaly
|
||||
@@ -168,7 +219,7 @@ class MessagingService {
|
||||
final targets = contacts.where((c) =>
|
||||
!c.blocked &&
|
||||
(group == null
|
||||
? c.id == convId
|
||||
? c.id == convId && c.accepted
|
||||
: group.memberIds.contains(c.id) && c.id != _identity?.id));
|
||||
for (final c in targets) {
|
||||
final until = _typingBackoffUntil[c.id];
|
||||
@@ -266,11 +317,13 @@ class MessagingService {
|
||||
final relay = _buildRelay();
|
||||
_relay = relay;
|
||||
_deliverSub = relay.deliveries.listen(_onDeliver);
|
||||
_prekeysLowSub = relay.prekeysLow.listen(_replenishPreKeys);
|
||||
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
|
||||
_stateSub = relay.stateStream.listen((state) {
|
||||
if (state == RelayConnectionState.connected) {
|
||||
unawaited(_flushOutbox());
|
||||
unawaited(_refreshMyAvatarIfOld());
|
||||
unawaited(_rotatePreKeysIfDue());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -337,11 +390,13 @@ class MessagingService {
|
||||
final relay = _buildRelay();
|
||||
_relay = relay;
|
||||
_deliverSub = relay.deliveries.listen(_onDeliver);
|
||||
_prekeysLowSub = relay.prekeysLow.listen(_replenishPreKeys);
|
||||
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
|
||||
_stateSub = relay.stateStream.listen((state) {
|
||||
if (state == RelayConnectionState.connected) {
|
||||
unawaited(_flushOutbox());
|
||||
unawaited(_refreshMyAvatarIfOld());
|
||||
unawaited(_rotatePreKeysIfDue());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -376,16 +431,109 @@ class MessagingService {
|
||||
Future<void> _registerWithRetry() async {
|
||||
try {
|
||||
await _register();
|
||||
} on RelayException catch (e) {
|
||||
// 409 = identita na relayi existuje a relay neumí převzetí (starší
|
||||
// verze). Opakovat má smysl (relay se aktualizuje), ale ne každých
|
||||
// 30 s navždy — a UI má vědět proč.
|
||||
_registrationConflict = e.statusCode == 409;
|
||||
_scheduleRegisterRetry();
|
||||
return;
|
||||
} catch (_) {
|
||||
_registerRetryTimer?.cancel();
|
||||
_registerRetryTimer =
|
||||
Timer(const Duration(seconds: 30), _registerWithRetry);
|
||||
_scheduleRegisterRetry();
|
||||
return;
|
||||
}
|
||||
_registrationConflict = false;
|
||||
_registerRetryDelay = const Duration(seconds: 30);
|
||||
_relay!.start();
|
||||
await _refreshSenderCert();
|
||||
}
|
||||
|
||||
void _scheduleRegisterRetry() {
|
||||
_registerRetryTimer?.cancel();
|
||||
_registerRetryTimer = Timer(_registerRetryDelay, _registerWithRetry);
|
||||
final doubled = _registerRetryDelay * 2;
|
||||
_registerRetryDelay =
|
||||
doubled > _registerRetryMax ? _registerRetryMax : doubled;
|
||||
onUpdate?.call();
|
||||
}
|
||||
|
||||
int? _kvInt(CryptoDb db, String key) {
|
||||
final raw = db.kvGet(key);
|
||||
return raw == null ? null : int.tryParse(utf8.decode(raw));
|
||||
}
|
||||
|
||||
/// Relay po AUTH hlásí docházející one-time prekeys → vygenerovat dávku
|
||||
/// a nahrát. Bez toho byl účet po vyčerpání zásoby natrvalo bez OTP
|
||||
/// a každá nová relace k němu běžela bez jednorázového klíče.
|
||||
Future<void> _replenishPreKeys(int remaining) async {
|
||||
final db = _db;
|
||||
final crypto = _crypto;
|
||||
final relay = _relay;
|
||||
if (db == null || crypto == null || relay == null || accountId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Registrace nahrála id 1–100; další dávky navazují.
|
||||
final next = _kvInt(db, 'otp_next_id') ?? 101;
|
||||
final otps = await _serial(
|
||||
() => crypto.generateOneTimePreKeys(startId: next, count: _otpBatch));
|
||||
await relay.uploadKeys(oneTimePreKeys: otps);
|
||||
db.kvPut('otp_next_id', utf8.encode('${next + _otpBatch}'));
|
||||
} catch (_) {
|
||||
// Síť / starší relay bez /v1/keys — relay to zopakuje po dalším AUTH.
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotace signed prekey (týdně) a last-resort Kyber prekey (měsíčně),
|
||||
/// Staré klíče zůstávají ve store — relace z bundle
|
||||
/// staženého těsně před rotací je ještě potřebují.
|
||||
Future<void> _rotatePreKeysIfDue() async {
|
||||
final db = _db;
|
||||
final crypto = _crypto;
|
||||
final relay = _relay;
|
||||
if (db == null || crypto == null || relay == null || accountId == null) {
|
||||
return;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
bool due(String key, Duration period) {
|
||||
final at = _kvInt(db, key);
|
||||
return at == null ||
|
||||
now.difference(DateTime.fromMillisecondsSinceEpoch(at)) > period;
|
||||
}
|
||||
|
||||
final spkDue = due('spk_rotated_at', _spkRotation);
|
||||
final kyberDue = due('kyber_rotated_at', _kyberRotation);
|
||||
if (!spkDue && !kyberDue) return;
|
||||
try {
|
||||
SepotSignedPreKey? spk;
|
||||
SepotKyberPreKey? kyber;
|
||||
if (spkDue) {
|
||||
final id = _kvInt(db, 'spk_next_id') ?? 2;
|
||||
spk = await _serial(() => crypto.generateSignedPreKey(
|
||||
id: id, timestampMs: now.millisecondsSinceEpoch));
|
||||
}
|
||||
if (kyberDue) {
|
||||
final id = _kvInt(db, 'kyber_next_id') ?? 2;
|
||||
kyber = await _serial(() => crypto.generateKyberPreKey(
|
||||
id: id, timestampMs: now.millisecondsSinceEpoch));
|
||||
}
|
||||
await relay.uploadKeys(signedPreKey: spk, kyberPreKey: kyber);
|
||||
final stamp = utf8.encode('${now.millisecondsSinceEpoch}');
|
||||
db.transaction(() {
|
||||
if (spk != null) {
|
||||
db.kvPut('spk_next_id', utf8.encode('${spk.id + 1}'));
|
||||
db.kvPut('spk_rotated_at', stamp);
|
||||
}
|
||||
if (kyber != null) {
|
||||
db.kvPut('kyber_next_id', utf8.encode('${kyber.id + 1}'));
|
||||
db.kvPut('kyber_rotated_at', stamp);
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
// Starší relay bez /v1/keys nebo offline — zkusí se po dalším connectu.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _register() async {
|
||||
final db = _db!;
|
||||
final crypto = _crypto!;
|
||||
@@ -410,10 +558,17 @@ class MessagingService {
|
||||
deliveryAddress: deliveryAddress,
|
||||
);
|
||||
|
||||
final stamp = utf8.encode('$nowMs');
|
||||
db.transaction(() {
|
||||
db.kvPut('account_id', utf8.encode(reg.accountId));
|
||||
db.kvPut('device_uuid', utf8.encode(reg.deviceUuid));
|
||||
db.kvPut('delivery_address', deliveryAddress);
|
||||
// Id pro doplňování a rotace klíčů navazují na registraci.
|
||||
db.kvPut('otp_next_id', utf8.encode('101'));
|
||||
db.kvPut('spk_next_id', utf8.encode('2'));
|
||||
db.kvPut('kyber_next_id', utf8.encode('2'));
|
||||
db.kvPut('spk_rotated_at', stamp);
|
||||
db.kvPut('kyber_rotated_at', stamp);
|
||||
});
|
||||
onUpdate?.call();
|
||||
}
|
||||
@@ -839,6 +994,8 @@ class MessagingService {
|
||||
Future<void> sendReadReceipts(Contact contact, List<String> messageIds,
|
||||
{String? gid}) async {
|
||||
if (messageIds.isEmpty) return;
|
||||
// Neznámý odesílatel se nedozví, že jsem jeho zprávu otevřel.
|
||||
if (gid == null && !contact.accepted) return;
|
||||
final enabled =
|
||||
(await storage.getSetting<bool>('read_receipts_enabled')) ?? false;
|
||||
if (!enabled) return;
|
||||
@@ -1794,19 +1951,26 @@ class MessagingService {
|
||||
verified: known.verified,
|
||||
notes: known.notes,
|
||||
blocked: known.blocked,
|
||||
avatar: known.avatar,
|
||||
accepted: known.accepted,
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
final name = (e['n'] as String?)?.trim();
|
||||
await storage.saveContact(Contact.fromPublicIdentity(PublicIdentity(
|
||||
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
||||
displayName: (name == null || name.isEmpty) ? '' : name,
|
||||
identityKey: base64Decode(e['ik'] as String),
|
||||
createdAt: DateTime.now(),
|
||||
accountId: aid,
|
||||
deliveryAddress: daB64 == null ? null : base64Decode(daB64),
|
||||
)));
|
||||
// Člena skupiny jsem si nepřidal já: jméno mu dal on sám (nebo ten,
|
||||
// kdo roster poslal). Jako u příchozí zprávy je to žádost.
|
||||
await storage.saveContact(Contact.fromPublicIdentity(
|
||||
PublicIdentity(
|
||||
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
||||
displayName: (name == null || name.isEmpty) ? '' : clipName(name),
|
||||
identityKey: base64Decode(e['ik'] as String),
|
||||
createdAt: DateTime.now(),
|
||||
accountId: aid,
|
||||
deliveryAddress: daB64 == null ? null : base64Decode(daB64),
|
||||
),
|
||||
accepted: false,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2009,6 +2173,10 @@ class MessagingService {
|
||||
'txt': caption ?? '',
|
||||
're': ?message.replyToId,
|
||||
'ttl': ?message.disappearAfterSeconds,
|
||||
// Anketa a zmínky patří k téže zprávě i při dodatečném odeslání —
|
||||
// bez nich by po reconnectu dorazil jen holý záložní text.
|
||||
'poll': ?message.poll?.toWire(),
|
||||
if (message.mentions.isNotEmpty) 'mn': message.mentions,
|
||||
'p': _profile(),
|
||||
};
|
||||
}
|
||||
@@ -2053,22 +2221,29 @@ class MessagingService {
|
||||
}
|
||||
final peerAddr = ProtocolAddress(name: peerAid, deviceId: 1);
|
||||
|
||||
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
|
||||
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
|
||||
if (!await crypto.hasSession(peerAddr)) {
|
||||
if (ephemeral) return null;
|
||||
final bundle = await relay.fetchBundle(peerDa);
|
||||
if (!_listEquals(bundle.identityKey, peer.identityKey)) {
|
||||
throw const IdentityKeyMismatchError();
|
||||
// Krypto část jde přes [_serial] (viz komentář u pole): relace se
|
||||
// načte, zašifruje a uloží bez souběhu. Odeslání po síti už zámek
|
||||
// nedrží — čekání na receipt by blokovalo všechny ostatní.
|
||||
final sealed = await _serial<Uint8List?>(() async {
|
||||
await _dropForeignSession(crypto, peerAddr, contact);
|
||||
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
|
||||
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
|
||||
if (!await crypto.hasSession(peerAddr)) {
|
||||
if (ephemeral) return null;
|
||||
final bundle = await relay.fetchBundle(peerDa);
|
||||
if (!_listEquals(bundle.identityKey, peer.identityKey)) {
|
||||
await _noteIdentityChanged(contact);
|
||||
throw const IdentityKeyMismatchError();
|
||||
}
|
||||
await crypto.processRemoteBundle(peerAddr, bundle);
|
||||
}
|
||||
await crypto.processRemoteBundle(peerAddr, bundle);
|
||||
}
|
||||
|
||||
final sealed = await crypto.sealedEncrypt(
|
||||
recipient: peerAddr,
|
||||
plaintext: utf8.encode(jsonEncode(content)),
|
||||
senderCertificate: await _senderCertificate(),
|
||||
);
|
||||
return crypto.sealedEncrypt(
|
||||
recipient: peerAddr,
|
||||
plaintext: utf8.encode(jsonEncode(content)),
|
||||
senderCertificate: await _senderCertificate(),
|
||||
);
|
||||
});
|
||||
if (sealed == null) return null;
|
||||
|
||||
final receipt = await relay.sendEnvelope(
|
||||
dstAddress: peerDa,
|
||||
@@ -2082,12 +2257,50 @@ class MessagingService {
|
||||
return receipt.status;
|
||||
}
|
||||
|
||||
/// libsignal 5.0.9 nad prázdným paměťovým storem identit důvěřuje
|
||||
/// čemukoli (TOFU bez paměti): příchozí PreKey zpráva s cizím klíčem pod
|
||||
/// stejnou adresou relaci tiše přepíše a další odpověď by šla jemu.
|
||||
/// Klíč relace proto porovnáváme s klíčem kontaktu sami; nesedí-li,
|
||||
/// relace se zahodí (nová vznikne z bundle, který se ověřuje výše)
|
||||
/// a kontakt dostane varování.
|
||||
Future<void> _dropForeignSession(
|
||||
SepotCrypto crypto, ProtocolAddress peerAddr, Contact contact) async {
|
||||
if (!await crypto.hasSession(peerAddr)) return;
|
||||
final bound = await crypto.sessionIdentity(peerAddr);
|
||||
if (bound == null ||
|
||||
_listEquals(bound, contact.publicIdentity.identityKey)) {
|
||||
return;
|
||||
}
|
||||
await crypto.dropSession(peerAddr);
|
||||
await _noteIdentityChanged(contact);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> debugDropForeignSession(
|
||||
SepotCrypto crypto, ProtocolAddress peerAddr, Contact contact) =>
|
||||
_dropForeignSession(crypto, peerAddr, contact);
|
||||
|
||||
// ── Příjem ──────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _onDeliver(pb.Envelope envelope) async {
|
||||
/// Dodávky se zpracovávají postupně (viz [_serial]) a evidují jako
|
||||
/// rozpracované, aby na ně [stop] počkal.
|
||||
void _onDeliver(pb.Envelope envelope) {
|
||||
_inFlight++;
|
||||
unawaited(_serial(() => _handleDelivery(envelope))
|
||||
.then((_) {}, onError: (_) {})
|
||||
.whenComplete(() {
|
||||
_inFlight--;
|
||||
if (_inFlight == 0) _drained?.complete();
|
||||
}));
|
||||
}
|
||||
|
||||
Future<void> _handleDelivery(pb.Envelope envelope) async {
|
||||
final crypto = _crypto;
|
||||
final trustRoot = _trustRoot;
|
||||
if (crypto == null || trustRoot == null) return;
|
||||
// Zamčené úložiště (auto-lock stihl zamknout dřív, než jsme se dostali
|
||||
// na řadu): nedešifrovat — ratchet by postoupil a zápis by selhal.
|
||||
// Relay obálku doručí znovu po odemčení.
|
||||
if (crypto == null || trustRoot == null || !storage.isUnlocked) return;
|
||||
|
||||
final SepotSealedMessage opened;
|
||||
try {
|
||||
@@ -2101,7 +2314,17 @@ class MessagingService {
|
||||
// (může jít o dočasný stav — např. cert refresh).
|
||||
return;
|
||||
}
|
||||
await _processOpened(opened, envelope.msgUuid);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> debugProcessOpened(
|
||||
SepotSealedMessage opened, List<int> msgUuid) =>
|
||||
_processOpened(opened, msgUuid);
|
||||
|
||||
/// Zpracuje už dešifrovanou obálku: kontakt, typ obsahu, uložení, ACK.
|
||||
Future<void> _processOpened(
|
||||
SepotSealedMessage opened, List<int> msgUuid) async {
|
||||
// Od tohohle bodu ratchet postoupil — obálku na relayi už NIKDY znovu
|
||||
// nedešifrujeme (duplicitní decrypt libsignal odmítne). Vadný OBSAH se
|
||||
// proto ACKuje a zahazuje: redelivery nemůže nic spravit, jen by
|
||||
@@ -2110,14 +2333,11 @@ class MessagingService {
|
||||
final content =
|
||||
jsonDecode(utf8.decode(opened.plaintext)) as Map<String, dynamic>;
|
||||
final type = content['t'] as String?;
|
||||
const known = {
|
||||
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote',
|
||||
..._groupControlTypes,
|
||||
};
|
||||
const known = knownContentTypes;
|
||||
if (content['v'] != 1 || !known.contains(type)) {
|
||||
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný
|
||||
// a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
|
||||
_relay?.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2127,15 +2347,19 @@ class MessagingService {
|
||||
// Blokovaný kontakt: zahoď cokoliv od něj (ACK, ať se nefrontuje),
|
||||
// nic neukládej ani nezobrazuj a neposílej doručenku.
|
||||
if (contact.blocked) {
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hovory jen 1:1; skupinový gid tu nemá co dělat.
|
||||
// Nic se neukládá — záznam „Hovor · 3:12“ si zapíše obsluha hovoru.
|
||||
// Žádost o zprávu nezvoní: cizí klíč by jinak rozezvonil telefon
|
||||
// s vlastním zvoleným jménem („Máma volá“).
|
||||
if (type == 'call') {
|
||||
if (content['gid'] == null) onCallSignal?.call(contact, content);
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
if (content['gid'] == null && contact.accepted) {
|
||||
onCallSignal?.call(contact, content);
|
||||
}
|
||||
_relay?.ack(msgUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2145,7 +2369,7 @@ class MessagingService {
|
||||
// nepomůže).
|
||||
if (_groupControlTypes.contains(type)) {
|
||||
await _handleGroupControl(senderFp, type, content);
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2155,7 +2379,7 @@ class MessagingService {
|
||||
final group =
|
||||
gid == null ? null : await _groupForIncoming(gid, senderFp);
|
||||
if (gid != null && group == null) {
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
return;
|
||||
}
|
||||
if (group != null && _noteCapability(group, senderFp, content)) {
|
||||
@@ -2170,7 +2394,7 @@ class MessagingService {
|
||||
final fresh = DateTime.now().difference(wireSentAt(content['ts'])) <
|
||||
TypingTracker.showFor;
|
||||
if (fresh && await _typingEnabled()) typing.mark(convId, senderFp);
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2205,7 +2429,7 @@ class MessagingService {
|
||||
);
|
||||
}
|
||||
}
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
onUpdate?.call();
|
||||
return;
|
||||
}
|
||||
@@ -2214,7 +2438,7 @@ class MessagingService {
|
||||
// novou bublinu. Aplikujeme a ACKneme (retry z fronty nepomůže).
|
||||
if (type == 'rx' || type == 'edit' || type == 'del' || type == 'vote') {
|
||||
await _applyIncomingControl(convId, type!, content, opened);
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
onUpdate?.call();
|
||||
return;
|
||||
}
|
||||
@@ -2228,13 +2452,18 @@ class MessagingService {
|
||||
final replyTo = content['re'] as String?;
|
||||
final Message message;
|
||||
if (type == 'txt') {
|
||||
final text = content['txt'];
|
||||
if (text is! String) {
|
||||
final raw = content['txt'];
|
||||
if (raw is! String) {
|
||||
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
|
||||
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
return;
|
||||
}
|
||||
// Strop délky z drátu: obálka unese 256 KiB a konverzace je jeden
|
||||
// JSON blob — čtvrtmegabajtová bublina by ji trvale zpomalila.
|
||||
const maxWireText = AppConstants.maxMessageLength * 4;
|
||||
final text =
|
||||
raw.length > maxWireText ? raw.substring(0, maxWireText) : raw;
|
||||
message = Message(
|
||||
id: messageId,
|
||||
senderId: contact.publicIdentity.fingerprint.display,
|
||||
@@ -2261,7 +2490,7 @@ class MessagingService {
|
||||
}
|
||||
} on Object {
|
||||
// Trvale vadná metadata — retry z fronty nepomůže, ACKnout
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
return;
|
||||
}
|
||||
message = Message(
|
||||
@@ -2286,7 +2515,7 @@ class MessagingService {
|
||||
// Uloženo na disku → teprve teď smí relay obálku zahodit.
|
||||
// (Blob přílohy do fronty nepatří — stahuje se na pozadí a při
|
||||
// neúspěchu má UI retry, ACK na něj nečeká.)
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
|
||||
// Zprávu už známe (odesílatel ji poslal znovu po výpadku spojení).
|
||||
// Druhá bublina ani další stahování téhož blobu nemá vzniknout, ale
|
||||
@@ -2319,11 +2548,11 @@ class MessagingService {
|
||||
} on FormatException {
|
||||
// Malformovaný obsah (ne-UTF-8/ne-JSON plaintext, vadná base64
|
||||
// v profilu či rosteru) — trvale vadné, ACK a zahodit.
|
||||
_relay?.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
} on TypeError {
|
||||
// Pole se špatným typem (txt/ttl/id/re/p jako číslo apod.) —
|
||||
// trvale vadné, ACK a zahodit.
|
||||
_relay?.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
} catch (_) {
|
||||
// Přechodné selhání zpracování (storage apod.): NEACKovat — další
|
||||
// pokus po reconnectu za nic nedá, i když duplicitní decrypt
|
||||
@@ -2553,6 +2782,7 @@ class MessagingService {
|
||||
// Bez tohohle se zablokovaný kontakt při změně adresy odblokoval.
|
||||
blocked: c.blocked,
|
||||
avatar: avatarChanged ? avatar : c.avatar,
|
||||
accepted: c.accepted,
|
||||
);
|
||||
await storage.saveContact(updated);
|
||||
if (avatarChanged) {
|
||||
@@ -2563,22 +2793,74 @@ class MessagingService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Neznámý odesílatel → nový (neověřený) kontakt
|
||||
// Neznámý klíč = nový kontakt, NIKDY splynutí s existujícím. Identita
|
||||
// je klíč: jméno ani fotka, které si odesílatel zvolil, neříkají nic
|
||||
// o tom, kdo to je. Proto žádost o zprávu, ne kontakt.
|
||||
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
||||
final name = (profile?['n'] as String?)?.trim();
|
||||
final identity = PublicIdentity(
|
||||
displayName: (name == null || name.isEmpty) ? '' : name,
|
||||
displayName: (name == null || name.isEmpty) ? '' : clipName(name),
|
||||
identityKey: opened.senderIdentityKey,
|
||||
createdAt: DateTime.now(),
|
||||
accountId: opened.senderName,
|
||||
deliveryAddress: profileDa == null ? null : base64Decode(profileDa),
|
||||
);
|
||||
final contact = Contact.fromPublicIdentity(identity)..avatar = avatar;
|
||||
final contact = Contact.fromPublicIdentity(identity, accepted: false)
|
||||
..avatar = avatar;
|
||||
await storage.saveContact(contact);
|
||||
if (avatar != null) unawaited(_downloadAvatar(avatar));
|
||||
|
||||
// Účet na relayi patří jednomu klíči navždy (identities.identity_key je
|
||||
// unikátní). Stejný účet s jiným klíčem = relay lže nebo byl převzat:
|
||||
// dosavadní kontakt ztrácí ověření a dostane varování.
|
||||
final previous = contacts
|
||||
.where((c) => c.publicIdentity.accountId == opened.senderName)
|
||||
.firstOrNull;
|
||||
if (previous != null) await _noteIdentityChanged(previous);
|
||||
|
||||
final lookalike =
|
||||
lookalikeContact(identity.displayName, contacts, exceptId: contact.id);
|
||||
if (lookalike != null && lookalike.id != previous?.id) {
|
||||
await _noteLookalike(lookalike, identity.displayName);
|
||||
}
|
||||
return contact;
|
||||
}
|
||||
|
||||
/// Bezpečnostní kód kontaktu se změnil: zrušit ověření a dát to najevo
|
||||
/// v jeho konverzaci. Opakovaný výskyt za sebou nepřidává další řádky.
|
||||
Future<void> _noteIdentityChanged(Contact contact) async {
|
||||
if (contact.verified) {
|
||||
contact.verified = false;
|
||||
await storage.saveContact(contact);
|
||||
}
|
||||
await _addNotice(contact, MessageNotice.identityChanged);
|
||||
}
|
||||
|
||||
/// Někdo cizí se jmenuje skoro stejně jako [contact]. Připomínka do jeho
|
||||
/// konverzace (jen když existuje — nezakládat chat kvůli útočníkovi).
|
||||
Future<void> _noteLookalike(Contact contact, String claimedName) async {
|
||||
if (await storage.loadConversation(contact.id) == null) return;
|
||||
await _addNotice(contact, MessageNotice.lookalikeRequest,
|
||||
content: claimedName);
|
||||
}
|
||||
|
||||
Future<void> _addNotice(Contact contact, MessageNotice notice,
|
||||
{String? content}) async {
|
||||
final me = _identity;
|
||||
if (me == null) return;
|
||||
final last = (await storage.loadConversation(contact.id))?.lastMessage;
|
||||
if (last?.notice == notice && last?.content == content) return;
|
||||
await storage.addMessage(
|
||||
contact.id,
|
||||
Message.notice(
|
||||
contactId: contact.id,
|
||||
myId: me.id,
|
||||
notice: notice,
|
||||
content: content),
|
||||
);
|
||||
onUpdate?.call();
|
||||
}
|
||||
|
||||
// ── Sdílení vlastního kontaktu ──────────────────────────────────────
|
||||
|
||||
/// Veřejná identita včetně routingu — tohle patří do QR/odkazu.
|
||||
@@ -2603,7 +2885,7 @@ class MessagingService {
|
||||
/// Probuzení z push budíčku: ujisti se, že jsme připojení — fronta se
|
||||
/// vyleje po AUTH (relay pošle DELIVER stream). Idempotentní.
|
||||
void onPushWake() {
|
||||
_relay?.start();
|
||||
_relay?.kick();
|
||||
}
|
||||
|
||||
/// Deregistrace účtu na relayi (GDPR čl. 17 / store „smazání účtu").
|
||||
@@ -2631,7 +2913,17 @@ class MessagingService {
|
||||
_purgeTimer?.cancel();
|
||||
_purgeTimer = null;
|
||||
await _deliverSub?.cancel();
|
||||
await _prekeysLowSub?.cancel();
|
||||
await _stateSub?.cancel();
|
||||
// Rozpracované dodávky doběhnou (dešifrováno → zapsáno → ACK), teprve
|
||||
// pak se úložiště smí zamknout. Strop, ať zaseklá dodávka nezablokuje
|
||||
// zámek navždy.
|
||||
if (_inFlight > 0) {
|
||||
final drained = _drained ??= Completer<void>();
|
||||
await drained.future
|
||||
.timeout(const Duration(seconds: 8), onTimeout: () {});
|
||||
}
|
||||
_drained = null;
|
||||
await _relay?.dispose();
|
||||
// DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit
|
||||
_db = null;
|
||||
|
||||
@@ -30,6 +30,16 @@ import 'package:sepot/models/message.dart';
|
||||
/// při onboardingu, který DB rovnou otevře).
|
||||
|
||||
/// Nátlakový PIN je shodný se skutečným — nelze nastavit.
|
||||
/// Příliš mnoho špatných PINů za sebou — další pokus až za [remaining].
|
||||
class PinLockedException implements Exception {
|
||||
const PinLockedException(this.remaining);
|
||||
|
||||
final Duration remaining;
|
||||
|
||||
@override
|
||||
String toString() => 'PIN locked for ${remaining.inSeconds}s';
|
||||
}
|
||||
|
||||
class DecoyPinConflict implements Exception {
|
||||
const DecoyPinConflict();
|
||||
@override
|
||||
@@ -51,6 +61,13 @@ class SecureStorageService {
|
||||
static const _legacyPinHashKey = 'pin_hash';
|
||||
|
||||
static const _decoyWrappedDekKey = 'wrapped_dek_decoy';
|
||||
// Brzda hádání PINu: počet chyb za sebou a čas, do kdy je zámek uzamčený.
|
||||
// V keystore (ne v DB) — DB je právě to, co se hádáním otevírá.
|
||||
static const _pinFailCountKey = 'pin_fail_count';
|
||||
static const _pinLockUntilKey = 'pin_lock_until';
|
||||
static const _pinFreeAttempts = 5;
|
||||
static const _pinLockBase = Duration(seconds: 30);
|
||||
static const _pinLockMax = Duration(minutes: 15);
|
||||
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
final String? _dbPathOverride;
|
||||
@@ -60,12 +77,24 @@ class SecureStorageService {
|
||||
bool _decoyMode = false;
|
||||
|
||||
/// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB.
|
||||
/// V tomhle režimu úložiště nevydá skutečnou identitu ani frázi,
|
||||
/// nestartuje síť a nic z keystore nemění (viz jednotlivé metody).
|
||||
bool get isDecoyMode => _decoyMode;
|
||||
|
||||
/// Identita ukazovaná v nátlakové relaci: náhodná, jen v paměti.
|
||||
LocalIdentity? _decoyIdentity;
|
||||
|
||||
void _requireRealSession() {
|
||||
if (_decoyMode) throw StateError('Not available in a duress session');
|
||||
}
|
||||
|
||||
SecureStorageService({String? dbPath})
|
||||
: _secureStorage = const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
// this_device: identita a klíče nesmí putovat v iCloud Keychain
|
||||
// ani v záloze na jiný telefon — obnova jde přes 24 slov.
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device),
|
||||
),
|
||||
_dbPathOverride = dbPath;
|
||||
|
||||
@@ -98,21 +127,59 @@ class SecureStorageService {
|
||||
if (wrapped == null) {
|
||||
throw StateError('No PIN set — use unlockWithoutPin');
|
||||
}
|
||||
final locked = await pinLockRemaining();
|
||||
if (locked != null) throw PinLockedException(locked);
|
||||
|
||||
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
|
||||
try {
|
||||
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: pin);
|
||||
await _openDb(dek);
|
||||
// Oba bloby jednou derivací (sdílená sůl) — čas odemčení neprozradí,
|
||||
// který PIN padl.
|
||||
final (index, dek) = await DekWrap.unwrapFirstMatching(
|
||||
wrapped: [wrapped, ?decoy],
|
||||
pin: pin,
|
||||
);
|
||||
await _openDb(dek, decoy: index == 1);
|
||||
await _resetPinFailures();
|
||||
} on WrongPinException {
|
||||
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
|
||||
if (decoy == null) rethrow;
|
||||
// Když ani decoy nesedí, unwrap vyhodí WrongPinException dál.
|
||||
final dek = await DekWrap.unwrap(wrapped: decoy, pin: pin);
|
||||
await _openDb(dek, decoy: true);
|
||||
await _notePinFailure();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Zbývající zámek po sérii špatných PINů; null = volno.
|
||||
Future<Duration?> pinLockRemaining() async {
|
||||
final raw = await _secureStorage.read(key: _pinLockUntilKey);
|
||||
final until = raw == null ? null : int.tryParse(raw);
|
||||
if (until == null) return null;
|
||||
final remaining = DateTime.fromMillisecondsSinceEpoch(until)
|
||||
.difference(DateTime.now());
|
||||
return remaining > Duration.zero ? remaining : null;
|
||||
}
|
||||
|
||||
Future<void> _notePinFailure() async {
|
||||
final raw = await _secureStorage.read(key: _pinFailCountKey);
|
||||
final failures = (raw == null ? 0 : int.tryParse(raw) ?? 0) + 1;
|
||||
await _secureStorage.write(key: _pinFailCountKey, value: '$failures');
|
||||
if (failures < _pinFreeAttempts) return;
|
||||
// 30 s po páté chybě, pak dvojnásobně až do 15 minut: šest číslic
|
||||
// (10⁶) tak trvá roky, Argon2id sám by brzdil jen ~2 s na pokus.
|
||||
var lock = _pinLockBase * (1 << (failures - _pinFreeAttempts));
|
||||
if (lock > _pinLockMax) lock = _pinLockMax;
|
||||
await _secureStorage.write(
|
||||
key: _pinLockUntilKey,
|
||||
value: '${DateTime.now().add(lock).millisecondsSinceEpoch}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _resetPinFailures() async {
|
||||
await _secureStorage.delete(key: _pinFailCountKey);
|
||||
await _secureStorage.delete(key: _pinLockUntilKey);
|
||||
}
|
||||
|
||||
/// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže.
|
||||
/// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře.
|
||||
Future<void> enablePin(String pin) async {
|
||||
_requireRealSession();
|
||||
final dek = _dek ?? await _getOrCreateRawDek();
|
||||
final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
|
||||
await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
|
||||
@@ -124,20 +191,34 @@ class SecureStorageService {
|
||||
|
||||
/// Vypne PIN: DEK se vrátí raw do OS keystore.
|
||||
Future<void> disablePin() async {
|
||||
_requireRealSession();
|
||||
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
||||
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
|
||||
await _secureStorage.delete(key: _wrappedDekKey);
|
||||
}
|
||||
|
||||
/// Ověří PIN, aniž by cokoli změnil (unwrap a zahození DEK).
|
||||
/// Stojí jednu Argon2id derivaci (~2 s) — to je zároveň rate limit.
|
||||
/// Ověří skutečný PIN, aniž by cokoli změnil (unwrap a zahození DEK).
|
||||
/// Tímhle se potvrzují citlivé akce v odemčené appce (fráze, záloha,
|
||||
/// smazání účtu, změna PINu).
|
||||
///
|
||||
/// Sdílí brzdu se zámkovou obrazovkou: kdo dostane do ruky odemčený
|
||||
/// telefon, nesmí tu hádat PIN rychleji než na zámku. Zamčeno =
|
||||
/// [PinLockedException].
|
||||
///
|
||||
/// V nátlakové relaci vždy false: i kdyby někdo znal skutečný PIN, k frázi
|
||||
/// skutečné identity se odtud dostat nesmí (otevře si appku normálně).
|
||||
Future<bool> verifyPin(String pin) async {
|
||||
if (_decoyMode) return false;
|
||||
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (wrapped == null) return false;
|
||||
final locked = await pinLockRemaining();
|
||||
if (locked != null) throw PinLockedException(locked);
|
||||
try {
|
||||
await DekWrap.unwrap(wrapped: wrapped, pin: pin);
|
||||
await _resetPinFailures();
|
||||
return true;
|
||||
} on WrongPinException {
|
||||
await _notePinFailure();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -151,6 +232,7 @@ class SecureStorageService {
|
||||
required String oldPin,
|
||||
required String newPin,
|
||||
}) async {
|
||||
_requireRealSession();
|
||||
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (wrapped == null) throw StateError('No PIN set');
|
||||
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin);
|
||||
@@ -165,7 +247,11 @@ class SecureStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
final rewrapped = await DekWrap.wrap(dek: dek, pin: newPin);
|
||||
// Sůl zůstává: nátlakový blob ji sdílí (jedna derivace pro oba PINy)
|
||||
// a bez jeho PINu by se přebalit nedal. Jedna sůl pro dva PINy téhož
|
||||
// uživatele nic neoslabuje — sůl brání předpočítání napříč uživateli.
|
||||
final rewrapped = await DekWrap.wrap(
|
||||
dek: dek, pin: newPin, salt: DekWrap.saltOf(wrapped));
|
||||
await _secureStorage.write(key: _wrappedDekKey, value: rewrapped);
|
||||
}
|
||||
|
||||
@@ -176,6 +262,7 @@ class SecureStorageService {
|
||||
/// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy
|
||||
/// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit.
|
||||
Future<void> enableDecoyPin(String decoyPin) async {
|
||||
_requireRealSession();
|
||||
final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (realWrapped == null) {
|
||||
throw StateError('Set a regular PIN first');
|
||||
@@ -193,7 +280,9 @@ class SecureStorageService {
|
||||
// Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB
|
||||
final decoyPath = await _dbPath(decoy: true);
|
||||
CryptoDb.open(path: decoyPath, dek: decoyDek).close();
|
||||
final wrapped = await DekWrap.wrap(dek: decoyDek, pin: decoyPin);
|
||||
// Stejná sůl jako skutečný blob → jedna Argon2id derivace pro oba PINy.
|
||||
final wrapped = await DekWrap.wrap(
|
||||
dek: decoyDek, pin: decoyPin, salt: DekWrap.saltOf(realWrapped));
|
||||
await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped);
|
||||
}
|
||||
|
||||
@@ -203,11 +292,20 @@ class SecureStorageService {
|
||||
|
||||
/// Zruší nátlakový PIN a smaže decoy DB.
|
||||
Future<void> disableDecoyPin() async {
|
||||
_requireRealSession();
|
||||
await _secureStorage.delete(key: _decoyWrappedDekKey);
|
||||
try {
|
||||
final f = File(await _dbPath(decoy: true));
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
} catch (_) {}
|
||||
await _deleteDbFiles(await _dbPath(decoy: true));
|
||||
}
|
||||
|
||||
Future<void> _deleteDbFiles(String path) async {
|
||||
for (final suffix in ['', '-wal', '-shm']) {
|
||||
try {
|
||||
final f = File('$path$suffix');
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
} catch (_) {
|
||||
// best-effort úklid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme
|
||||
@@ -222,6 +320,8 @@ class SecureStorageService {
|
||||
_db?.close();
|
||||
_db = null;
|
||||
_dek = null;
|
||||
_decoyMode = false;
|
||||
_decoyIdentity = null;
|
||||
}
|
||||
|
||||
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
|
||||
@@ -293,6 +393,11 @@ class SecureStorageService {
|
||||
|
||||
/// Načti lokální identitu
|
||||
Future<LocalIdentity?> loadIdentity() async {
|
||||
if (_decoyMode) {
|
||||
// Nátlaková relace ukazuje náhodnou identitu jen z paměti — skutečný
|
||||
// klíč z keystore se do ní nikdy nedostane (nátlakový PIN musí být izolovaný).
|
||||
return _decoyIdentity ??= await LocalIdentity.create('');
|
||||
}
|
||||
final json = await _secureStorage.read(key: _identityKey);
|
||||
if (json == null) return null;
|
||||
try {
|
||||
@@ -320,6 +425,7 @@ class SecureStorageService {
|
||||
}
|
||||
|
||||
Future<List<String>?> loadSeedPhrase() async {
|
||||
if (_decoyMode) return null;
|
||||
final json = await _secureStorage.read(key: _seedPhraseKey);
|
||||
if (json == null) return null;
|
||||
return (jsonDecode(json) as List).cast<String>();
|
||||
@@ -331,6 +437,7 @@ class SecureStorageService {
|
||||
/// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings).
|
||||
/// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním.
|
||||
Future<void> enableBiometrics() async {
|
||||
_requireRealSession();
|
||||
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
||||
await _secureStorage.write(
|
||||
key: _biometricDekKey,
|
||||
@@ -342,6 +449,7 @@ class SecureStorageService {
|
||||
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
|
||||
/// PIN-only model: DEK existuje jen za Argon2id).
|
||||
Future<void> disableBiometrics() async {
|
||||
_requireRealSession();
|
||||
await _secureStorage.delete(key: _biometricDekKey);
|
||||
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
|
||||
}
|
||||
@@ -475,24 +583,29 @@ class SecureStorageService {
|
||||
if (!isUnlocked) return false;
|
||||
var changed = false;
|
||||
final now = DateTime.now();
|
||||
for (final conv in await loadConversations()) {
|
||||
final expired = conv.messages
|
||||
.where((m) =>
|
||||
m.disappearAfterSeconds != null &&
|
||||
m.timestamp
|
||||
.add(Duration(seconds: m.disappearAfterSeconds!))
|
||||
.isBefore(now))
|
||||
.toList();
|
||||
bool isExpired(Message m) =>
|
||||
m.disappearAfterSeconds != null &&
|
||||
m.timestamp
|
||||
.add(Duration(seconds: m.disappearAfterSeconds!))
|
||||
.isBefore(now);
|
||||
for (final snapshot in await loadConversations()) {
|
||||
if (!snapshot.messages.any(isExpired)) continue;
|
||||
// Konverzace se před zápisem načte znovu: mezi snímkem a zápisem
|
||||
// běží event loop (mazání blobů, příjem zpráv) a starý snímek by
|
||||
// přepsal zprávu přijatou mezitím — už ACKnutou, tedy navždy pryč.
|
||||
final conv = await loadConversation(snapshot.contactId);
|
||||
if (conv == null) continue;
|
||||
final expired = conv.messages.where(isExpired).toList();
|
||||
if (expired.isEmpty) continue;
|
||||
changed = true;
|
||||
conv.messages.removeWhere(isExpired);
|
||||
await saveConversation(conv);
|
||||
for (final m in expired) {
|
||||
final blobId = m.attachment?.blobId;
|
||||
if (blobId != null) {
|
||||
await AttachmentStore().delete(blobId);
|
||||
}
|
||||
conv.messages.remove(m);
|
||||
}
|
||||
await saveConversation(conv);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
@@ -512,12 +625,19 @@ class SecureStorageService {
|
||||
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
|
||||
/// i lokální bloby příloh.
|
||||
Future<void> deleteAll() async {
|
||||
final path = await _dbPath();
|
||||
lock();
|
||||
for (final suffix in ['', '-wal', '-shm']) {
|
||||
final f = File('$path$suffix');
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
if (_decoyMode) {
|
||||
// Nátlaková relace: „smazat" smí jen prázdnou decoy DB — skutečná
|
||||
// data a keystore zůstávají (dřív to smazalo reálný účet).
|
||||
final decoyPath = await _dbPath(decoy: true);
|
||||
lock();
|
||||
await _deleteDbFiles(decoyPath);
|
||||
return;
|
||||
}
|
||||
final path = await _dbPath();
|
||||
final decoyPath = await _dbPath(decoy: true);
|
||||
lock();
|
||||
await _deleteDbFiles(path);
|
||||
await _deleteDbFiles(decoyPath);
|
||||
await AttachmentStore().deleteAll();
|
||||
await _secureStorage.deleteAll();
|
||||
}
|
||||
|
||||
@@ -71,7 +71,14 @@ class _ChangePinScreenState extends ConsumerState<ChangePinScreen> {
|
||||
switch (_krok) {
|
||||
case _Krok.stary:
|
||||
setState(() => _pracuje = true);
|
||||
final ok = await storage.verifyPin(_pin);
|
||||
final bool ok;
|
||||
try {
|
||||
ok = await storage.verifyPin(_pin);
|
||||
} on PinLockedException catch (e) {
|
||||
if (!mounted) return;
|
||||
_reset(context.l10n.pinLockedFor(e.remaining.inSeconds));
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (!ok) {
|
||||
_reset(context.l10n.pinWrong);
|
||||
|
||||
@@ -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,190 @@
|
||||
/// Čerstvé ověření před citlivou akcí v už odemčené appce.
|
||||
///
|
||||
/// Odemčený telefon v cizí ruce nesmí stačit na převzetí účtu: 24 slov,
|
||||
/// záloha, smazání účtu nebo oslabení zámku chtějí PIN (Argon2id unwrap,
|
||||
/// stejná brzda jako zámková obrazovka), případně skutečnou biometrii.
|
||||
/// Žádné okno „před chvílí jsem ověřil“ — každá akce se ptá znovu.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
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/l10n/l10n.dart';
|
||||
import 'package:sepot/core/storage/secure_storage.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:sepot/widgets/pin_keypad.dart';
|
||||
|
||||
/// Systémová biometrická výzva. Provider kvůli testům — skutečný
|
||||
/// `local_auth` ve widget testu neběží.
|
||||
final biometricPromptProvider = Provider<Future<bool> Function(String reason)>((
|
||||
ref,
|
||||
) {
|
||||
return (reason) => LocalAuthentication().authenticate(
|
||||
localizedReason: reason,
|
||||
// biometricOnly: zámek telefonu (PIN/vzor zařízení) by obešel
|
||||
// Argon2id ochranu slabším faktorem, který zná i „půjčovatel“.
|
||||
options: const AuthenticationOptions(stickyAuth: true, biometricOnly: true),
|
||||
);
|
||||
});
|
||||
|
||||
/// Vrátí true, jen když uživatel právě teď prokázal, že zná PIN, nebo
|
||||
/// prošel biometrií. [reason] se ukáže ve výzvě.
|
||||
///
|
||||
/// [allowBiometrics] = false u akcí, které biometrii samy zakládají:
|
||||
/// zapnutí biometrie se nesmí potvrdit tím, co se teprve zapíná.
|
||||
Future<bool> confirmSensitiveAction(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required String reason,
|
||||
bool allowBiometrics = true,
|
||||
}) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
if (storage.isDecoyMode) return false;
|
||||
|
||||
if (!await storage.hasPin()) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.reauthPinRequired),
|
||||
action: SnackBarAction(
|
||||
label: context.l10n.reauthSetPin,
|
||||
onPressed: () => context.push('/pin-setup'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (allowBiometrics && await storage.isBiometricsEnabled()) {
|
||||
try {
|
||||
if (await ref.read(biometricPromptProvider)(reason)) return true;
|
||||
} catch (_) {
|
||||
// Biometrie nedostupná nebo zrušená — PIN je vždy cesta.
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.mounted) return false;
|
||||
final ok = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
backgroundColor: SepotColors.background,
|
||||
builder: (_) => _ReauthPinSheet(reason: reason),
|
||||
);
|
||||
return ok ?? false;
|
||||
}
|
||||
|
||||
class _ReauthPinSheet extends ConsumerStatefulWidget {
|
||||
const _ReauthPinSheet({required this.reason});
|
||||
|
||||
final String reason;
|
||||
|
||||
@override
|
||||
ConsumerState<_ReauthPinSheet> createState() => _ReauthPinSheetState();
|
||||
}
|
||||
|
||||
class _ReauthPinSheetState extends ConsumerState<_ReauthPinSheet> {
|
||||
String _pin = '';
|
||||
String? _error;
|
||||
bool _busy = false;
|
||||
|
||||
void _onKey(String key) {
|
||||
if (_busy) return;
|
||||
setState(() {
|
||||
_error = null;
|
||||
if (key == 'DEL') {
|
||||
if (_pin.isNotEmpty) _pin = _pin.substring(0, _pin.length - 1);
|
||||
} else if (_pin.length < 6) {
|
||||
_pin += key;
|
||||
}
|
||||
});
|
||||
if (_pin.length == 6) _verify();
|
||||
}
|
||||
|
||||
Future<void> _verify() async {
|
||||
setState(() => _busy = true);
|
||||
final l10n = context.l10n;
|
||||
final storage = ref.read(storageProvider);
|
||||
String error;
|
||||
try {
|
||||
if (await storage.verifyPin(_pin)) {
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
return;
|
||||
}
|
||||
final locked = await storage.pinLockRemaining();
|
||||
error = locked == null
|
||||
? l10n.pinWrong
|
||||
: l10n.pinLockedFor(locked.inSeconds);
|
||||
} on PinLockedException catch (e) {
|
||||
error = l10n.pinLockedFor(e.remaining.inSeconds);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = error;
|
||||
_pin = '';
|
||||
_busy = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(32, 24, 32, 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.reauthTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.reason,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
PinDots(filled: _pin.length),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: SepotColors.error, fontSize: 14),
|
||||
),
|
||||
],
|
||||
if (_busy) ...[
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
PinKeypad(onKey: _onKey, enabled: !_busy),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : () => Navigator.of(context).pop(false),
|
||||
child: Text(l10n.commonCancel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -89,9 +89,13 @@ class _CallScreenState extends ConsumerState<CallScreen> {
|
||||
RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 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(
|
||||
@@ -136,6 +140,7 @@ class _CallScreenState extends ConsumerState<CallScreen> {
|
||||
_InCallControls(call: call, controller: controller),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showVideo && !call.cameraOff)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/contact_name.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
@@ -45,13 +46,15 @@ class ArchivedChatsScreen extends ConsumerWidget {
|
||||
?.where((c) => c.publicIdentity.id == conv.contactId)
|
||||
.firstOrNull;
|
||||
final name = group?.name ??
|
||||
contact?.displayName ??
|
||||
context.l10n.commonUnknownContact;
|
||||
(contact == null
|
||||
? context.l10n.commonUnknownContact
|
||||
: contactName(context.l10n, contact));
|
||||
return ChatListTile(
|
||||
conversation: conv,
|
||||
contactName: name,
|
||||
myId: myId,
|
||||
isGroup: group != null,
|
||||
isRequest: group == null && contact?.accepted == false,
|
||||
onTap: () => context.push('/conversation/${conv.contactId}'),
|
||||
onLongPress: () => _actions(context, ref, conv),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/contact_name.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
@@ -206,7 +207,7 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
|
||||
String nameOf(Conversation c) => ConversationsNotifier.isNotes(c.contactId)
|
||||
? l10n.notesTitle
|
||||
: groupOf(c.contactId)?.name ??
|
||||
contactOf(c.contactId)?.displayName ??
|
||||
_nameOrNull(l10n, contactOf(c.contactId)) ??
|
||||
l10n.commonUnknownContact;
|
||||
|
||||
return SafeArea(
|
||||
@@ -265,7 +266,8 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
|
||||
? l10n.chatTyping
|
||||
: writers.length == 1
|
||||
? l10n.chatTypingNamed(
|
||||
(contactOf(writers.first)?.displayName ?? '?')
|
||||
(_nameOrNull(l10n, contactOf(writers.first)) ??
|
||||
'?')
|
||||
.split(' ')
|
||||
.first)
|
||||
: l10n.chatTypingSeveral;
|
||||
@@ -275,8 +277,10 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
|
||||
myId: myId,
|
||||
isGroup: group != null,
|
||||
verified: contactOf(c.contactId)?.verified ?? false,
|
||||
isRequest: group == null &&
|
||||
contactOf(c.contactId)?.accepted == false,
|
||||
lastAuthor: group != null && last != null
|
||||
? contactOf(last.senderId)?.displayName
|
||||
? _nameOrNull(l10n, contactOf(last.senderId))
|
||||
: null,
|
||||
highlight: hits[c],
|
||||
typingLabel: typingLabel,
|
||||
@@ -685,3 +689,6 @@ class _EmptyState extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String? _nameOrNull(AppLocalizations l10n, Contact? c) =>
|
||||
c == null ? null : contactName(l10n, c);
|
||||
|
||||
@@ -18,6 +18,10 @@ class ChatListTile extends StatelessWidget {
|
||||
final bool isGroup;
|
||||
final bool verified;
|
||||
|
||||
/// Žádost o zprávu (neznámý odesílatel): náhled místo textu hlásí, o co
|
||||
/// jde — první věta útočníka nemá dostat prostor v seznamu chatů.
|
||||
final bool isRequest;
|
||||
|
||||
/// Jméno autora poslední zprávy ve skupině (null = 1:1 nebo neznámý).
|
||||
final String? lastAuthor;
|
||||
|
||||
@@ -40,6 +44,7 @@ class ChatListTile extends StatelessWidget {
|
||||
required this.myId,
|
||||
this.isGroup = false,
|
||||
this.verified = false,
|
||||
this.isRequest = false,
|
||||
this.lastAuthor,
|
||||
this.highlight,
|
||||
this.typingLabel,
|
||||
@@ -54,7 +59,9 @@ class ChatListTile extends StatelessWidget {
|
||||
final unread = conversation.unreadCount(myId);
|
||||
final muted = conversation.isMuted;
|
||||
final mine = lastMsg != null && lastMsg.isMine(myId);
|
||||
final preview = previewOf(context.l10n, lastMsg);
|
||||
final preview = isRequest
|
||||
? (icon: Icons.person_search_rounded, text: context.l10n.requestTitle)
|
||||
: previewOf(context.l10n, lastMsg);
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
@@ -245,6 +252,9 @@ class ChatListTile extends StatelessWidget {
|
||||
final label = callLogLabel(l10n, call);
|
||||
return (icon: label.icon, text: label.text);
|
||||
}
|
||||
if (msg.notice != null) {
|
||||
return (icon: Icons.gpp_maybe_rounded, text: l10n.noticePreview);
|
||||
}
|
||||
return switch (msg.type) {
|
||||
MessageType.text => (icon: null, text: msg.content ?? ''),
|
||||
MessageType.image => (
|
||||
|
||||
@@ -95,7 +95,9 @@ class _ContactsScreenState extends ConsumerState<ContactsScreen> {
|
||||
Expanded(
|
||||
child: contactsAsync.when(
|
||||
data: (contacts) {
|
||||
// Žádosti o zprávu kontakty nejsou — žijí jen v seznamu chatů.
|
||||
final filtered = contacts.where((c) {
|
||||
if (!c.accepted) return false;
|
||||
if (_searchQuery.isEmpty) return true;
|
||||
return c.displayName.toLowerCase().contains(_searchQuery) ||
|
||||
c.sepotId.toLowerCase().contains(_searchQuery);
|
||||
|
||||
@@ -5,6 +5,7 @@ library;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/contact_name.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
@@ -17,9 +18,7 @@ import 'package:sepot/providers/groups_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
String contactLabel(BuildContext context, Contact contact) =>
|
||||
contact.displayName.isEmpty
|
||||
? context.l10n.commonUnknownContact
|
||||
: contact.displayName;
|
||||
contactName(context.l10n, contact);
|
||||
|
||||
void _snack(BuildContext context, String text) {
|
||||
if (!context.mounted) return;
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sepot/core/l10n/contact_name.dart';
|
||||
import 'package:sepot/core/calls/call_controller.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
@@ -21,6 +22,8 @@ import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/core/utils/text_fold.dart';
|
||||
import 'package:sepot/features/calls/call_log_text.dart';
|
||||
import 'package:sepot/features/conversation/chat_actions.dart';
|
||||
import 'package:sepot/features/conversation/message_request_panel.dart';
|
||||
import 'package:sepot/features/conversation/widgets/safety_notice_row.dart';
|
||||
import 'package:sepot/features/conversation/forward_sheet.dart';
|
||||
import 'package:sepot/features/conversation/widgets/attachment_content.dart';
|
||||
import 'package:sepot/features/conversation/widgets/create_poll_sheet.dart';
|
||||
@@ -64,7 +67,8 @@ class ConversationScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<ConversationScreen> createState() => _ConversationScreenState();
|
||||
}
|
||||
|
||||
class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
class _ConversationScreenState extends ConsumerState<ConversationScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final _messageController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
final _searchController = TextEditingController();
|
||||
@@ -133,9 +137,43 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
_drafts = ref.read(draftsProvider.notifier);
|
||||
_restoreDraft();
|
||||
_messageController.addListener(_scheduleDraftSave);
|
||||
_conversations = ref.read(conversationsProvider.notifier);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _captureUnreadAndMark());
|
||||
}
|
||||
|
||||
/// Notifier zachycený v initState: v dispose už `ref` použít nejde.
|
||||
late final ConversationsNotifier _conversations;
|
||||
|
||||
/// Zpráva, která přijde do otevřeného chatu, je přečtená, jen když ji
|
||||
/// uživatel opravdu vidí: appka v popředí a tahle obrazovka navrchu
|
||||
/// (ne pod profilem kontaktu nebo náhledem fotky).
|
||||
bool get _isSeenNow =>
|
||||
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed &&
|
||||
(_route?.isCurrent ?? false);
|
||||
|
||||
// Route uložená v didChangeDependencies: lifecycle callback může přijít
|
||||
// i ve chvíli, kdy je element už deaktivovaný a hledat předka nesmí.
|
||||
ModalRoute<Object?>? _route;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_route = ModalRoute.of(context);
|
||||
}
|
||||
|
||||
void _markIfSeen() {
|
||||
if (!mounted || !_isSeenNow) return;
|
||||
_conversations.markAsRead(widget.contactId);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// Zprávy, které přišly se zhasnutým displejem nad otevřeným chatem,
|
||||
// uživatel uvidí až po návratu do appky.
|
||||
if (state == AppLifecycleState.resumed) _markIfSeen();
|
||||
}
|
||||
|
||||
/// Zapamatuje si první nepřečtenou zprávu a teprve pak konverzaci označí
|
||||
/// za přečtenou — jinak by řádek „nové zprávy“ nikdy nebylo kam dát.
|
||||
Future<void> _captureUnreadAndMark() async {
|
||||
@@ -185,6 +223,10 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
// Odchod z chatu: všechno, co bylo na obrazovce, je přečtené – i zprávy
|
||||
// dorazivší těsně před odchodem, které listener nestihl označit.
|
||||
_conversations.markAsRead(widget.contactId);
|
||||
_draftTimer?.cancel();
|
||||
// Odchod z chatu uprostřed úpravy = koncept je ten odložený, ne text
|
||||
// upravované zprávy.
|
||||
@@ -214,6 +256,17 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
.where((c) => c.publicIdentity.id == widget.contactId)
|
||||
.firstOrNull;
|
||||
final conversationAsync = ref.watch(conversationProvider(widget.contactId));
|
||||
// Nová příchozí zpráva v otevřeném chatu se rovnou čte; jinak by po
|
||||
// návratu do seznamu svítila jako nepřečtená.
|
||||
// První načtení vynechá: to obslouží _captureUnreadAndMark, které si
|
||||
// nejdřív zapamatuje řádek „N nových zpráv“.
|
||||
ref.listen(conversationProvider(widget.contactId), (prev, next) {
|
||||
if (prev?.valueOrNull == null) return;
|
||||
final conv = next.valueOrNull;
|
||||
final myId = ref.read(identityProvider).valueOrNull?.id;
|
||||
if (conv == null || myId == null || conv.unreadCount(myId) == 0) return;
|
||||
_markIfSeen();
|
||||
});
|
||||
final identity = ref.watch(identityProvider).valueOrNull;
|
||||
// Skupinová konverzace: contactId == gid
|
||||
final group = ref.watch(groupProvider(widget.contactId)).valueOrNull;
|
||||
@@ -222,14 +275,13 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
// v kontaktech (roster je zakládá až s první zprávou), zkrátíme na id.
|
||||
String autorJmeno(String fingerprint) {
|
||||
if (fingerprint == identity?.id) return context.l10n.groupsYou;
|
||||
final jmeno = contacts
|
||||
final autor = contacts
|
||||
.where((c) => c.publicIdentity.id == fingerprint)
|
||||
.firstOrNull
|
||||
?.displayName;
|
||||
if (jmeno == null) {
|
||||
.firstOrNull;
|
||||
if (autor == null) {
|
||||
return 'SEP-${fingerprint.substring(0, fingerprint.length.clamp(0, 10))}';
|
||||
}
|
||||
return jmeno.isEmpty ? context.l10n.commonUnknownContact : jmeno;
|
||||
return contactName(context.l10n, autor);
|
||||
}
|
||||
|
||||
final Widget bottom;
|
||||
@@ -243,6 +295,8 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
child: Text(context.l10n.chatUnblock),
|
||||
),
|
||||
);
|
||||
} else if (group == null && contact != null && !contact.accepted) {
|
||||
bottom = MessageRequestPanel(contact: contact);
|
||||
} else {
|
||||
bottom = _buildComposer();
|
||||
}
|
||||
@@ -422,9 +476,9 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
}
|
||||
|
||||
Widget _buildContactHeader(Contact? contact, {bool typing = false}) {
|
||||
final name = (contact == null || contact.displayName.isEmpty)
|
||||
final name = contact == null
|
||||
? context.l10n.commonUnknownContact
|
||||
: contact.displayName;
|
||||
: contactName(context.l10n, contact);
|
||||
final verified = contact?.verified ?? false;
|
||||
return _buildHeaderFrame(children: [
|
||||
_backButton(),
|
||||
@@ -435,19 +489,27 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
// Stav ověření místo syrového ID (UX-FLOWS §3.1)
|
||||
subtitle: typing
|
||||
? _typingLabel(context.l10n.chatTyping)
|
||||
: Text(
|
||||
verified
|
||||
? context.l10n.verifyVerified
|
||||
: context.l10n.verifyUnverified,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color:
|
||||
verified ? SepotColors.sage : SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
: contact != null && !contact.accepted
|
||||
? Text(
|
||||
context.l10n.requestTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: SepotColors.warning),
|
||||
)
|
||||
: Text(
|
||||
verified
|
||||
? context.l10n.verifyVerified
|
||||
: context.l10n.verifyUnverified,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: verified
|
||||
? SepotColors.sage
|
||||
: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
onTap: () => _openProfile('/contact/${widget.contactId}'),
|
||||
),
|
||||
if (contact != null && !contact.blocked) ...[
|
||||
// Žádosti o zprávu se nevolá — hovor by prozradil, že jsem u telefonu.
|
||||
if (contact != null && !contact.blocked && contact.accepted) ...[
|
||||
IconButton(
|
||||
tooltip: context.l10n.callVoice,
|
||||
icon: const Icon(Icons.call_outlined,
|
||||
@@ -773,6 +835,27 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
? null
|
||||
: messages.where((m) => m.id == msg.replyToId).firstOrNull;
|
||||
|
||||
final notice = msg.notice;
|
||||
if (notice != null) {
|
||||
return KeyedSubtree(
|
||||
key: _keys.putIfAbsent(msg.id, GlobalKey.new),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (showDate) _buildDateDivider(msg.timestamp),
|
||||
SafetyNoticeRow(
|
||||
notice: notice,
|
||||
contactName: autorJmeno(widget.contactId),
|
||||
claimedName: msg.content,
|
||||
onVerify: group == null
|
||||
? () => context.push('/verify/${widget.contactId}')
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final callLog = msg.callLog;
|
||||
if (callLog != null) {
|
||||
return KeyedSubtree(
|
||||
@@ -1695,9 +1778,7 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
String jmeno(String f) {
|
||||
final c = contacts.where((c) => c.id == f).firstOrNull;
|
||||
if (c == null) return 'SEP-${f.substring(0, f.length.clamp(0, 10))}';
|
||||
return c.displayName.isEmpty
|
||||
? context.l10n.commonUnknownContact
|
||||
: c.displayName;
|
||||
return contactName(context.l10n, c);
|
||||
}
|
||||
|
||||
Widget radek(IconData icon, String label, DateTime? t, {Color? color}) {
|
||||
@@ -2150,7 +2231,11 @@ class _SwipeToReplyState extends State<_SwipeToReply>
|
||||
static const _max = 84.0;
|
||||
static const _trigger = 58.0;
|
||||
|
||||
late final AnimationController _back = AnimationController(
|
||||
// Kontroler vzniká až při prvním tahu. Dřív ho `late final` vytvořil
|
||||
// teprve dispose() u bublin, které nikdo netáhl – nad deaktivovaným
|
||||
// elementem, což v debug buildu padá na hledání TickerMode.
|
||||
AnimationController? _backCtl;
|
||||
AnimationController get _back => _backCtl ??= AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 180))
|
||||
..addListener(() => setState(() => _dx = _from * (1 - _back.value)));
|
||||
double _dx = 0;
|
||||
@@ -2159,7 +2244,7 @@ class _SwipeToReplyState extends State<_SwipeToReply>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_back.dispose();
|
||||
_backCtl?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/l10n/contact_name.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
@@ -37,8 +38,8 @@ Future<void> forwardMessage(
|
||||
for (final g in groups)
|
||||
if (!g.left && g.id != fromId) _Target(g.id, g.name, isGroup: true),
|
||||
for (final c in contacts)
|
||||
if (!c.blocked && c.id != fromId)
|
||||
_Target(c.id, c.displayName, verified: c.verified),
|
||||
if (!c.blocked && c.accepted && c.id != fromId)
|
||||
_Target(c.id, contactName(context.l10n, c), verified: c.verified),
|
||||
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
// Poznámky nahoře: přeposlat si něco „na později“ je nejčastější případ.
|
||||
if (!ConversationsNotifier.isNotes(fromId)) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sepot/core/l10n/contact_name.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/attachment.dart';
|
||||
@@ -103,9 +104,9 @@ class _ImageViewerScreenState extends ConsumerState<ImageViewerScreen> {
|
||||
final contact = (ref.read(contactsProvider).valueOrNull ?? const [])
|
||||
.where((c) => c.publicIdentity.id == m.senderId)
|
||||
.firstOrNull;
|
||||
return contact?.displayName.isNotEmpty == true
|
||||
? contact!.displayName
|
||||
: context.l10n.commonUnknownContact;
|
||||
return contact == null
|
||||
? context.l10n.commonUnknownContact
|
||||
: contactName(context.l10n, contact);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/// Žádost o zprávu: místo psacího pole u odesílatele, kterého jsem si
|
||||
/// nepřidal. Jméno i fotku si zvolil sám, takže dokud ho nepřijmu, nesmí
|
||||
/// vypadat jako můj kontakt a nesmí ode mě dostat odpověď omylem.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/safety/lookalike_names.dart';
|
||||
import 'package:sepot/core/services/messaging_service.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/features/conversation/chat_actions.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
|
||||
class MessageRequestPanel extends ConsumerWidget {
|
||||
const MessageRequestPanel({super.key, required this.contact});
|
||||
|
||||
final Contact contact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = context.l10n;
|
||||
final contacts = ref.watch(contactsProvider).valueOrNull ?? const [];
|
||||
final claimed = contact.displayName.isEmpty
|
||||
? l10n.commonUnknownContact
|
||||
: contact.displayName;
|
||||
final lookalike =
|
||||
lookalikeContact(contact.displayName, contacts, exceptId: contact.id);
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
minimum: const EdgeInsets.fromLTRB(10, 6, 10, 10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: lookalike != null ? SepotColors.error : SepotColors.warning,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
lookalike != null
|
||||
? Icons.gpp_maybe_rounded
|
||||
: Icons.person_search_rounded,
|
||||
size: 20,
|
||||
color: lookalike != null
|
||||
? SepotColors.error
|
||||
: SepotColors.warning,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.requestTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.requestBody(claimed),
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: SepotColors.textSecondary),
|
||||
),
|
||||
if (lookalike != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.requestLookalikeWarning(lookalike.displayName),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.error),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
setContactBlocked(context, ref, contact, true),
|
||||
child: Text(l10n.chatMenuBlockContact,
|
||||
style: const TextStyle(color: SepotColors.error)),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
FilledButton(
|
||||
onPressed: () => acceptMessageRequest(context, ref, contact),
|
||||
child: Text(l10n.requestAccept),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Přijetí = uživatel kontakt pojmenuje sám. Jméno, které se plete
|
||||
/// s jiným přijatým kontaktem, neprojde: dvě „Mámy“ v seznamu by
|
||||
/// podvrh jen schovaly.
|
||||
Future<void> acceptMessageRequest(
|
||||
BuildContext context, WidgetRef ref, Contact contact) async {
|
||||
final contacts = ref.read(contactsProvider).valueOrNull ?? const [];
|
||||
final controller = TextEditingController(
|
||||
text: lookalikeContact(contact.displayName, contacts,
|
||||
exceptId: contact.id) ==
|
||||
null
|
||||
? contact.displayName
|
||||
: '',
|
||||
);
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => _AcceptDialog(
|
||||
controller: controller,
|
||||
isTaken: (n) =>
|
||||
lookalikeContact(n, contacts, exceptId: contact.id) != null,
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
if (name == null) return;
|
||||
await ref.read(contactsProvider.notifier).accept(contact.id, name: name);
|
||||
}
|
||||
|
||||
class _AcceptDialog extends StatefulWidget {
|
||||
const _AcceptDialog({required this.controller, required this.isTaken});
|
||||
|
||||
final TextEditingController controller;
|
||||
final bool Function(String name) isTaken;
|
||||
|
||||
@override
|
||||
State<_AcceptDialog> createState() => _AcceptDialogState();
|
||||
}
|
||||
|
||||
class _AcceptDialogState extends State<_AcceptDialog> {
|
||||
String? _error;
|
||||
|
||||
void _submit() {
|
||||
final l10n = context.l10n;
|
||||
final name = widget.controller.text.trim();
|
||||
if (name.isEmpty) {
|
||||
setState(() => _error = l10n.requestNameEmpty);
|
||||
return;
|
||||
}
|
||||
if (widget.isTaken(name)) {
|
||||
setState(() => _error = l10n.requestNameTaken);
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(name);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return AlertDialog(
|
||||
title: Text(l10n.requestAcceptTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.requestAcceptBody, style: const TextStyle(fontSize: 13)),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: widget.controller,
|
||||
autofocus: true,
|
||||
maxLength: MessagingService.maxDisplayNameLength,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.requestAcceptNameLabel,
|
||||
errorText: _error,
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.commonCancel),
|
||||
),
|
||||
FilledButton(onPressed: _submit, child: Text(l10n.requestAccept)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/// Bezpečnostní upozornění uprostřed konverzace. Vypadá jinak než bublina
|
||||
/// (nikdo ho neposlal), aby nešlo zaměnit se zprávou protistrany.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
|
||||
class SafetyNoticeRow extends StatelessWidget {
|
||||
const SafetyNoticeRow({
|
||||
super.key,
|
||||
required this.notice,
|
||||
required this.contactName,
|
||||
this.claimedName,
|
||||
this.onVerify,
|
||||
});
|
||||
|
||||
final MessageNotice notice;
|
||||
final String contactName;
|
||||
|
||||
/// Jméno, které si zvolil neznámý odesílatel ([MessageNotice.lookalikeRequest]).
|
||||
final String? claimedName;
|
||||
|
||||
/// Odkaz na porovnání bezpečnostního kódu (null = bez tlačítka).
|
||||
final VoidCallback? onVerify;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final text = switch (notice) {
|
||||
MessageNotice.identityChanged => l10n.noticeIdentityChanged(contactName),
|
||||
MessageNotice.lookalikeRequest =>
|
||||
l10n.noticeLookalikeRequest(claimedName ?? '', contactName),
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 6),
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: SepotColors.warning),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.gpp_maybe_rounded,
|
||||
size: 18, color: SepotColors.warning),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: SepotColors.textPrimary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (onVerify != null &&
|
||||
notice == MessageNotice.identityChanged)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: onVerify,
|
||||
child: Text(l10n.noticeVerifyAction),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,9 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
||||
: null;
|
||||
final contactsAsync = ref.watch(contactsProvider);
|
||||
final contacts = (contactsAsync.valueOrNull ?? [])
|
||||
.where((c) => !c.blocked)
|
||||
// Do skupiny jen lidi, které jsem přijal — žádost o zprávu by
|
||||
// jinak dostala roster všech členů pod jménem, které si vymyslela.
|
||||
.where((c) => !c.blocked && c.accepted)
|
||||
.where((c) => existing == null || !existing.memberIds.contains(c.id))
|
||||
.toList();
|
||||
// Já jsem v memberIds existující skupiny; nová skupina mě přidá sama.
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:local_auth/local_auth.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/features/auth/reauth.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
class BiometricsTile extends ConsumerStatefulWidget {
|
||||
@@ -48,6 +49,14 @@ class _BiometricsTileState extends ConsumerState<BiometricsTile> {
|
||||
_snack(l10n.biometricsStorageLocked);
|
||||
return;
|
||||
}
|
||||
// Biometrie dostane kopii DEK — zapnout ji smí jen ten, kdo zná PIN.
|
||||
// Otisk sám nestačí: v telefonu může být zapsaný i cizí prst.
|
||||
if (!mounted ||
|
||||
!await confirmSensitiveAction(context, ref,
|
||||
reason: l10n.reauthReasonBiometrics,
|
||||
allowBiometrics: false)) {
|
||||
return;
|
||||
}
|
||||
final auth = LocalAuthentication();
|
||||
if (!await auth.isDeviceSupported()) {
|
||||
_snack(l10n.biometricsNotAvailable);
|
||||
@@ -55,7 +64,8 @@ class _BiometricsTileState extends ConsumerState<BiometricsTile> {
|
||||
}
|
||||
final ok = await auth.authenticate(
|
||||
localizedReason: l10n.biometricsEnrollPromptReason,
|
||||
options: const AuthenticationOptions(stickyAuth: true),
|
||||
options: const AuthenticationOptions(
|
||||
stickyAuth: true, biometricOnly: true),
|
||||
);
|
||||
if (!ok) return;
|
||||
await storage.enableBiometrics();
|
||||
|
||||