20 Commits

Author SHA1 Message Date
Julius Joska 830d6290ca release: 1.2.3 (26) 2026-10-23 10:53:46 +02:00
Julius Joska cbb09afbb1 fix(ui): skip the "(unknown)" suffix when building avatar initials
A message request named "Mom (unknown)" showed the initials "M(".
2026-10-23 10:51:02 +02:00
Julius Joska 59992af778 fix(push): register APNs and VoIP tokens on iOS
The local notifications plugin was initialised without iOS settings, so
flutter_local_notifications threw "iOS settings must be set" before the
app registered with Apple, and restoreMode swallowed the error. iPhones
never sent the relay an APNs token for message wake-ups or a VoIP token
for calls.

- add DarwinInitializationSettings and request iOS permission explicitly
- register with Apple before asking for notification permission (silent
  wake-ups do not need it)
- send the VoIP token on its own (PLATFORM_APNS with an empty endpoint)
  when no APNs token is available yet
- record a failed registration so the chat list shows it instead of
  failing silently
- drop the force cast of rootViewController in AppDelegate and the unused
  SceneDelegate
2026-10-23 10:51:02 +02:00
Julius Joska 717aeed4ff fix(security): treat a new identity key as a message request, never as a known contact
A stranger could pick the display name of an existing contact ("Mom")
and appear as that contact. Messages from a key that is not in the
contact list now open a message request labelled "(unknown)": no
replies, read receipts, typing indicators, calls or name in
notifications until the user accepts it and names the contact
themselves. Names that look like an existing contact (case, diacritics,
Cyrillic/Greek homoglyphs, invisible characters) also show a warning in
the real contact's chat.

When the relay account of a known contact shows up with a different
key, the chat shows "safety number changed", verification is cleared
and the session that now belongs to the other key is dropped before
sending, so a reply never goes to the new key.
2026-10-23 10:50:42 +02:00
Julius Joska 2f6b542f24 fix(security): require a fresh PIN or biometric check for sensitive actions
An unlocked phone was enough to read the 24-word recovery phrase. The
phrase, backup export and import, the duress PIN, a longer auto-lock,
turning off screen security, enabling biometrics and deleting the
account now ask for the PIN (or biometrics with biometricOnly) every
time. The duress session never shows the phrase. PIN checks outside the
lock screen count towards the same failure throttle.
2026-10-23 10:50:16 +02:00
Julius Joska 5b237c82f2 docs: describe relay metadata and audit status precisely
State what the relay sees at runtime (authenticated submission reveals
which device hands an envelope to which address) instead of claiming it
never learns the sender, note where data is stored, drop the
"Signal-grade" label and add a non-affiliation notice, and document
that only internal audits and penetration tests have been done so far.
2026-10-23 09:17:44 +02:00
Julius Joska d2eb0d4f8c docs: describe how to verify the relay revision, APK and native libraries
Also state plainly what is not reproducible today and what we plan to do
about it.
2026-10-23 09:15:15 +02:00
Julius Joska ae127decd9 build: pin the native libsignal library by SHA-256
The libsignal Dart package downloads a prebuilt libsignal_frb from the
GitHub releases of djx-y-z/libsignal_dart and only checks it against a
checksum file from the same release. Keep our own pins in
app/tool/native_libs.sha256 and verify the finished AAB/APK with
app/tool/verify_native_libs.sh; any mismatch fails the release build.

The pins cover the upstream archives and the library as packaged after
the Android Gradle plugin strips it. `--from-upstream` re-derives the
packaged hashes from the upstream archives with llvm-strip.
2026-10-23 09:15:15 +02:00
Julius Joska ab0e35ddf4 docs(readme): update release status for Android and iOS 2026-10-15 09:32:53 +02:00
Julius Joska d431501b74 docs(changelog): note the iOS push settings fix in 1.2.2 2026-10-11 16:12:46 +02:00
Julius Joska 6f9b08e844 fix(push): do not call UnifiedPush outside Android
UnifiedPush only exists on Android. On iOS getDistributor() and
getDistributors() throw UnimplementedError, which surfaced as an uncaught
error from the Push tile in Settings. Report an empty state instead.
2026-10-11 16:12:41 +02:00
Julius Joska 09ef54ef69 release: 1.2.2 (25)
New app icon: the Š that fades into dots. Launcher icons, adaptive icon
foreground and splash regenerated from assets/icon.
2026-10-11 15:31:53 +02:00
Julius Joska bfea7201f7 fix(chat): clear unread state for messages read in an open chat
Messages arriving while a conversation was open were never marked as read,
so the chat list kept showing them as unread after going back. The screen
now marks incoming messages read while it is on top and the app is in the
foreground, again when the app returns to the foreground, and on leaving.

Read state is now monotonic in Conversation.updateMessage, so a stale copy
of a message (attachment download, delayed resend) can no longer flip it
back to unread.

Also create the swipe-to-reply animation controller lazily instead of in
dispose().
2026-10-11 15:31:53 +02:00
Julius Joska 05eeccd83b test: tag suites that need the native libsignal library 2026-10-09 15:15:34 +02:00
Julius Joska d13b88aebe release: 1.2.1 (24)
Incoming calls, recovery from the 24-word phrase on a new device, duress
session isolation and three message-loss paths; see CHANGELOG.md.
2026-10-09 15:07:38 +02:00
Julius Joska b824f62878 deploy(relay): upgrade base packages in the runtime image
The bookworm-slim base ships fixable CVEs (perl-base and friends); apply
Debian's security updates at build time.
2026-10-09 14:06:42 +02:00
Julius Joska 00739ac8a0 docs: security policy, accurate push and status notes, unreleased changelog
- SECURITY.md with the reporting address and expectations; README points to
  security@sepot.cz and security.txt
- README: FCM is the Android default since 1.2.0 when no UnifiedPush
  distributor is installed; status reflects the Play release
- the "F-Droid" channel is not available yet and is no longer listed
- CHANGELOG: unreleased fixes and security hardening
2026-10-09 14:05:39 +02:00
Julius Joska 94cfd72690 chore(relay): merge the duplicated test module in apns.rs
cargo test on the published tree failed with E0428 since 1.2.0.
2026-10-09 14:05:39 +02:00
Julius Joska e3a2378c34 fix(app): accept incoming calls, restore from recovery phrase, duress session isolation, message loss
Calls: `call` was missing from the known content types, so every offer was
acknowledged and dropped; the call screen column now fills the width (the
avatar jumped left after hang-up); a ring started from a push ends after
12 s when no offer arrives; STUN fallback when no TURN credentials.
Registration: X-Sepot-Identity-Proof lets a new device reclaim an existing
identity; retry backs off from 30 s to 10 min and exposes a conflict flag.
Prekeys: PrekeysLow triggers a batch of 50 one-time prekeys via POST
/v1/keys; the signed prekey rotates weekly, the Kyber prekey monthly.
Messaging: crypto operations run serially (concurrent encrypts derived the
same message key); stop() waits for in-flight deliveries (auto-lock between
decrypt and persist lost messages); disappearing-message cleanup no longer
overwrites fresh messages; a push wake-up reconnects stale connections; the
outbox carries polls and mentions; length limits on wire input.
Storage: a duress session never exposes the real identity or phrase, does
not start networking and cannot touch the keystore; deleting data in a
duress session leaves the real account alone; PIN guessing is throttled
(5 misses, then 30 s doubling up to 15 min); the duress blob shares the salt
so unlock time does not reveal which PIN was entered; iOS keychain items are
this-device only; biometrics are biometric-only; screen security defaults
to on and is applied after unlock; tapjacking filter in MainActivity.
Tests: content types, shared-salt unwrap, duress isolation.
2026-10-09 14:02:33 +02:00
Julius Joska 8b452a5593 fix(relay): message loss on large queues, call wake-up throttling, identity reclaim, prekey upload
- stop counting ACK and Pong frames toward the per-connection frame window;
  draining a large queue used to trip the 429 limit, disconnect the client
  and, after eight rounds, drop the remaining envelopes
- deliver to online recipients with try_send: a recipient that stops reading
  its socket no longer pins the sender's task and memory
- throttle call wake-ups per recipient (1 per 10 s, 6 per 10 min) and
  ordinary wake-ups (1 per 5 s); collapse ids for APNs and FCM
- POST /v1/register accepts X-Sepot-Identity-Proof and lets a new device take
  over an existing identity (restore from the recovery phrase)
- POST /v1/keys uploads one-time prekeys and rotates signed/Kyber prekeys;
  PrekeysLow is sent after AUTH when fewer than 20 remain
- GET /v1/keys hands out at most 30 one-time prekeys per address per hour
- reqwest errors are logged without the URL; UnifiedPush POSTs pin the
  addresses the SSRF guard resolved; NAT64/6to4 embedded addresses are checked
- absolute AUTH deadline, at most 3 AUTH attempts, length limits on push
  endpoints and VoIP tokens
- TURN credentials live 12 h and are reissued per connection every 10 min
- global attachment storage cap (SEPOT_ATTACHMENTS_TOTAL_BYTES), nosniff and
  attachment disposition on downloads
- SEPOT_MAX_CONNS_PER_IP, /v1/ready backed by the database, graceful shutdown,
  RLIMIT_NOFILE raised to the hard limit, server-info reports source and revision
- Dockerfile: rust 1.99, GIT_SHA build arg for the image label and
  SEPOT_REVISION; Tor image on alpine 3.24
2026-10-09 14:02:33 +02:00
159 changed files with 4961 additions and 473 deletions
+83
View File
@@ -4,6 +4,89 @@ Všechny podstatné změny v aplikaci Šepot. Formát dle
[Keep a Changelog](https://keepachangelog.com/cs/1.1.0/), [Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
verzování dle [ajtak standardu](#verzování). verzování dle [ajtak standardu](#verzování).
## [Nevydáno]
## [1.2.3] – 2026-10-23
### 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 ## [1.2.0] – 2026-10-01
### Nový vzhled ### Nový vzhled
+11 -9
View File
@@ -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. **Š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 ## 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). - **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. - **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. - **Attachments** — images and files are encrypted client-side (AES-256-GCM) and uploaded as opaque blobs with a TTL; image metadata (EXIF/GPS) is stripped on send.
- **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers). - **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers).
- **Push without Google by default** — UnifiedPush/ntfy; FCM (Android) and APNs (iOS) are opt-in. A push wake-up never carries content, sender, or count. - **Push with or without Google** — UnifiedPush/ntfy when a distributor is installed, otherwise FCM on Android (default since 1.2.0, can be switched off) and APNs on iOS. A push wake-up never carries content, sender, or count.
- **Tor transport** (Android, via Orbot) — routes relay traffic over a `.onion` to hide the client IP. Off by default. - **Tor transport** (Android, via Orbot) — routes relay traffic over a `.onion` to hide the client IP. Off by default.
- **Five languages** — Czech, English, German, Russian, Italian. The UI follows the system language and can be overridden in Settings. - **Five languages** — Czech, English, German, Russian, Italian. The UI follows the system language and can be overridden in Settings.
@@ -29,9 +29,11 @@ The design goal is **metadata minimization with a public threat model** — neve
SQLCipher at rest opaque attachment blobs 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 ## Repository layout
@@ -71,10 +73,10 @@ UI strings live in `app/lib/l10n/app_*.arb` (`cs`, `en`, `de`, `ru`, `it`) and a
## Security ## Security
Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **bezpecnost@sepot.cz** rather than opening a public issue. Cryptography is implemented by 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 ## License
Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](LICENSE). Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](LICENSE).
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository, F-Droid, direct APK). That permission covers only Šepot's own code. The bundled Signal `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md). The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository and the direct APK releases). That permission covers only Šepot's own code. The bundled `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).
+43
View File
@@ -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.
+1 -1
View File
@@ -26,7 +26,7 @@ their app-store status is documented in detail below.
Šepot relies on that stated position and, as an AGPL work, keeps its Šepot relies on that stated position and, as an AGPL work, keeps its
complete corresponding source available under the AGPL through channels complete corresponding source available under the AGPL through channels
without app-store restrictions (this public repository, F-Droid, direct without app-store restrictions (this public repository, direct
APK). A written clarification for the current Rust `libsignal` has been APK). A written clarification for the current Rust `libsignal` has been
requested from Signal (tracking: <https://github.com/signalapp/libsignal/issues/684>). requested from Signal (tracking: <https://github.com/signalapp/libsignal/issues/684>).
@@ -1,5 +1,6 @@
package cz.sepot.sepot package cz.sepot.sepot
import android.content.pm.ApplicationInfo
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager import android.view.WindowManager
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterFragmentActivity
@@ -7,10 +8,20 @@ import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity() { class MainActivity : FlutterFragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací // Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací.
window.setFlags( // Výchozí stav; Dart přepínač „Zabezpečení obrazovky" ho může po
WindowManager.LayoutParams.FLAG_SECURE, // odemčení vypnout (default zapnuto, jako u Signalu).
WindowManager.LayoutParams.FLAG_SECURE, // 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
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#0a0e17</color> <color name="ic_launcher_background">#0B0C12</color>
</resources> </resources>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 575 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 27 KiB

+3 -3
View File
@@ -1,9 +1,9 @@
# Launcher ikony — kandidát 1 „Š s tečkami" (nokturno-ikona-B2, ember paleta). # Launcher icons: the "Š that fades into dots" mark (variant B, 2026-10).
# Zdroj: sepot-web/koncept/logo/nokturno-ikona-B2.png; regenerace: # Source: assets/icon/icon-1024.png (full) and maskable-fg-512.png (adaptive foreground); regenerate with:
# dart run flutter_launcher_icons # dart run flutter_launcher_icons
flutter_launcher_icons: flutter_launcher_icons:
android: true android: true
ios: true ios: true
image_path: assets/icon/icon-1024.png image_path: assets/icon/icon-1024.png
adaptive_icon_background: "#0a0e17" adaptive_icon_background: "#0B0C12"
adaptive_icon_foreground: assets/icon/maskable-fg-512.png adaptive_icon_foreground: assets/icon/maskable-fg-512.png
+21 -14
View File
@@ -17,22 +17,29 @@ import flutter_callkit_incoming
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
let controller = window?.rootViewController as! FlutterViewController // Bez force-castu: kdyby root nebyl FlutterViewController (jiný storyboard,
let channel = FlutterMethodChannel( // budoucí scény), appka by spadla hned při startu. Registrar dá messenger
name: "cz.sepot.sepot/apns", binaryMessenger: controller.binaryMessenger) // enginu i tak.
channel.setMethodCallHandler { [weak self] call, result in let messenger =
switch call.method { (window?.rootViewController as? FlutterViewController)?.binaryMessenger
case "register": ?? registrar(forPlugin: "SepotApns")?.messenger()
// Nativní APNs registrace (bez Firebase) — token přijde v callbacku if let messenger {
application.registerForRemoteNotifications() let channel = FlutterMethodChannel(
result(nil) name: "cz.sepot.sepot/apns", binaryMessenger: messenger)
case "getToken": channel.setMethodCallHandler { [weak self] call, result in
result(self?.pendingToken) switch call.method {
default: case "register":
result(FlutterMethodNotImplemented) // Nativní APNs registrace (bez Firebase) — token přijde v callbacku
application.registerForRemoteNotifications()
result(nil)
case "getToken":
result(self?.pendingToken)
default:
result(FlutterMethodNotImplemented)
}
} }
apnsChannel = channel
} }
apnsChannel = channel
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 B

After

Width:  |  Height:  |  Size: 560 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 817 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 26 KiB

-6
View File
@@ -1,6 +0,0 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+3 -1
View File
@@ -289,7 +289,9 @@ class _LoadingScreenState extends ConsumerState<_LoadingScreen> {
final storage = ref.read(storageProvider); final storage = ref.read(storageProvider);
final legacyPin = await storage.hasLegacyPin(); final legacyPin = await storage.hasLegacyPin();
await storage.unlockWithoutPin(); await storage.unlockWithoutPin();
if (mounted) context.go(legacyPin ? '/pin-setup' : '/chats'); if (!mounted) return;
ref.invalidate(screenSecurityProvider);
context.go(legacyPin ? '/pin-setup' : '/chats');
} }
} }
+26 -3
View File
@@ -236,9 +236,15 @@ class CallController extends Notifier<CallState> {
_ringId = pushRing.id; _ringId = pushRing.id;
if (pushRing.accepted) { if (pushRing.accepted) {
await accept(); await accept();
} else if (CallRinger.declinedEarly.remove(pushRing.id)) { return;
await reject();
} }
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; return;
} }
final foreground = 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) { void _onRingAccept(String id) {
if (id == _ringId && state.phase == CallPhase.incoming) { if (id == _ringId && state.phase == CallPhase.incoming) {
unawaited(accept()); unawaited(accept());
@@ -389,11 +404,19 @@ class CallController extends Notifier<CallState> {
return pc; return pc;
} }
/// STUN bez údajů: coturn na témž hostiteli odpovídá na STUN bez
/// přihlášení, takže i když relay TURN údaje nevydá (503, timeout),
/// jde aspoň přímé spojení přes NAT. Dřív byl seznam prázdný = jen host
/// kandidáti = hovor jen v jedné LAN.
static const _stunFallback = {
'urls': ['stun:turn.sepot.cz:3478'],
};
/// STUN i TURN běží na stejném serveru; bez údajů z relaye aspoň STUN /// 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). /// (přímé spojení, když obě strany nejsou za symetrickým NATem).
Future<List<Map<String, dynamic>>> _iceServers() async { Future<List<Map<String, dynamic>>> _iceServers() async {
final turn = await ref.read(messagingServiceProvider).turnCredentials(); 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 [ return [
{ {
'urls': turn.urls, 'urls': turn.urls,
+50 -6
View File
@@ -22,6 +22,13 @@ class CallRinger {
/// Jak dlouho zvoní — stejné jako expirace budíčku na relayi. /// Jak dlouho zvoní — stejné jako expirace budíčku na relayi.
static const ringMillis = 45000; 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; static StreamSubscription<CallEvent?>? _sub;
/// Id zvonění, která uživatel odmítl dřív, než dorazila nabídka. /// 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)); _params(id: callId, name: name, video: video));
/// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme. /// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme.
static Future<void> ringFromPush() => FlutterCallkitIncoming /// Vrací až po [pushRingGrace]: pokud se zvonění do té doby nespárovalo
.showCallkitIncoming(_params( /// s nabídkou (přijetí nebo ukončení ho z aktivních hovorů odebere, nebo
id: const Uuid().v4(), /// ho [CallController] převezme), tiše se ukončí. Background isolate FCM
name: _appName, /// handleru tím zůstane naživu přesně tak dlouho, jak je potřeba.
video: false, static Future<void> ringFromPush() async {
extra: {pushMarker: true})); 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 /// 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. /// uživatel už přijal — typicky na zamčené obrazovce, než appka naběhla.
@@ -84,6 +120,14 @@ class CallRinger {
return null; 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) => static Future<void> connected(String id) =>
FlutterCallkitIncoming.setCallConnected(id); FlutterCallkitIncoming.setCallConnected(id);
+66 -3
View File
@@ -53,16 +53,20 @@ class DekWrap {
int memoryKiB = defaultMemoryKiB, int memoryKiB = defaultMemoryKiB,
int iterations = defaultIterations, int iterations = defaultIterations,
int parallelism = defaultParallelism, int parallelism = defaultParallelism,
Uint8List? salt,
}) async { }) async {
if (dek.length != 32) { if (dek.length != 32) {
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes'); throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
} }
_enforceFloor(memoryKiB, iterations); _enforceFloor(memoryKiB, iterations);
final salt = _randomBytes(16); // Volitelná sdílená sůl: nátlakový blob se stejnou solí a parametry
// jako skutečný odemyká jedinou derivací (viz unwrapFirstMatching) —
// bez ní by decoy PIN trval 2× déle a šel poznat stopkami.
final useSalt = salt ?? _randomBytes(16);
final unlockKey = await _deriveUnlockKey( final unlockKey = await _deriveUnlockKey(
pin: pin, pin: pin,
salt: salt, salt: useSalt,
memoryKiB: memoryKiB, memoryKiB: memoryKiB,
iterations: iterations, iterations: iterations,
parallelism: parallelism, parallelism: parallelism,
@@ -80,13 +84,72 @@ class DekWrap {
'$memoryKiB', '$memoryKiB',
'$iterations', '$iterations',
'$parallelism', '$parallelism',
base64Encode(salt), base64Encode(useSalt),
base64Encode(box.nonce), base64Encode(box.nonce),
base64Encode(box.cipherText), base64Encode(box.cipherText),
base64Encode(box.mac.bytes), base64Encode(box.mac.bytes),
].join(':'); ].join(':');
} }
/// Sůl použitá v zabaleném blobu (pro sdílení s nátlakovým blobem).
static Uint8List saltOf(String wrapped) {
final parts = wrapped.split(':');
if (parts.length != 8 || parts[0] != _version) {
throw FormatException('Unknown wrapped DEK format', wrapped);
}
return Uint8List.fromList(base64Decode(parts[4]));
}
/// Zkusí PIN proti několika blobům (skutečný + nátlakový) a vrátí index
/// prvního, který sedí, s rozbaleným DEK. Argon2id se počítá jen jednou
/// pro každou odlišnou kombinaci parametrů a soli, takže u blobů se
/// sdílenou solí stojí odemčení stejně, ať sedí kterýkoli — čas
/// odemčení neprozradí, který PIN byl zadán. Nic nesedí →
/// [WrongPinException].
static Future<(int, Uint8List)> unwrapFirstMatching({
required List<String> wrapped,
required String pin,
}) async {
final parsed = <List<String>>[];
for (final w in wrapped) {
final parts = w.split(':');
if (parts.length != 8 || parts[0] != _version) {
throw FormatException('Unknown wrapped DEK format', w);
}
_enforceFloor(int.parse(parts[1]), int.parse(parts[2]));
parsed.add(parts);
}
final keys = <String, Uint8List>{};
for (final parts in parsed) {
final group = parts.sublist(1, 5).join(':');
keys[group] ??= await _deriveUnlockKey(
pin: pin,
salt: base64Decode(parts[4]),
memoryKiB: int.parse(parts[1]),
iterations: int.parse(parts[2]),
parallelism: int.parse(parts[3]),
);
}
final aes = AesGcm.with256bits();
for (var i = 0; i < parsed.length; i++) {
final parts = parsed[i];
try {
final dek = await aes.decrypt(
SecretBox(
base64Decode(parts[6]),
nonce: base64Decode(parts[5]),
mac: Mac(base64Decode(parts[7])),
),
secretKey: SecretKey(keys[parts.sublist(1, 5).join(':')]!),
);
return (i, Uint8List.fromList(dek));
} on SecretBoxAuthenticationError {
continue;
}
}
throw const WrongPinException();
}
/// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí /// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí
/// [WrongPinException]; na neplatném formátu [FormatException]. /// [WrongPinException]; na neplatném formátu [FormatException].
static Future<Uint8List> unwrap({ static Future<Uint8List> unwrap({
+98
View File
@@ -81,6 +81,32 @@ class SepotSealedMessage {
} }
/// Jeden one-time prekey k publikaci na relay. /// Jeden one-time prekey k publikaci na relay.
/// Nový signed prekey k nahrání na relay (rotace).
class SepotSignedPreKey {
SepotSignedPreKey({
required this.id,
required this.publicKey,
required this.signature,
});
final int id;
final Uint8List publicKey;
final Uint8List signature;
}
/// Nový last-resort Kyber prekey k nahrání na relay.
class SepotKyberPreKey {
SepotKyberPreKey({
required this.id,
required this.publicKey,
required this.signature,
});
final int id;
final Uint8List publicKey;
final Uint8List signature;
}
class SepotOneTimePreKey { class SepotOneTimePreKey {
SepotOneTimePreKey({required this.id, required this.publicKey}); SepotOneTimePreKey({required this.id, required this.publicKey});
@@ -305,6 +331,18 @@ class SepotCrypto {
Future<bool> hasSession(ProtocolAddress remote) => Future<bool> hasSession(ProtocolAddress remote) =>
_stores.session.containsSession(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. /// X3DH/PQXDH: zpracuje bundle protistrany a založí session.
Future<void> processRemoteBundle( Future<void> processRemoteBundle(
ProtocolAddress remote, ProtocolAddress remote,
@@ -420,4 +458,64 @@ class SepotCrypto {
} }
return result; return result;
} }
/// Nový signed prekey (rotace). Starý zůstává ve store — relace založené
/// z bundle těsně před rotací ho ještě potřebují.
Future<SepotSignedPreKey> generateSignedPreKey({
required int id,
required int timestampMs,
}) async {
final identityPrivate = PrivateKey.deserialize(
bytes: _identity.privateKey.toList(),
);
final private = PrivateKey.generate();
final public = private.getPublicKey();
final signature = identityPrivate.sign(
message: public.serialize().toList(),
);
await _stores.signedPreKey.storeSignedPreKey(
id,
SignedPreKeyRecord(
id: id,
publicKey: public,
privateKey: private,
signature: signature.toList(),
timestamp: BigInt.from(timestampMs),
),
);
return SepotSignedPreKey(
id: id,
publicKey: Uint8List.fromList(public.serialize().toList()),
signature: Uint8List.fromList(signature.toList()),
);
}
/// Nový last-resort Kyber prekey (rotace PQXDH kotvy).
Future<SepotKyberPreKey> generateKyberPreKey({
required int id,
required int timestampMs,
}) async {
final identityPrivate = PrivateKey.deserialize(
bytes: _identity.privateKey.toList(),
);
final keyPair = KyberKeyPair.generate();
final public = keyPair.getPublicKey();
final signature = identityPrivate.sign(
message: public.serialize().toList(),
);
await _stores.kyberPreKey.storeKyberPreKey(
id,
KyberPreKeyRecord.create(
id: id,
keyPair: keyPair,
signature: signature.toList(),
timestamp: BigInt.from(timestampMs),
),
);
return SepotKyberPreKey(
id: id,
publicKey: Uint8List.fromList(public.serialize().toList()),
signature: Uint8List.fromList(signature.toList()),
);
}
} }
+12
View File
@@ -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);
}
+96 -9
View File
@@ -64,9 +64,17 @@ class RelayClient {
final _deliverController = StreamController<pb.Envelope>.broadcast(); final _deliverController = StreamController<pb.Envelope>.broadcast();
final _receiptController = StreamController<pb.Receipt>.broadcast(); final _receiptController = StreamController<pb.Receipt>.broadcast();
final _turnController = StreamController<pb.TurnCredentials>.broadcast(); final _turnController = StreamController<pb.TurnCredentials>.broadcast();
final _prekeysLowController = StreamController<int>.broadcast();
var _state = RelayConnectionState.disconnected; var _state = RelayConnectionState.disconnected;
/// Kdy naposled přišel aplikační rámec od relaye.
DateTime _lastFrameAt = DateTime.fromMillisecondsSinceEpoch(0);
static const _staleAfter = Duration(seconds: 20);
/// Relay hlásí docházející one-time prekeys (zbývající počet).
Stream<int> get prekeysLow => _prekeysLowController.stream;
RelayConnectionState get state => _state; RelayConnectionState get state => _state;
Stream<RelayConnectionState> get stateStream => _stateController.stream; Stream<RelayConnectionState> get stateStream => _stateController.stream;
@@ -100,9 +108,18 @@ class RelayClient {
required List<SepotOneTimePreKey> oneTimePreKeys, required List<SepotOneTimePreKey> oneTimePreKeys,
required Uint8List deliveryAddress, required Uint8List deliveryAddress,
}) async { }) async {
// Důkaz držení identity klíče: obnova ze 24 slov na novém telefonu
// narazí na relayi na už existující identitu (409) a bez tohohle
// podpisu by ji nikdy nepřevzala. Starší relay hlavičku
// ignoruje.
final proofTs = DateTime.now().millisecondsSinceEpoch;
final proof = _crypto.sign(utf8.encode('POST /v1/register\n$proofTs'));
final resp = await _transport.httpClient().post( final resp = await _transport.httpClient().post(
Uri.parse('$baseUrl/v1/register'), Uri.parse('$baseUrl/v1/register'),
headers: {'content-type': 'application/json'}, headers: {
'content-type': 'application/json',
'x-sepot-identity-proof': '$proofTs.${base64Encode(proof)}',
},
body: jsonEncode({ body: jsonEncode({
'identity_key': base64Encode(_crypto.identityPublicKey), 'identity_key': base64Encode(_crypto.identityPublicKey),
'device_id': 1, 'device_id': 1,
@@ -144,6 +161,49 @@ class RelayClient {
_crypto.bindAccount(accountId); _crypto.bindAccount(accountId);
} }
/// POST /v1/keys — doplnění one-time prekeys a rotace signed / Kyber
/// prekey. Vrací počet OTP na relayi po nahrání.
Future<int> uploadKeys({
SepotSignedPreKey? signedPreKey,
SepotKyberPreKey? kyberPreKey,
List<SepotOneTimePreKey> oneTimePreKeys = const [],
}) async {
final deviceUuid = _requireDevice();
final ts = DateTime.now().millisecondsSinceEpoch;
final signature = _crypto.sign(utf8.encode('POST /v1/keys\n$ts'));
final resp = await _transport.httpClient().post(
Uri.parse('$baseUrl/v1/keys'),
headers: {
'content-type': 'application/json',
'x-sepot-auth': '$deviceUuid.$ts.${base64Encode(signature)}',
},
body: jsonEncode({
if (signedPreKey != null)
'signed_prekey': {
'key_id': signedPreKey.id,
'public_key': base64Encode(signedPreKey.publicKey),
'signature': base64Encode(signedPreKey.signature),
},
if (kyberPreKey != null)
'kyber_prekey': {
'key_id': kyberPreKey.id,
'public_key': base64Encode(kyberPreKey.publicKey),
'signature': base64Encode(kyberPreKey.signature),
'is_last_resort': true,
},
'one_time_prekeys': [
for (final otp in oneTimePreKeys)
{'key_id': otp.id, 'public_key': base64Encode(otp.publicKey)},
],
}),
);
if (resp.statusCode != 200) {
throw RelayException('prekey upload failed', resp.statusCode, resp.body);
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
return (body['one_time_prekeys'] as num?)?.toInt() ?? 0;
}
/// GET /v1/keys/{addr} — bundle protistrany (spotřebuje 1 OTP). /// GET /v1/keys/{addr} — bundle protistrany (spotřebuje 1 OTP).
Future<SepotPreKeyBundle> fetchBundle(Uint8List deliveryAddress) async { Future<SepotPreKeyBundle> fetchBundle(Uint8List deliveryAddress) async {
final hexAddr = deliveryAddress final hexAddr = deliveryAddress
@@ -280,6 +340,20 @@ class RelayClient {
_setState(RelayConnectionState.disconnected); _setState(RelayConnectionState.disconnected);
} }
/// Budíček od relaye znamená, že nás nevidí jako živé. Spojení, které
/// hlásí „připojeno", ale [_staleAfter] nic nepřijalo, je po změně sítě
/// nejspíš zombie — zavřít a navázat hned, ne až po ping timeoutu
/// (pojistka proti zombie spojení na straně appky). Jinak jen [start].
void kick() {
if (_state == RelayConnectionState.connected &&
DateTime.now().difference(_lastFrameAt) > _staleAfter) {
_reconnectDelayS = 1;
_onDisconnected();
return;
}
start();
}
Future<void> _teardownChannel() async { Future<void> _teardownChannel() async {
await _wsSub?.cancel(); await _wsSub?.cancel();
_wsSub = null; _wsSub = null;
@@ -313,6 +387,7 @@ class RelayClient {
_wsSub = channel.stream.listen( _wsSub = channel.stream.listen(
(data) { (data) {
final frame = pb.ServerFrame.fromBuffer(data as List<int>); final frame = pb.ServerFrame.fromBuffer(data as List<int>);
_lastFrameAt = DateTime.now();
if (!authenticated) { if (!authenticated) {
if (frame.hasChallenge()) { if (frame.hasChallenge()) {
final signature = _crypto.sign([ final signature = _crypto.sign([
@@ -345,6 +420,10 @@ class RelayClient {
if (!_turnController.isClosed) { if (!_turnController.isClosed) {
_turnController.add(frame.turnCredentials); _turnController.add(frame.turnCredentials);
} }
} else if (frame.hasPrekeysLow()) {
if (!_prekeysLowController.isClosed) {
_prekeysLowController.add(frame.prekeysLow.remaining);
}
} }
}, },
onDone: _onDisconnected, onDone: _onDisconnected,
@@ -389,14 +468,21 @@ class RelayClient {
final receiptFuture = _receiptController.stream final receiptFuture = _receiptController.stream
.firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid)) .firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid))
.timeout(timeout); .timeout(timeout);
_send(pb.ClientFrame( try {
envelope: pb.Envelope( _send(pb.ClientFrame(
dstAddress: dstAddress, envelope: pb.Envelope(
msgUuid: msgUuid, dstAddress: dstAddress,
contentHint: hint, msgUuid: msgUuid,
payload: payload, 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; return receiptFuture;
} }
@@ -455,6 +541,7 @@ class RelayClient {
_transport.close(); _transport.close();
await _stateController.close(); await _stateController.close();
await _turnController.close(); await _turnController.close();
await _prekeysLowController.close();
await _deliverController.close(); await _deliverController.close();
await _receiptController.close(); await _receiptController.close();
} }
+5 -3
View File
@@ -74,7 +74,7 @@ class PushBootstrap {
static Future<bool> registrationFailed() async => static Future<bool> registrationFailed() async =>
await _store.read(key: _registrationFailedKey) == '1'; 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.write(key: _registrationFailedKey, value: '1')
: _store.delete(key: _registrationFailedKey); : _store.delete(key: _registrationFailedKey);
@@ -87,11 +87,13 @@ class PushBootstrap {
if (Platform.isIOS) { if (Platform.isIOS) {
ApnsBridge.onWake = _wake; ApnsBridge.onWake = _wake;
ApnsBridge.ensureHandler(); ApnsBridge.ensureHandler();
// Zvonění z VoIP pushe bez nabídky nesmí zvonit celých 45 s.
unawaited(CallRinger.expireStalePushRings());
} else { } else {
await UnifiedPush.initialize( await UnifiedPush.initialize(
onNewEndpoint: _onNewEndpoint, onNewEndpoint: _onNewEndpoint,
onRegistrationFailed: (_, _) => onRegistrationFailed: (_, _) =>
unawaited(_setRegistrationFailed(true)), unawaited(setRegistrationFailed(true)),
onUnregistered: _onUnregistered, onUnregistered: _onUnregistered,
onMessage: _onMessage, onMessage: _onMessage,
); );
@@ -136,7 +138,7 @@ class PushBootstrap {
} }
static void _onNewEndpoint(PushEndpoint endpoint, String instance) { static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
unawaited(_setRegistrationFailed(false)); // registrace se povedla unawaited(setRegistrationFailed(false)); // registrace se povedla
final s = _service; final s = _service;
if (s != null) { if (s != null) {
s.handleNewEndpoint(endpoint.url); s.handleNewEndpoint(endpoint.url);
+28 -1
View File
@@ -27,6 +27,14 @@ class PushNotifier {
await _plugin.initialize( await _plugin.initialize(
InitializationSettings( InitializationSettings(
android: const AndroidInitializationSettings('@mipmap/ic_launcher'), 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( linux: LinuxInitializationSettings(
defaultActionName: l10n.pushActionOpen, defaultActionName: l10n.pushActionOpen,
), ),
@@ -48,17 +56,23 @@ class PushNotifier {
priority: Priority.high, priority: Priority.high,
category: AndroidNotificationCategory.message, category: AndroidNotificationCategory.message,
), ),
iOS: const DarwinNotificationDetails(),
linux: const LinuxNotificationDetails(), 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 { static Future<void> requestPermission() async {
await _ensureInit(); await _ensureInit();
await _plugin await _plugin
.resolvePlatformSpecificImplementation< .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>() AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission(); ?.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í. /// 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 → /// Generická notifikace bez obsahu. Klepnutí otevře appku (unlock →
/// WS připojení → fronta se vyleje normální cestou). /// WS připojení → fronta se vyleje normální cestou).
static Future<void> showWakeNotification() async { static Future<void> showWakeNotification() async {
+94 -26
View File
@@ -27,6 +27,7 @@ import 'dart:typed_data';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
import 'package:sepot/core/calls/call_ringer.dart'; import 'package:sepot/core/calls/call_ringer.dart';
import 'package:sepot/core/push/apns_ios.dart'; import 'package:sepot/core/push/apns_ios.dart';
import 'package:sepot/core/push/push_bootstrap.dart'; import 'package:sepot/core/push/push_bootstrap.dart';
@@ -35,10 +36,27 @@ import 'package:sepot/core/services/messaging_service.dart';
import 'package:sepot/proto/sepot.pb.dart' as pb; import 'package:sepot/proto/sepot.pb.dart' as pb;
import 'package:unifiedpush/unifiedpush.dart'; 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 { class PushService {
PushService(this._messaging); PushService(this._messaging, {IosPushPlatform ios = const IosPushPlatform()})
: _ios = ios;
final MessagingService _messaging; final MessagingService _messaging;
final IosPushPlatform _ios;
StreamSubscription<String>? _fcmTokenSub; StreamSubscription<String>? _fcmTokenSub;
StreamSubscription<RemoteMessage>? _fcmMessageSub; StreamSubscription<RemoteMessage>? _fcmMessageSub;
@@ -52,7 +70,15 @@ class PushService {
/// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.) /// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.)
bool get canWake => _messaging.isStarted; bool get canWake => _messaging.isStarted;
void wake() => _messaging.onPushWake(); /// Budíček: běžící messaging připojí WS; po auto-locku (zamčená DB) aspoň
/// generická notifikace bez obsahu, ať se uživatel o zprávě dozví.
void wake() {
if (canWake) {
_messaging.onPushWake();
} else {
unawaited(PushNotifier.showWakeNotification());
}
}
void handleNewEndpoint(String url) { void handleNewEndpoint(String url) {
_messaging.registerPush(_registration( _messaging.registerPush(_registration(
@@ -86,34 +112,66 @@ class PushService {
String? _apnsToken; String? _apnsToken;
/// APNs token pro zprávy + VoIP token pro hovory v jedné registraci /// Registrace iOS pushů pro relay: APNs token pro zprávy + VoIP token pro
/// (relay drží obojí u zařízení). /// hovory (relay drží obojí u zařízení). VoIP token jde i sám, s prázdným
Future<void> _registerApnsToken(String token) async { /// endpointem — PushKit ho vydá nezávisle na APNs a hovory nesmí čekat,
_apnsToken = token; /// až Apple vydá i token pro zprávy. Null = zatím není co poslat.
final voip = await CallRinger.voipToken(); @visibleForTesting
final reg = _registration( static pb.RegisterPush? apnsRegistration({
pb.RegisterPush_Platform.PLATFORM_APNS, String? apnsToken,
Uint8List.fromList(utf8.encode(token)), 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); if (voipToken != null) reg.voipToken = utf8.encode(voipToken);
_messaging.registerPush(reg); return reg;
} }
/// PushKit vydal (nový) VoIP token — přeregistrovat, pokud už APNs běží. Future<void> _sendApnsRegistration() async {
Future<void> refreshVoipToken() async { final reg = apnsRegistration(
final token = _apnsToken; apnsToken: _apnsToken,
if (token != null) await _registerApnsToken(token); 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 /// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
/// asynchronně a sám se pošle relayi. Bez Googlu. /// 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 { Future<void> enableApns() async {
await init(); await init();
await PushNotifier.requestPermission(); await _ios.registerApns();
await ApnsBridge.register(); await _ios.writeMode('apns');
final existing = await ApnsBridge.currentToken(); final existing = await _ios.apnsToken();
if (existing != null) _registerApnsToken(existing); if (existing != null) {
await PushBootstrap.writeMode('apns'); 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. /// 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í). /// Seznam nainstalovaných distributorů (pro výběr v nastavení).
Future<List<String>> distributors() => UnifiedPush.getDistributors(); /// UnifiedPush existuje jen na Androidu; jinde by volání pluginu vyhodilo
/// UnimplementedError (iOS: dlaždice Push v Nastavení).
Future<List<String>> distributors() async =>
Platform.isAndroid ? UnifiedPush.getDistributors() : const [];
/// Aktuálně uložený distributor (null = push vypnutý / nevybraný). /// Aktuálně uložený distributor (null = push vypnutý / nevybraný).
Future<String?> currentDistributor() => UnifiedPush.getDistributor(); Future<String?> currentDistributor() async =>
Platform.isAndroid ? UnifiedPush.getDistributor() : null;
/// Vybere konkrétního distributora a zaregistruje se u něj. /// Vybere konkrétního distributora a zaregistruje se u něj.
Future<void> useDistributor(String distributor) async { Future<void> useDistributor(String distributor) async {
@@ -150,7 +212,7 @@ class PushService {
} }
Future<bool> get isEnabled async => Future<bool> get isEnabled async =>
(await UnifiedPush.getDistributor()) != null; Platform.isAndroid && (await UnifiedPush.getDistributor()) != null;
// ── Režimy (off / up / fcm) ───────────────────────────────────────── // ── Režimy (off / up / fcm) ─────────────────────────────────────────
@@ -159,6 +221,7 @@ class PushService {
Future<String> currentMode() async { Future<String> currentMode() async {
final mode = await PushBootstrap.readMode(); final mode = await PushBootstrap.readMode();
if (mode != 'off') return mode; if (mode != 'off') return mode;
if (!Platform.isAndroid) return mode;
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off'; return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
} }
@@ -232,10 +295,15 @@ class PushService {
Future<void> restoreMode() async { Future<void> restoreMode() async {
// iOS: jediná smysluplná cesta je APNs (UnifiedPush je Android-only). // iOS: jediná smysluplná cesta je APNs (UnifiedPush je Android-only).
// Zapínáme ho automaticky — pro iOS messenger jsou notifikace základ. // Zapínáme ho automaticky — pro iOS messenger jsou notifikace základ.
if (Platform.isIOS) { if (_ios.isIOS) {
try { try {
await enableApns(); 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(); await init();
} }
return; return;
+64
View File
@@ -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;
}
+345 -53
View File
@@ -36,6 +36,7 @@ import 'package:sepot/core/errors.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart'; import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/core/network/relay_client.dart'; import 'package:sepot/core/network/relay_client.dart';
import 'package:sepot/core/network/relay_transport.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/group_admin_policy.dart';
import 'package:sepot/core/services/image_processor.dart'; import 'package:sepot/core/services/image_processor.dart';
import 'package:sepot/core/services/typing_tracker.dart'; import 'package:sepot/core/services/typing_tracker.dart';
@@ -107,6 +108,56 @@ class MessagingService {
StreamSubscription<RelayConnectionState>? _stateSub; StreamSubscription<RelayConnectionState>? _stateSub;
Timer? _registerRetryTimer; Timer? _registerRetryTimer;
Timer? _purgeTimer; 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 /// 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 /// 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) => final targets = contacts.where((c) =>
!c.blocked && !c.blocked &&
(group == null (group == null
? c.id == convId ? c.id == convId && c.accepted
: group.memberIds.contains(c.id) && c.id != _identity?.id)); : group.memberIds.contains(c.id) && c.id != _identity?.id));
for (final c in targets) { for (final c in targets) {
final until = _typingBackoffUntil[c.id]; final until = _typingBackoffUntil[c.id];
@@ -266,11 +317,13 @@ class MessagingService {
final relay = _buildRelay(); final relay = _buildRelay();
_relay = relay; _relay = relay;
_deliverSub = relay.deliveries.listen(_onDeliver); _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. // Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
_stateSub = relay.stateStream.listen((state) { _stateSub = relay.stateStream.listen((state) {
if (state == RelayConnectionState.connected) { if (state == RelayConnectionState.connected) {
unawaited(_flushOutbox()); unawaited(_flushOutbox());
unawaited(_refreshMyAvatarIfOld()); unawaited(_refreshMyAvatarIfOld());
unawaited(_rotatePreKeysIfDue());
} }
}); });
@@ -337,11 +390,13 @@ class MessagingService {
final relay = _buildRelay(); final relay = _buildRelay();
_relay = relay; _relay = relay;
_deliverSub = relay.deliveries.listen(_onDeliver); _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. // Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
_stateSub = relay.stateStream.listen((state) { _stateSub = relay.stateStream.listen((state) {
if (state == RelayConnectionState.connected) { if (state == RelayConnectionState.connected) {
unawaited(_flushOutbox()); unawaited(_flushOutbox());
unawaited(_refreshMyAvatarIfOld()); unawaited(_refreshMyAvatarIfOld());
unawaited(_rotatePreKeysIfDue());
} }
}); });
@@ -376,16 +431,109 @@ class MessagingService {
Future<void> _registerWithRetry() async { Future<void> _registerWithRetry() async {
try { try {
await _register(); 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 (_) { } catch (_) {
_registerRetryTimer?.cancel(); _scheduleRegisterRetry();
_registerRetryTimer =
Timer(const Duration(seconds: 30), _registerWithRetry);
return; return;
} }
_registrationConflict = false;
_registerRetryDelay = const Duration(seconds: 30);
_relay!.start(); _relay!.start();
await _refreshSenderCert(); 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 { Future<void> _register() async {
final db = _db!; final db = _db!;
final crypto = _crypto!; final crypto = _crypto!;
@@ -410,10 +558,17 @@ class MessagingService {
deliveryAddress: deliveryAddress, deliveryAddress: deliveryAddress,
); );
final stamp = utf8.encode('$nowMs');
db.transaction(() { db.transaction(() {
db.kvPut('account_id', utf8.encode(reg.accountId)); db.kvPut('account_id', utf8.encode(reg.accountId));
db.kvPut('device_uuid', utf8.encode(reg.deviceUuid)); db.kvPut('device_uuid', utf8.encode(reg.deviceUuid));
db.kvPut('delivery_address', deliveryAddress); 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(); onUpdate?.call();
} }
@@ -839,6 +994,8 @@ class MessagingService {
Future<void> sendReadReceipts(Contact contact, List<String> messageIds, Future<void> sendReadReceipts(Contact contact, List<String> messageIds,
{String? gid}) async { {String? gid}) async {
if (messageIds.isEmpty) return; 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 = final enabled =
(await storage.getSetting<bool>('read_receipts_enabled')) ?? false; (await storage.getSetting<bool>('read_receipts_enabled')) ?? false;
if (!enabled) return; if (!enabled) return;
@@ -1794,19 +1951,26 @@ class MessagingService {
verified: known.verified, verified: known.verified,
notes: known.notes, notes: known.notes,
blocked: known.blocked, blocked: known.blocked,
avatar: known.avatar,
accepted: known.accepted,
)); ));
} }
continue; continue;
} }
final name = (e['n'] as String?)?.trim(); final name = (e['n'] as String?)?.trim();
await storage.saveContact(Contact.fromPublicIdentity(PublicIdentity( // Člena skupiny jsem si nepřidal já: jméno mu dal on sám (nebo ten,
// Prázdné jméno = UI si doplní lokalizovaný fallback // kdo roster poslal). Jako u příchozí zprávy je to žádost.
displayName: (name == null || name.isEmpty) ? '' : name, await storage.saveContact(Contact.fromPublicIdentity(
identityKey: base64Decode(e['ik'] as String), PublicIdentity(
createdAt: DateTime.now(), // Prázdné jméno = UI si doplní lokalizovaný fallback
accountId: aid, displayName: (name == null || name.isEmpty) ? '' : clipName(name),
deliveryAddress: daB64 == null ? null : base64Decode(daB64), 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 ?? '', 'txt': caption ?? '',
're': ?message.replyToId, 're': ?message.replyToId,
'ttl': ?message.disappearAfterSeconds, '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(), 'p': _profile(),
}; };
} }
@@ -2053,22 +2221,29 @@ class MessagingService {
} }
final peerAddr = ProtocolAddress(name: peerAid, deviceId: 1); final peerAddr = ProtocolAddress(name: peerAid, deviceId: 1);
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět // Krypto část jde přes [_serial] (viz komentář u pole): relace se
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní). // načte, zašifruje a uloží bez souběhu. Odeslání po síti už zámek
if (!await crypto.hasSession(peerAddr)) { // nedrží — čekání na receipt by blokovalo všechny ostatní.
if (ephemeral) return null; final sealed = await _serial<Uint8List?>(() async {
final bundle = await relay.fetchBundle(peerDa); await _dropForeignSession(crypto, peerAddr, contact);
if (!_listEquals(bundle.identityKey, peer.identityKey)) { // První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
throw const IdentityKeyMismatchError(); // 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); return crypto.sealedEncrypt(
} recipient: peerAddr,
plaintext: utf8.encode(jsonEncode(content)),
final sealed = await crypto.sealedEncrypt( senderCertificate: await _senderCertificate(),
recipient: peerAddr, );
plaintext: utf8.encode(jsonEncode(content)), });
senderCertificate: await _senderCertificate(), if (sealed == null) return null;
);
final receipt = await relay.sendEnvelope( final receipt = await relay.sendEnvelope(
dstAddress: peerDa, dstAddress: peerDa,
@@ -2082,12 +2257,50 @@ class MessagingService {
return receipt.status; 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 ────────────────────────────────────────────────────────── // ── 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 crypto = _crypto;
final trustRoot = _trustRoot; 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; final SepotSealedMessage opened;
try { try {
@@ -2101,7 +2314,17 @@ class MessagingService {
// (může jít o dočasný stav — např. cert refresh). // (může jít o dočasný stav — např. cert refresh).
return; 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 // Od tohohle bodu ratchet postoupil — obálku na relayi už NIKDY znovu
// nedešifrujeme (duplicitní decrypt libsignal odmítne). Vadný OBSAH se // nedešifrujeme (duplicitní decrypt libsignal odmítne). Vadný OBSAH se
// proto ACKuje a zahazuje: redelivery nemůže nic spravit, jen by // proto ACKuje a zahazuje: redelivery nemůže nic spravit, jen by
@@ -2110,14 +2333,11 @@ class MessagingService {
final content = final content =
jsonDecode(utf8.decode(opened.plaintext)) as Map<String, dynamic>; jsonDecode(utf8.decode(opened.plaintext)) as Map<String, dynamic>;
final type = content['t'] as String?; final type = content['t'] as String?;
const known = { const known = knownContentTypes;
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote',
..._groupControlTypes,
};
if (content['v'] != 1 || !known.contains(type)) { if (content['v'] != 1 || !known.contains(type)) {
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný // 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. // a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
_relay?.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
@@ -2127,15 +2347,19 @@ class MessagingService {
// Blokovaný kontakt: zahoď cokoliv od něj (ACK, ať se nefrontuje), // Blokovaný kontakt: zahoď cokoliv od něj (ACK, ať se nefrontuje),
// nic neukládej ani nezobrazuj a neposílej doručenku. // nic neukládej ani nezobrazuj a neposílej doručenku.
if (contact.blocked) { if (contact.blocked) {
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
// Hovory jen 1:1; skupinový gid tu nemá co dělat. // 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. // 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 (type == 'call') {
if (content['gid'] == null) onCallSignal?.call(contact, content); if (content['gid'] == null && contact.accepted) {
_relay!.ack(envelope.msgUuid); onCallSignal?.call(contact, content);
}
_relay?.ack(msgUuid);
return; return;
} }
@@ -2145,7 +2369,7 @@ class MessagingService {
// nepomůže). // nepomůže).
if (_groupControlTypes.contains(type)) { if (_groupControlTypes.contains(type)) {
await _handleGroupControl(senderFp, type, content); await _handleGroupControl(senderFp, type, content);
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
@@ -2155,7 +2379,7 @@ class MessagingService {
final group = final group =
gid == null ? null : await _groupForIncoming(gid, senderFp); gid == null ? null : await _groupForIncoming(gid, senderFp);
if (gid != null && group == null) { if (gid != null && group == null) {
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
if (group != null && _noteCapability(group, senderFp, content)) { if (group != null && _noteCapability(group, senderFp, content)) {
@@ -2170,7 +2394,7 @@ class MessagingService {
final fresh = DateTime.now().difference(wireSentAt(content['ts'])) < final fresh = DateTime.now().difference(wireSentAt(content['ts'])) <
TypingTracker.showFor; TypingTracker.showFor;
if (fresh && await _typingEnabled()) typing.mark(convId, senderFp); if (fresh && await _typingEnabled()) typing.mark(convId, senderFp);
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
@@ -2205,7 +2429,7 @@ class MessagingService {
); );
} }
} }
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
onUpdate?.call(); onUpdate?.call();
return; return;
} }
@@ -2214,7 +2438,7 @@ class MessagingService {
// novou bublinu. Aplikujeme a ACKneme (retry z fronty nepomůže). // novou bublinu. Aplikujeme a ACKneme (retry z fronty nepomůže).
if (type == 'rx' || type == 'edit' || type == 'del' || type == 'vote') { if (type == 'rx' || type == 'edit' || type == 'del' || type == 'vote') {
await _applyIncomingControl(convId, type!, content, opened); await _applyIncomingControl(convId, type!, content, opened);
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
onUpdate?.call(); onUpdate?.call();
return; return;
} }
@@ -2228,13 +2452,18 @@ class MessagingService {
final replyTo = content['re'] as String?; final replyTo = content['re'] as String?;
final Message message; final Message message;
if (type == 'txt') { if (type == 'txt') {
final text = content['txt']; final raw = content['txt'];
if (text is! String) { if (raw is! String) {
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty // Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh. // nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; 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( message = Message(
id: messageId, id: messageId,
senderId: contact.publicIdentity.fingerprint.display, senderId: contact.publicIdentity.fingerprint.display,
@@ -2261,7 +2490,7 @@ class MessagingService {
} }
} on Object { } on Object {
// Trvale vadná metadata — retry z fronty nepomůže, ACKnout // Trvale vadná metadata — retry z fronty nepomůže, ACKnout
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
message = Message( message = Message(
@@ -2286,7 +2515,7 @@ class MessagingService {
// Uloženo na disku → teprve teď smí relay obálku zahodit. // Uloženo na disku → teprve teď smí relay obálku zahodit.
// (Blob přílohy do fronty nepatří — stahuje se na pozadí a při // (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á.) // 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í). // 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 // Druhá bublina ani další stahování téhož blobu nemá vzniknout, ale
@@ -2319,11 +2548,11 @@ class MessagingService {
} on FormatException { } on FormatException {
// Malformovaný obsah (ne-UTF-8/ne-JSON plaintext, vadná base64 // Malformovaný obsah (ne-UTF-8/ne-JSON plaintext, vadná base64
// v profilu či rosteru) — trvale vadné, ACK a zahodit. // v profilu či rosteru) — trvale vadné, ACK a zahodit.
_relay?.ack(envelope.msgUuid); _relay?.ack(msgUuid);
} on TypeError { } on TypeError {
// Pole se špatným typem (txt/ttl/id/re/p jako číslo apod.) — // Pole se špatným typem (txt/ttl/id/re/p jako číslo apod.) —
// trvale vadné, ACK a zahodit. // trvale vadné, ACK a zahodit.
_relay?.ack(envelope.msgUuid); _relay?.ack(msgUuid);
} catch (_) { } catch (_) {
// Přechodné selhání zpracování (storage apod.): NEACKovat — další // Přechodné selhání zpracování (storage apod.): NEACKovat — další
// pokus po reconnectu za nic nedá, i když duplicitní decrypt // 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. // Bez tohohle se zablokovaný kontakt při změně adresy odblokoval.
blocked: c.blocked, blocked: c.blocked,
avatar: avatarChanged ? avatar : c.avatar, avatar: avatarChanged ? avatar : c.avatar,
accepted: c.accepted,
); );
await storage.saveContact(updated); await storage.saveContact(updated);
if (avatarChanged) { if (avatarChanged) {
@@ -2563,22 +2793,74 @@ class MessagingService {
return updated; 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 // Prázdné jméno = UI si doplní lokalizovaný fallback
final name = (profile?['n'] as String?)?.trim(); final name = (profile?['n'] as String?)?.trim();
final identity = PublicIdentity( final identity = PublicIdentity(
displayName: (name == null || name.isEmpty) ? '' : name, displayName: (name == null || name.isEmpty) ? '' : clipName(name),
identityKey: opened.senderIdentityKey, identityKey: opened.senderIdentityKey,
createdAt: DateTime.now(), createdAt: DateTime.now(),
accountId: opened.senderName, accountId: opened.senderName,
deliveryAddress: profileDa == null ? null : base64Decode(profileDa), 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); await storage.saveContact(contact);
if (avatar != null) unawaited(_downloadAvatar(avatar)); 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; 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 ────────────────────────────────────── // ── Sdílení vlastního kontaktu ──────────────────────────────────────
/// Veřejná identita včetně routingu — tohle patří do QR/odkazu. /// 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 /// Probuzení z push budíčku: ujisti se, že jsme připojení — fronta se
/// vyleje po AUTH (relay pošle DELIVER stream). Idempotentní. /// vyleje po AUTH (relay pošle DELIVER stream). Idempotentní.
void onPushWake() { void onPushWake() {
_relay?.start(); _relay?.kick();
} }
/// Deregistrace účtu na relayi (GDPR čl. 17 / store „smazání účtu"). /// Deregistrace účtu na relayi (GDPR čl. 17 / store „smazání účtu").
@@ -2631,7 +2913,17 @@ class MessagingService {
_purgeTimer?.cancel(); _purgeTimer?.cancel();
_purgeTimer = null; _purgeTimer = null;
await _deliverSub?.cancel(); await _deliverSub?.cancel();
await _prekeysLowSub?.cancel();
await _stateSub?.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(); await _relay?.dispose();
// DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit // DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit
_db = null; _db = null;
+151 -31
View File
@@ -30,6 +30,16 @@ import 'package:sepot/models/message.dart';
/// při onboardingu, který DB rovnou otevře). /// při onboardingu, který DB rovnou otevře).
/// Nátlakový PIN je shodný se skutečným — nelze nastavit. /// Nátlakový PIN je shodný se skutečným — nelze nastavit.
/// Příliš mnoho špatných PINů za sebou — další pokus až za [remaining].
class PinLockedException implements Exception {
const PinLockedException(this.remaining);
final Duration remaining;
@override
String toString() => 'PIN locked for ${remaining.inSeconds}s';
}
class DecoyPinConflict implements Exception { class DecoyPinConflict implements Exception {
const DecoyPinConflict(); const DecoyPinConflict();
@override @override
@@ -51,6 +61,13 @@ class SecureStorageService {
static const _legacyPinHashKey = 'pin_hash'; static const _legacyPinHashKey = 'pin_hash';
static const _decoyWrappedDekKey = 'wrapped_dek_decoy'; static const _decoyWrappedDekKey = 'wrapped_dek_decoy';
// Brzda hádání PINu: počet chyb za sebou a čas, do kdy je zámek uzamčený.
// V keystore (ne v DB) — DB je právě to, co se hádáním otevírá.
static const _pinFailCountKey = 'pin_fail_count';
static const _pinLockUntilKey = 'pin_lock_until';
static const _pinFreeAttempts = 5;
static const _pinLockBase = Duration(seconds: 30);
static const _pinLockMax = Duration(minutes: 15);
final FlutterSecureStorage _secureStorage; final FlutterSecureStorage _secureStorage;
final String? _dbPathOverride; final String? _dbPathOverride;
@@ -60,12 +77,24 @@ class SecureStorageService {
bool _decoyMode = false; bool _decoyMode = false;
/// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB. /// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB.
/// V tomhle režimu úložiště nevydá skutečnou identitu ani frázi,
/// nestartuje síť a nic z keystore nemění (viz jednotlivé metody).
bool get isDecoyMode => _decoyMode; bool get isDecoyMode => _decoyMode;
/// Identita ukazovaná v nátlakové relaci: náhodná, jen v paměti.
LocalIdentity? _decoyIdentity;
void _requireRealSession() {
if (_decoyMode) throw StateError('Not available in a duress session');
}
SecureStorageService({String? dbPath}) SecureStorageService({String? dbPath})
: _secureStorage = const FlutterSecureStorage( : _secureStorage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true), aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), // this_device: identita a klíče nesmí putovat v iCloud Keychain
// ani v záloze na jiný telefon — obnova jde přes 24 slov.
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device),
), ),
_dbPathOverride = dbPath; _dbPathOverride = dbPath;
@@ -98,21 +127,59 @@ class SecureStorageService {
if (wrapped == null) { if (wrapped == null) {
throw StateError('No PIN set — use unlockWithoutPin'); throw StateError('No PIN set — use unlockWithoutPin');
} }
final locked = await pinLockRemaining();
if (locked != null) throw PinLockedException(locked);
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
try { try {
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: pin); // Oba bloby jednou derivací (sdílená sůl) — čas odemčení neprozradí,
await _openDb(dek); // který PIN padl.
final (index, dek) = await DekWrap.unwrapFirstMatching(
wrapped: [wrapped, ?decoy],
pin: pin,
);
await _openDb(dek, decoy: index == 1);
await _resetPinFailures();
} on WrongPinException { } on WrongPinException {
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey); await _notePinFailure();
if (decoy == null) rethrow; rethrow;
// Když ani decoy nesedí, unwrap vyhodí WrongPinException dál.
final dek = await DekWrap.unwrap(wrapped: decoy, pin: pin);
await _openDb(dek, decoy: true);
} }
} }
/// Zbývající zámek po sérii špatných PINů; null = volno.
Future<Duration?> pinLockRemaining() async {
final raw = await _secureStorage.read(key: _pinLockUntilKey);
final until = raw == null ? null : int.tryParse(raw);
if (until == null) return null;
final remaining = DateTime.fromMillisecondsSinceEpoch(until)
.difference(DateTime.now());
return remaining > Duration.zero ? remaining : null;
}
Future<void> _notePinFailure() async {
final raw = await _secureStorage.read(key: _pinFailCountKey);
final failures = (raw == null ? 0 : int.tryParse(raw) ?? 0) + 1;
await _secureStorage.write(key: _pinFailCountKey, value: '$failures');
if (failures < _pinFreeAttempts) return;
// 30 s po páté chybě, pak dvojnásobně až do 15 minut: šest číslic
// (10⁶) tak trvá roky, Argon2id sám by brzdil jen ~2 s na pokus.
var lock = _pinLockBase * (1 << (failures - _pinFreeAttempts));
if (lock > _pinLockMax) lock = _pinLockMax;
await _secureStorage.write(
key: _pinLockUntilKey,
value: '${DateTime.now().add(lock).millisecondsSinceEpoch}',
);
}
Future<void> _resetPinFailures() async {
await _secureStorage.delete(key: _pinFailCountKey);
await _secureStorage.delete(key: _pinLockUntilKey);
}
/// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže. /// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže.
/// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře. /// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře.
Future<void> enablePin(String pin) async { Future<void> enablePin(String pin) async {
_requireRealSession();
final dek = _dek ?? await _getOrCreateRawDek(); final dek = _dek ?? await _getOrCreateRawDek();
final wrapped = await DekWrap.wrap(dek: dek, pin: pin); final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
await _secureStorage.write(key: _wrappedDekKey, value: wrapped); await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
@@ -124,20 +191,34 @@ class SecureStorageService {
/// Vypne PIN: DEK se vrátí raw do OS keystore. /// Vypne PIN: DEK se vrátí raw do OS keystore.
Future<void> disablePin() async { Future<void> disablePin() async {
_requireRealSession();
final dek = _dek ?? (throw StateError('Requires unlocked storage')); final dek = _dek ?? (throw StateError('Requires unlocked storage'));
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek)); await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
await _secureStorage.delete(key: _wrappedDekKey); await _secureStorage.delete(key: _wrappedDekKey);
} }
/// Ověří PIN, aniž by cokoli změnil (unwrap a zahození DEK). /// Ověří skutečný PIN, aniž by cokoli změnil (unwrap a zahození DEK).
/// Stojí jednu Argon2id derivaci (~2 s) — to je zároveň rate limit. /// 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 { Future<bool> verifyPin(String pin) async {
if (_decoyMode) return false;
final wrapped = await _secureStorage.read(key: _wrappedDekKey); final wrapped = await _secureStorage.read(key: _wrappedDekKey);
if (wrapped == null) return false; if (wrapped == null) return false;
final locked = await pinLockRemaining();
if (locked != null) throw PinLockedException(locked);
try { try {
await DekWrap.unwrap(wrapped: wrapped, pin: pin); await DekWrap.unwrap(wrapped: wrapped, pin: pin);
await _resetPinFailures();
return true; return true;
} on WrongPinException { } on WrongPinException {
await _notePinFailure();
return false; return false;
} }
} }
@@ -151,6 +232,7 @@ class SecureStorageService {
required String oldPin, required String oldPin,
required String newPin, required String newPin,
}) async { }) async {
_requireRealSession();
final wrapped = await _secureStorage.read(key: _wrappedDekKey); final wrapped = await _secureStorage.read(key: _wrappedDekKey);
if (wrapped == null) throw StateError('No PIN set'); if (wrapped == null) throw StateError('No PIN set');
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin); final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin);
@@ -165,7 +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); 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 /// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy
/// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit. /// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit.
Future<void> enableDecoyPin(String decoyPin) async { Future<void> enableDecoyPin(String decoyPin) async {
_requireRealSession();
final realWrapped = await _secureStorage.read(key: _wrappedDekKey); final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
if (realWrapped == null) { if (realWrapped == null) {
throw StateError('Set a regular PIN first'); throw StateError('Set a regular PIN first');
@@ -193,7 +280,9 @@ class SecureStorageService {
// Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB // Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB
final decoyPath = await _dbPath(decoy: true); final decoyPath = await _dbPath(decoy: true);
CryptoDb.open(path: decoyPath, dek: decoyDek).close(); CryptoDb.open(path: decoyPath, dek: decoyDek).close();
final wrapped = await DekWrap.wrap(dek: decoyDek, pin: decoyPin); // Stejná sůl jako skutečný blob → jedna Argon2id derivace pro oba PINy.
final wrapped = await DekWrap.wrap(
dek: decoyDek, pin: decoyPin, salt: DekWrap.saltOf(realWrapped));
await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped); await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped);
} }
@@ -203,11 +292,20 @@ class SecureStorageService {
/// Zruší nátlakový PIN a smaže decoy DB. /// Zruší nátlakový PIN a smaže decoy DB.
Future<void> disableDecoyPin() async { Future<void> disableDecoyPin() async {
_requireRealSession();
await _secureStorage.delete(key: _decoyWrappedDekKey); await _secureStorage.delete(key: _decoyWrappedDekKey);
try { await _deleteDbFiles(await _dbPath(decoy: true));
final f = File(await _dbPath(decoy: true)); }
if (f.existsSync()) f.deleteSync();
} catch (_) {} 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 /// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme
@@ -222,6 +320,8 @@ class SecureStorageService {
_db?.close(); _db?.close();
_db = null; _db = null;
_dek = null; _dek = null;
_decoyMode = false;
_decoyIdentity = null;
} }
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async { Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
@@ -293,6 +393,11 @@ class SecureStorageService {
/// Načti lokální identitu /// Načti lokální identitu
Future<LocalIdentity?> loadIdentity() async { Future<LocalIdentity?> loadIdentity() async {
if (_decoyMode) {
// Nátlaková relace ukazuje náhodnou identitu jen z paměti — skutečný
// klíč z keystore se do ní nikdy nedostane (nátlakový PIN musí být izolovaný).
return _decoyIdentity ??= await LocalIdentity.create('');
}
final json = await _secureStorage.read(key: _identityKey); final json = await _secureStorage.read(key: _identityKey);
if (json == null) return null; if (json == null) return null;
try { try {
@@ -320,6 +425,7 @@ class SecureStorageService {
} }
Future<List<String>?> loadSeedPhrase() async { Future<List<String>?> loadSeedPhrase() async {
if (_decoyMode) return null;
final json = await _secureStorage.read(key: _seedPhraseKey); final json = await _secureStorage.read(key: _seedPhraseKey);
if (json == null) return null; if (json == null) return null;
return (jsonDecode(json) as List).cast<String>(); return (jsonDecode(json) as List).cast<String>();
@@ -331,6 +437,7 @@ class SecureStorageService {
/// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings). /// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings).
/// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním. /// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním.
Future<void> enableBiometrics() async { Future<void> enableBiometrics() async {
_requireRealSession();
final dek = _dek ?? (throw StateError('Requires unlocked storage')); final dek = _dek ?? (throw StateError('Requires unlocked storage'));
await _secureStorage.write( await _secureStorage.write(
key: _biometricDekKey, key: _biometricDekKey,
@@ -342,6 +449,7 @@ class SecureStorageService {
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý /// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
/// PIN-only model: DEK existuje jen za Argon2id). /// PIN-only model: DEK existuje jen za Argon2id).
Future<void> disableBiometrics() async { Future<void> disableBiometrics() async {
_requireRealSession();
await _secureStorage.delete(key: _biometricDekKey); await _secureStorage.delete(key: _biometricDekKey);
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false'); await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
} }
@@ -475,24 +583,29 @@ class SecureStorageService {
if (!isUnlocked) return false; if (!isUnlocked) return false;
var changed = false; var changed = false;
final now = DateTime.now(); final now = DateTime.now();
for (final conv in await loadConversations()) { bool isExpired(Message m) =>
final expired = conv.messages m.disappearAfterSeconds != null &&
.where((m) => m.timestamp
m.disappearAfterSeconds != null && .add(Duration(seconds: m.disappearAfterSeconds!))
m.timestamp .isBefore(now);
.add(Duration(seconds: m.disappearAfterSeconds!)) for (final snapshot in await loadConversations()) {
.isBefore(now)) if (!snapshot.messages.any(isExpired)) continue;
.toList(); // Konverzace se před zápisem načte znovu: mezi snímkem a zápisem
// běží event loop (mazání blobů, příjem zpráv) a starý snímek by
// přepsal zprávu přijatou mezitím — už ACKnutou, tedy navždy pryč.
final conv = await loadConversation(snapshot.contactId);
if (conv == null) continue;
final expired = conv.messages.where(isExpired).toList();
if (expired.isEmpty) continue; if (expired.isEmpty) continue;
changed = true; changed = true;
conv.messages.removeWhere(isExpired);
await saveConversation(conv);
for (final m in expired) { for (final m in expired) {
final blobId = m.attachment?.blobId; final blobId = m.attachment?.blobId;
if (blobId != null) { if (blobId != null) {
await AttachmentStore().delete(blobId); await AttachmentStore().delete(blobId);
} }
conv.messages.remove(m);
} }
await saveConversation(conv);
} }
return changed; return changed;
} }
@@ -512,12 +625,19 @@ class SecureStorageService {
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore /// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
/// i lokální bloby příloh. /// i lokální bloby příloh.
Future<void> deleteAll() async { Future<void> deleteAll() async {
final path = await _dbPath(); if (_decoyMode) {
lock(); // Nátlaková relace: „smazat" smí jen prázdnou decoy DB — skutečná
for (final suffix in ['', '-wal', '-shm']) { // data a keystore zůstávají (dřív to smazalo reálný účet).
final f = File('$path$suffix'); final decoyPath = await _dbPath(decoy: true);
if (f.existsSync()) f.deleteSync(); 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 AttachmentStore().deleteAll();
await _secureStorage.deleteAll(); await _secureStorage.deleteAll();
} }
+8 -1
View File
@@ -71,7 +71,14 @@ class _ChangePinScreenState extends ConsumerState<ChangePinScreen> {
switch (_krok) { switch (_krok) {
case _Krok.stary: case _Krok.stary:
setState(() => _pracuje = true); 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 (!mounted) return;
if (!ok) { if (!ok) {
_reset(context.l10n.pinWrong); _reset(context.l10n.pinWrong);
+20 -2
View File
@@ -4,10 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:local_auth/local_auth.dart'; import 'package:local_auth/local_auth.dart';
import 'package:sepot/core/crypto/dek_wrap.dart'; import 'package:sepot/core/crypto/dek_wrap.dart';
import 'package:sepot/core/storage/secure_storage.dart' show PinLockedException;
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/messaging_provider.dart'; import 'package:sepot/providers/messaging_provider.dart';
import 'package:sepot/providers/push_provider.dart'; import 'package:sepot/providers/push_provider.dart';
import 'package:sepot/providers/settings_provider.dart';
import 'package:sepot/providers/storage_provider.dart'; import 'package:sepot/providers/storage_provider.dart';
import 'package:sepot/widgets/sepot_logo.dart'; import 'package:sepot/widgets/sepot_logo.dart';
@@ -41,7 +43,10 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
try { try {
final ok = await LocalAuthentication().authenticate( final ok = await LocalAuthentication().authenticate(
localizedReason: unlockReason, localizedReason: unlockReason,
options: const AuthenticationOptions(stickyAuth: true), // biometricOnly: zámek telefonu (PIN/vzor zařízení) nesmí Šepot
// odemknout — obešel by Argon2id ochranu DEK slabším faktorem.
options: const AuthenticationOptions(
stickyAuth: true, biometricOnly: true),
); );
if (!ok) return; // uživatel zrušil → zůstává PIN if (!ok) return; // uživatel zrušil → zůstává PIN
await storage.unlockWithBiometrics(); await storage.unlockWithBiometrics();
@@ -222,6 +227,9 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
void _restartMessagingAfterUnlock() { void _restartMessagingAfterUnlock() {
ref.invalidate(messagingStartProvider); ref.invalidate(messagingStartProvider);
ref.invalidate(pushInitProvider); ref.invalidate(pushInitProvider);
// Zabezpečení obrazovky se čte z DB — před odemčením provider nic
// neaplikoval, teď má skutečnou hodnotu.
ref.invalidate(screenSecurityProvider);
} }
Future<void> _verifyPin() async { Future<void> _verifyPin() async {
@@ -234,12 +242,22 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
_restartMessagingAfterUnlock(); _restartMessagingAfterUnlock();
if (mounted) context.go('/chats'); if (mounted) context.go('/chats');
} on WrongPinException { } on WrongPinException {
final locked = await ref.read(storageProvider).pinLockRemaining();
if (!mounted) return;
setState(() { setState(() {
_error = context.l10n.pinWrong2; _error = locked == null
? context.l10n.pinWrong2
: context.l10n.pinLockedFor(locked.inSeconds);
_pin = ''; _pin = '';
_isVerifying = false; _isVerifying = false;
}); });
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
} on PinLockedException catch (e) {
setState(() {
_error = context.l10n.pinLockedFor(e.remaining.inSeconds);
_pin = '';
_isVerifying = false;
});
} on StateError { } on StateError {
// Wrapped DEK neexistuje — nekonzistentní stav, zpět na onboarding // Wrapped DEK neexistuje — nekonzistentní stav, zpět na onboarding
if (mounted) context.go('/onboarding'); if (mounted) context.go('/onboarding');
+190
View File
@@ -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),
),
],
),
);
}
}
+8 -3
View File
@@ -89,9 +89,13 @@ class _CallScreenState extends ConsumerState<CallScreen> {
RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
), ),
), ),
SafeArea( // Positioned.fill: bez něj si Stack dá sloupci volné rozměry a ve
child: Column( // fázi „ukončeno" (žádný ovládací řádek přes celou šířku) se sloupec
children: [ // smrskne na šířku jména a zarovná vlevo nahoru — avatar uskočí.
Positioned.fill(
child: SafeArea(
child: Column(
children: [
const SizedBox(height: 48), const SizedBox(height: 48),
if (!showVideo) ...[ if (!showVideo) ...[
SepotAvatar( SepotAvatar(
@@ -136,6 +140,7 @@ class _CallScreenState extends ConsumerState<CallScreen> {
_InCallControls(call: call, controller: controller), _InCallControls(call: call, controller: controller),
const SizedBox(height: 32), const SizedBox(height: 32),
], ],
),
), ),
), ),
if (showVideo && !call.cameraOff) if (showVideo && !call.cameraOff)
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:sepot/core/l10n/contact_name.dart';
import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/core/theme/app_theme.dart';
@@ -45,13 +46,15 @@ class ArchivedChatsScreen extends ConsumerWidget {
?.where((c) => c.publicIdentity.id == conv.contactId) ?.where((c) => c.publicIdentity.id == conv.contactId)
.firstOrNull; .firstOrNull;
final name = group?.name ?? final name = group?.name ??
contact?.displayName ?? (contact == null
context.l10n.commonUnknownContact; ? context.l10n.commonUnknownContact
: contactName(context.l10n, contact));
return ChatListTile( return ChatListTile(
conversation: conv, conversation: conv,
contactName: name, contactName: name,
myId: myId, myId: myId,
isGroup: group != null, isGroup: group != null,
isRequest: group == null && contact?.accepted == false,
onTap: () => context.push('/conversation/${conv.contactId}'), onTap: () => context.push('/conversation/${conv.contactId}'),
onLongPress: () => _actions(context, ref, conv), onLongPress: () => _actions(context, ref, conv),
); );
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:sepot/core/l10n/contact_name.dart';
import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/core/theme/app_theme.dart';
@@ -206,7 +207,7 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
String nameOf(Conversation c) => ConversationsNotifier.isNotes(c.contactId) String nameOf(Conversation c) => ConversationsNotifier.isNotes(c.contactId)
? l10n.notesTitle ? l10n.notesTitle
: groupOf(c.contactId)?.name ?? : groupOf(c.contactId)?.name ??
contactOf(c.contactId)?.displayName ?? _nameOrNull(l10n, contactOf(c.contactId)) ??
l10n.commonUnknownContact; l10n.commonUnknownContact;
return SafeArea( return SafeArea(
@@ -265,7 +266,8 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
? l10n.chatTyping ? l10n.chatTyping
: writers.length == 1 : writers.length == 1
? l10n.chatTypingNamed( ? l10n.chatTypingNamed(
(contactOf(writers.first)?.displayName ?? '?') (_nameOrNull(l10n, contactOf(writers.first)) ??
'?')
.split(' ') .split(' ')
.first) .first)
: l10n.chatTypingSeveral; : l10n.chatTypingSeveral;
@@ -275,8 +277,10 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
myId: myId, myId: myId,
isGroup: group != null, isGroup: group != null,
verified: contactOf(c.contactId)?.verified ?? false, verified: contactOf(c.contactId)?.verified ?? false,
isRequest: group == null &&
contactOf(c.contactId)?.accepted == false,
lastAuthor: group != null && last != null lastAuthor: group != null && last != null
? contactOf(last.senderId)?.displayName ? _nameOrNull(l10n, contactOf(last.senderId))
: null, : null,
highlight: hits[c], highlight: hits[c],
typingLabel: typingLabel, typingLabel: typingLabel,
@@ -685,3 +689,6 @@ class _EmptyState extends StatelessWidget {
); );
} }
} }
String? _nameOrNull(AppLocalizations l10n, Contact? c) =>
c == null ? null : contactName(l10n, c);
+11 -1
View File
@@ -18,6 +18,10 @@ class ChatListTile extends StatelessWidget {
final bool isGroup; final bool isGroup;
final bool verified; 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ý). /// Jméno autora poslední zprávy ve skupině (null = 1:1 nebo neznámý).
final String? lastAuthor; final String? lastAuthor;
@@ -40,6 +44,7 @@ class ChatListTile extends StatelessWidget {
required this.myId, required this.myId,
this.isGroup = false, this.isGroup = false,
this.verified = false, this.verified = false,
this.isRequest = false,
this.lastAuthor, this.lastAuthor,
this.highlight, this.highlight,
this.typingLabel, this.typingLabel,
@@ -54,7 +59,9 @@ class ChatListTile extends StatelessWidget {
final unread = conversation.unreadCount(myId); final unread = conversation.unreadCount(myId);
final muted = conversation.isMuted; final muted = conversation.isMuted;
final mine = lastMsg != null && lastMsg.isMine(myId); 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( return InkWell(
onTap: onTap, onTap: onTap,
@@ -245,6 +252,9 @@ class ChatListTile extends StatelessWidget {
final label = callLogLabel(l10n, call); final label = callLogLabel(l10n, call);
return (icon: label.icon, text: label.text); return (icon: label.icon, text: label.text);
} }
if (msg.notice != null) {
return (icon: Icons.gpp_maybe_rounded, text: l10n.noticePreview);
}
return switch (msg.type) { return switch (msg.type) {
MessageType.text => (icon: null, text: msg.content ?? ''), MessageType.text => (icon: null, text: msg.content ?? ''),
MessageType.image => ( MessageType.image => (
@@ -95,7 +95,9 @@ class _ContactsScreenState extends ConsumerState<ContactsScreen> {
Expanded( Expanded(
child: contactsAsync.when( child: contactsAsync.when(
data: (contacts) { data: (contacts) {
// Žádosti o zprávu kontakty nejsou — žijí jen v seznamu chatů.
final filtered = contacts.where((c) { final filtered = contacts.where((c) {
if (!c.accepted) return false;
if (_searchQuery.isEmpty) return true; if (_searchQuery.isEmpty) return true;
return c.displayName.toLowerCase().contains(_searchQuery) || return c.displayName.toLowerCase().contains(_searchQuery) ||
c.sepotId.toLowerCase().contains(_searchQuery); c.sepotId.toLowerCase().contains(_searchQuery);
@@ -5,6 +5,7 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:sepot/core/l10n/contact_name.dart';
import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/core/theme/app_theme.dart';
@@ -17,9 +18,7 @@ import 'package:sepot/providers/groups_provider.dart';
import 'package:sepot/providers/storage_provider.dart'; import 'package:sepot/providers/storage_provider.dart';
String contactLabel(BuildContext context, Contact contact) => String contactLabel(BuildContext context, Contact contact) =>
contact.displayName.isEmpty contactName(context.l10n, contact);
? context.l10n.commonUnknownContact
: contact.displayName;
void _snack(BuildContext context, String text) { void _snack(BuildContext context, String text) {
if (!context.mounted) return; if (!context.mounted) return;
@@ -12,6 +12,7 @@ import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:mime/mime.dart'; import 'package:mime/mime.dart';
import 'package:intl/intl.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/calls/call_controller.dart';
import 'package:sepot/core/constants/app_constants.dart'; import 'package:sepot/core/constants/app_constants.dart';
import 'package:sepot/core/l10n/error_messages.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/core/utils/text_fold.dart';
import 'package:sepot/features/calls/call_log_text.dart'; import 'package:sepot/features/calls/call_log_text.dart';
import 'package:sepot/features/conversation/chat_actions.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/forward_sheet.dart';
import 'package:sepot/features/conversation/widgets/attachment_content.dart'; import 'package:sepot/features/conversation/widgets/attachment_content.dart';
import 'package:sepot/features/conversation/widgets/create_poll_sheet.dart'; import 'package:sepot/features/conversation/widgets/create_poll_sheet.dart';
@@ -64,7 +67,8 @@ class ConversationScreen extends ConsumerStatefulWidget {
ConsumerState<ConversationScreen> createState() => _ConversationScreenState(); ConsumerState<ConversationScreen> createState() => _ConversationScreenState();
} }
class _ConversationScreenState extends ConsumerState<ConversationScreen> { class _ConversationScreenState extends ConsumerState<ConversationScreen>
with WidgetsBindingObserver {
final _messageController = TextEditingController(); final _messageController = TextEditingController();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
final _searchController = TextEditingController(); final _searchController = TextEditingController();
@@ -133,9 +137,43 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
_drafts = ref.read(draftsProvider.notifier); _drafts = ref.read(draftsProvider.notifier);
_restoreDraft(); _restoreDraft();
_messageController.addListener(_scheduleDraftSave); _messageController.addListener(_scheduleDraftSave);
_conversations = ref.read(conversationsProvider.notifier);
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) => _captureUnreadAndMark()); 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čí /// 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. /// za přečtenou — jinak by řádek „nové zprávy“ nikdy nebylo kam dát.
Future<void> _captureUnreadAndMark() async { Future<void> _captureUnreadAndMark() async {
@@ -185,6 +223,10 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
@override @override
void dispose() { 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(); _draftTimer?.cancel();
// Odchod z chatu uprostřed úpravy = koncept je ten odložený, ne text // Odchod z chatu uprostřed úpravy = koncept je ten odložený, ne text
// upravované zprávy. // upravované zprávy.
@@ -214,6 +256,17 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
.where((c) => c.publicIdentity.id == widget.contactId) .where((c) => c.publicIdentity.id == widget.contactId)
.firstOrNull; .firstOrNull;
final conversationAsync = ref.watch(conversationProvider(widget.contactId)); 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; final identity = ref.watch(identityProvider).valueOrNull;
// Skupinová konverzace: contactId == gid // Skupinová konverzace: contactId == gid
final group = ref.watch(groupProvider(widget.contactId)).valueOrNull; 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. // v kontaktech (roster je zakládá až s první zprávou), zkrátíme na id.
String autorJmeno(String fingerprint) { String autorJmeno(String fingerprint) {
if (fingerprint == identity?.id) return context.l10n.groupsYou; if (fingerprint == identity?.id) return context.l10n.groupsYou;
final jmeno = contacts final autor = contacts
.where((c) => c.publicIdentity.id == fingerprint) .where((c) => c.publicIdentity.id == fingerprint)
.firstOrNull .firstOrNull;
?.displayName; if (autor == null) {
if (jmeno == null) {
return 'SEP-${fingerprint.substring(0, fingerprint.length.clamp(0, 10))}'; 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; final Widget bottom;
@@ -243,6 +295,8 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
child: Text(context.l10n.chatUnblock), child: Text(context.l10n.chatUnblock),
), ),
); );
} else if (group == null && contact != null && !contact.accepted) {
bottom = MessageRequestPanel(contact: contact);
} else { } else {
bottom = _buildComposer(); bottom = _buildComposer();
} }
@@ -422,9 +476,9 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
} }
Widget _buildContactHeader(Contact? contact, {bool typing = false}) { Widget _buildContactHeader(Contact? contact, {bool typing = false}) {
final name = (contact == null || contact.displayName.isEmpty) final name = contact == null
? context.l10n.commonUnknownContact ? context.l10n.commonUnknownContact
: contact.displayName; : contactName(context.l10n, contact);
final verified = contact?.verified ?? false; final verified = contact?.verified ?? false;
return _buildHeaderFrame(children: [ return _buildHeaderFrame(children: [
_backButton(), _backButton(),
@@ -435,19 +489,27 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
// Stav ověření místo syrového ID (UX-FLOWS §3.1) // Stav ověření místo syrového ID (UX-FLOWS §3.1)
subtitle: typing subtitle: typing
? _typingLabel(context.l10n.chatTyping) ? _typingLabel(context.l10n.chatTyping)
: Text( : contact != null && !contact.accepted
verified ? Text(
? context.l10n.verifyVerified context.l10n.requestTitle,
: context.l10n.verifyUnverified, style: const TextStyle(
style: TextStyle( fontSize: 12, color: SepotColors.warning),
fontSize: 12, )
color: : Text(
verified ? SepotColors.sage : SepotColors.textSecondary, verified
), ? context.l10n.verifyVerified
), : context.l10n.verifyUnverified,
style: TextStyle(
fontSize: 12,
color: verified
? SepotColors.sage
: SepotColors.textSecondary,
),
),
onTap: () => _openProfile('/contact/${widget.contactId}'), 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( IconButton(
tooltip: context.l10n.callVoice, tooltip: context.l10n.callVoice,
icon: const Icon(Icons.call_outlined, icon: const Icon(Icons.call_outlined,
@@ -773,6 +835,27 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
? null ? null
: messages.where((m) => m.id == msg.replyToId).firstOrNull; : 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; final callLog = msg.callLog;
if (callLog != null) { if (callLog != null) {
return KeyedSubtree( return KeyedSubtree(
@@ -1695,9 +1778,7 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
String jmeno(String f) { String jmeno(String f) {
final c = contacts.where((c) => c.id == f).firstOrNull; final c = contacts.where((c) => c.id == f).firstOrNull;
if (c == null) return 'SEP-${f.substring(0, f.length.clamp(0, 10))}'; if (c == null) return 'SEP-${f.substring(0, f.length.clamp(0, 10))}';
return c.displayName.isEmpty return contactName(context.l10n, c);
? context.l10n.commonUnknownContact
: c.displayName;
} }
Widget radek(IconData icon, String label, DateTime? t, {Color? color}) { 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 _max = 84.0;
static const _trigger = 58.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)) vsync: this, duration: const Duration(milliseconds: 180))
..addListener(() => setState(() => _dx = _from * (1 - _back.value))); ..addListener(() => setState(() => _dx = _from * (1 - _back.value)));
double _dx = 0; double _dx = 0;
@@ -2159,7 +2244,7 @@ class _SwipeToReplyState extends State<_SwipeToReply>
@override @override
void dispose() { void dispose() {
_back.dispose(); _backCtl?.dispose();
super.dispose(); super.dispose();
} }
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/constants/app_constants.dart';
import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
@@ -37,8 +38,8 @@ Future<void> forwardMessage(
for (final g in groups) for (final g in groups)
if (!g.left && g.id != fromId) _Target(g.id, g.name, isGroup: true), if (!g.left && g.id != fromId) _Target(g.id, g.name, isGroup: true),
for (final c in contacts) for (final c in contacts)
if (!c.blocked && c.id != fromId) if (!c.blocked && c.accepted && c.id != fromId)
_Target(c.id, c.displayName, verified: c.verified), _Target(c.id, contactName(context.l10n, c), verified: c.verified),
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); ]..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. // Poznámky nahoře: přeposlat si něco „na později“ je nejčastější případ.
if (!ConversationsNotifier.isNotes(fromId)) { if (!ConversationsNotifier.isNotes(fromId)) {
@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.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/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/models/attachment.dart'; import 'package:sepot/models/attachment.dart';
@@ -103,9 +104,9 @@ class _ImageViewerScreenState extends ConsumerState<ImageViewerScreen> {
final contact = (ref.read(contactsProvider).valueOrNull ?? const []) final contact = (ref.read(contactsProvider).valueOrNull ?? const [])
.where((c) => c.publicIdentity.id == m.senderId) .where((c) => c.publicIdentity.id == m.senderId)
.firstOrNull; .firstOrNull;
return contact?.displayName.isNotEmpty == true return contact == null
? contact!.displayName ? context.l10n.commonUnknownContact
: context.l10n.commonUnknownContact; : contactName(context.l10n, contact);
} }
@override @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; : null;
final contactsAsync = ref.watch(contactsProvider); final contactsAsync = ref.watch(contactsProvider);
final contacts = (contactsAsync.valueOrNull ?? []) 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)) .where((c) => existing == null || !existing.memberIds.contains(c.id))
.toList(); .toList();
// Já jsem v memberIds existující skupiny; nová skupina mě přidá sama. // Já jsem v memberIds existující skupiny; nová skupina mě přidá sama.
+11 -1
View File
@@ -12,6 +12,7 @@ import 'package:local_auth/local_auth.dart';
import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/features/auth/reauth.dart';
import 'package:sepot/providers/storage_provider.dart'; import 'package:sepot/providers/storage_provider.dart';
class BiometricsTile extends ConsumerStatefulWidget { class BiometricsTile extends ConsumerStatefulWidget {
@@ -48,6 +49,14 @@ class _BiometricsTileState extends ConsumerState<BiometricsTile> {
_snack(l10n.biometricsStorageLocked); _snack(l10n.biometricsStorageLocked);
return; 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(); final auth = LocalAuthentication();
if (!await auth.isDeviceSupported()) { if (!await auth.isDeviceSupported()) {
_snack(l10n.biometricsNotAvailable); _snack(l10n.biometricsNotAvailable);
@@ -55,7 +64,8 @@ class _BiometricsTileState extends ConsumerState<BiometricsTile> {
} }
final ok = await auth.authenticate( final ok = await auth.authenticate(
localizedReason: l10n.biometricsEnrollPromptReason, localizedReason: l10n.biometricsEnrollPromptReason,
options: const AuthenticationOptions(stickyAuth: true), options: const AuthenticationOptions(
stickyAuth: true, biometricOnly: true),
); );
if (!ok) return; if (!ok) return;
await storage.enableBiometrics(); await storage.enableBiometrics();
+162 -95
View File
@@ -24,6 +24,7 @@ import 'package:sepot/providers/identity_provider.dart';
import 'package:sepot/providers/messaging_provider.dart'; import 'package:sepot/providers/messaging_provider.dart';
import 'package:sepot/providers/storage_provider.dart'; import 'package:sepot/providers/storage_provider.dart';
import 'package:sepot/providers/settings_provider.dart'; import 'package:sepot/providers/settings_provider.dart';
import 'package:sepot/features/auth/reauth.dart';
import 'package:sepot/features/settings/biometrics_tile.dart'; import 'package:sepot/features/settings/biometrics_tile.dart';
import 'package:sepot/features/settings/language_settings_tile.dart'; import 'package:sepot/features/settings/language_settings_tile.dart';
import 'package:sepot/features/settings/push_settings_tile.dart'; import 'package:sepot/features/settings/push_settings_tile.dart';
@@ -38,6 +39,9 @@ class SettingsScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final identityAsync = ref.watch(identityProvider); final identityAsync = ref.watch(identityProvider);
final l10n = context.l10n; final l10n = context.l10n;
// Nátlaková relace: nic, co by prozradilo nebo poškodilo skutečný účet
// (fráze, biometrie, PIN, záloha, smazání) — appka vypadá jako čistá.
final decoy = ref.watch(storageProvider).isDecoyMode;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -63,13 +67,15 @@ class SettingsScreen extends ConsumerWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
// Sekce Zabezpečení // Sekce Zabezpečení
_buildSectionHeader(l10n.settingsSectionSecurity), _buildSectionHeader(l10n.settingsSectionSecurity),
_buildTile( if (!decoy) ...[
icon: Icons.pin_outlined, _buildTile(
title: l10n.settingsChangePinTitle, icon: Icons.pin_outlined,
subtitle: l10n.settingsChangePinSubtitle, title: l10n.settingsChangePinTitle,
onTap: () => context.push('/change-pin'), subtitle: l10n.settingsChangePinSubtitle,
), onTap: () => context.push('/change-pin'),
const BiometricsTile(), ),
const BiometricsTile(),
],
_buildTile( _buildTile(
icon: Icons.timer_outlined, icon: Icons.timer_outlined,
title: l10n.settingsAutoLockTitle, title: l10n.settingsAutoLockTitle,
@@ -80,24 +86,25 @@ class SettingsScreen extends ConsumerWidget {
), ),
onTap: () => _nastavAutoLock(context, ref), onTap: () => _nastavAutoLock(context, ref),
), ),
_buildTile( if (!decoy)
icon: Icons.password_outlined, _buildTile(
title: l10n.settingsDecoyPinTitle, icon: Icons.password_outlined,
subtitle: ref.watch(hasDecoyPinProvider).valueOrNull == true title: l10n.settingsDecoyPinTitle,
? l10n.settingsDecoyPinSetSubtitle subtitle: ref.watch(hasDecoyPinProvider).valueOrNull == true
: l10n.settingsDecoyPinUnsetSubtitle, ? l10n.settingsDecoyPinSetSubtitle
trailing: ref.watch(hasDecoyPinProvider).valueOrNull == true : l10n.settingsDecoyPinUnsetSubtitle,
? TextButton( trailing: ref.watch(hasDecoyPinProvider).valueOrNull == true
onPressed: () => _disableDecoyPin(context, ref), ? TextButton(
child: Text(l10n.commonCancel), onPressed: () => _disableDecoyPin(context, ref),
) child: Text(l10n.commonCancel),
: null, )
onTap: () { : null,
if (ref.read(hasDecoyPinProvider).valueOrNull != true) { onTap: () {
_setDecoyPin(context, ref); if (ref.read(hasDecoyPinProvider).valueOrNull != true) {
} _setDecoyPin(context, ref);
}, }
), },
),
const SizedBox(height: 8), const SizedBox(height: 8),
// Sekce Notifikace // Sekce Notifikace
_buildSectionHeader(l10n.settingsSectionNotifications), _buildSectionHeader(l10n.settingsSectionNotifications),
@@ -145,33 +152,34 @@ class SettingsScreen extends ConsumerWidget {
subtitle: l10n.settingsScreenSecuritySubtitle, subtitle: l10n.settingsScreenSecuritySubtitle,
trailing: Switch( trailing: Switch(
value: ref.watch(screenSecurityProvider).valueOrNull ?? false, value: ref.watch(screenSecurityProvider).valueOrNull ?? false,
onChanged: (v) => onChanged: (v) => _setScreenSecurity(context, ref, v),
ref.read(screenSecurityProvider.notifier).set(v),
activeTrackColor: SepotColors.ember, activeTrackColor: SepotColors.ember,
), ),
onTap: () {}, onTap: () {},
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Sekce Záloha // Sekce Záloha
_buildSectionHeader(l10n.settingsSectionBackup), if (!decoy) ...[
_buildTile( _buildSectionHeader(l10n.settingsSectionBackup),
icon: Icons.key_outlined, _buildTile(
title: l10n.settingsSeedPhraseTitle, icon: Icons.key_outlined,
subtitle: l10n.settingsSeedPhraseSubtitle, title: l10n.settingsSeedPhraseTitle,
onTap: () => _showSeedPhrase(context, ref), subtitle: l10n.settingsSeedPhraseSubtitle,
), onTap: () => _showSeedPhrase(context, ref),
_buildTile( ),
icon: Icons.backup_outlined, _buildTile(
title: l10n.settingsBackupExportTitle, icon: Icons.backup_outlined,
subtitle: l10n.settingsBackupExportSubtitle, title: l10n.settingsBackupExportTitle,
onTap: () => _exportBackup(context, ref), subtitle: l10n.settingsBackupExportSubtitle,
), onTap: () => _exportBackup(context, ref),
_buildTile( ),
icon: Icons.restore_outlined, _buildTile(
title: l10n.settingsBackupImportTitle, icon: Icons.restore_outlined,
subtitle: l10n.settingsBackupImportSubtitle, title: l10n.settingsBackupImportTitle,
onTap: () => _importBackup(context, ref), subtitle: l10n.settingsBackupImportSubtitle,
), onTap: () => _importBackup(context, ref),
),
],
// „Web klient" byl slib funkce, která neexistuje (multi-device je // „Web klient" byl slib funkce, která neexistuje (multi-device je
// vlna 3). Apple Guideline 2.1 takové dlaždice bere jako nedodělek. // vlna 3). Apple Guideline 2.1 takové dlaždice bere jako nedodělek.
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -230,17 +238,18 @@ class SettingsScreen extends ConsumerWidget {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Smazat účet // Smazat účet
Padding( if (!decoy)
padding: const EdgeInsets.symmetric(horizontal: 16), Padding(
child: OutlinedButton( padding: const EdgeInsets.symmetric(horizontal: 16),
onPressed: () => _confirmDeleteAccount(context, ref), child: OutlinedButton(
style: OutlinedButton.styleFrom( onPressed: () => _confirmDeleteAccount(context, ref),
foregroundColor: SepotColors.error, style: OutlinedButton.styleFrom(
side: const BorderSide(color: SepotColors.error), foregroundColor: SepotColors.error,
side: const BorderSide(color: SepotColors.error),
),
child: Text(l10n.settingsDeleteAllDataButton),
), ),
child: Text(l10n.settingsDeleteAllDataButton),
), ),
),
const SizedBox(height: 48), const SizedBox(height: 48),
], ],
), ),
@@ -310,9 +319,15 @@ class SettingsScreen extends ConsumerWidget {
], ],
), ),
); );
if (vybrano != null && vybrano != ted) { if (vybrano == null || vybrano == ted || !context.mounted) return;
await ref.read(autoLockProvider.notifier).set(vybrano); // Delší interval nebo „nikdy“ = slabší zámek. Kratší projde bez ptaní.
final weakens = vybrano == 0 || (ted != 0 && vybrano > ted);
if (weakens &&
!await confirmSensitiveAction(context, ref,
reason: context.l10n.reauthReasonAutoLock)) {
return;
} }
await ref.read(autoLockProvider.notifier).set(vybrano);
} }
/// Moje profilová fotka: vybrat z galerie (oříznutí a zmenšení běží /// Moje profilová fotka: vybrat z galerie (oříznutí a zmenšení běží
@@ -509,15 +524,26 @@ class SettingsScreen extends ConsumerWidget {
final l10n = context.l10n; final l10n = context.l10n;
final identity = ref.read(identityProvider).valueOrNull; final identity = ref.read(identityProvider).valueOrNull;
if (identity == null) return; if (identity == null) return;
if (!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonBackupExport)) {
return;
}
try { try {
final bytes = await ref.read(backupServiceProvider).export(identity); final bytes = await ref.read(backupServiceProvider).export(identity);
final dir = await getTemporaryDirectory(); final dir = await getTemporaryDirectory();
final path = '${dir.path}/sepot-zaloha.sepotbackup'; final path = '${dir.path}/sepot-zaloha.sepotbackup';
await File(path).writeAsBytes(bytes); await File(path).writeAsBytes(bytes);
await Share.shareXFiles( try {
[XFile(path, mimeType: 'application/octet-stream')], await Share.shareXFiles(
subject: l10n.backupShareSubject, [XFile(path, mimeType: 'application/octet-stream')],
); subject: l10n.backupShareSubject,
);
} finally {
// Šifrovaná, ale v cache nemá co pohledávat po skončení sdílení.
try {
await File(path).delete();
} catch (_) {}
}
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -531,6 +557,10 @@ class SettingsScreen extends ConsumerWidget {
final l10n = context.l10n; final l10n = context.l10n;
final identity = ref.read(identityProvider).valueOrNull; final identity = ref.read(identityProvider).valueOrNull;
if (identity == null) return; if (identity == null) return;
if (!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonBackupImport)) {
return;
}
try { try {
final res = await FilePicker.pickFiles(withData: true); final res = await FilePicker.pickFiles(withData: true);
final data = res?.files.firstOrNull?.bytes; final data = res?.files.firstOrNull?.bytes;
@@ -554,6 +584,11 @@ class SettingsScreen extends ConsumerWidget {
Future<void> _setDecoyPin(BuildContext context, WidgetRef ref) async { Future<void> _setDecoyPin(BuildContext context, WidgetRef ref) async {
final l10n = context.l10n; final l10n = context.l10n;
if (!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonDecoyPin)) {
return;
}
if (!context.mounted) return;
final pinCtrl = TextEditingController(); final pinCtrl = TextEditingController();
final confirmCtrl = TextEditingController(); final confirmCtrl = TextEditingController();
const numberField = TextInputType.number; const numberField = TextInputType.number;
@@ -629,6 +664,10 @@ class SettingsScreen extends ConsumerWidget {
Future<void> _disableDecoyPin(BuildContext context, WidgetRef ref) async { Future<void> _disableDecoyPin(BuildContext context, WidgetRef ref) async {
final l10n = context.l10n; final l10n = context.l10n;
if (!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonDecoyPin)) {
return;
}
await ref.read(storageProvider).disableDecoyPin(); await ref.read(storageProvider).disableDecoyPin();
ref.invalidate(hasDecoyPinProvider); ref.invalidate(hasDecoyPinProvider);
if (context.mounted) { if (context.mounted) {
@@ -638,8 +677,15 @@ class SettingsScreen extends ConsumerWidget {
} }
} }
void _showSeedPhrase(BuildContext context, WidgetRef ref) async { /// Fráze = celý účet navždy. Načte se z keystore až po čerstvém ověření
/// a kopírovat ji nejde záměrně: schránku čtou klávesnice a synchronizace
/// do cloudu, takže by 24 slov opustilo telefon mimo kontrolu uživatele.
Future<void> _showSeedPhrase(BuildContext context, WidgetRef ref) async {
final l10n = context.l10n; final l10n = context.l10n;
if (!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonSeed)) {
return;
}
final storage = ref.read(storageProvider); final storage = ref.read(storageProvider);
final seed = await storage.loadSeedPhrase(); final seed = await storage.loadSeedPhrase();
@@ -654,47 +700,64 @@ class SettingsScreen extends ConsumerWidget {
if (!context.mounted) return; if (!context.mounted) return;
showDialog( await ref.read(screenSecurityProvider.notifier).enforceWhile(
context: context, () => showDialog<void>(
builder: (ctx) => AlertDialog( context: context,
title: Text(ctx.l10n.settingsSeedPhraseTitle), builder: (ctx) => AlertDialog(
content: Column( title: Text(ctx.l10n.settingsSeedPhraseTitle),
mainAxisSize: MainAxisSize.min, // 24 čipů se na malý displej nevejde — posouvat, ne přetéct.
children: [ content: SingleChildScrollView(
Text( child: Column(
ctx.l10n.settingsSeedPhraseWarning, mainAxisSize: MainAxisSize.min,
style: const TextStyle( children: [
fontSize: 13, Text(
color: SepotColors.warning, ctx.l10n.settingsSeedPhraseWarning,
), style: const TextStyle(
), fontSize: 13,
const SizedBox(height: 16), color: SepotColors.warning,
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(seed.length, (i) {
return Chip(
label: Text(
ctx.l10n.settingsSeedWordChip(i + 1, seed[i]),
style: SepotTheme.cryptoText,
), ),
backgroundColor: SepotColors.surfaceVariant, ),
side: const BorderSide(color: SepotColors.border), const SizedBox(height: 16),
); Wrap(
}), spacing: 8,
runSpacing: 8,
children: List.generate(seed.length, (i) {
return Chip(
label: Text(
ctx.l10n.settingsSeedWordChip(i + 1, seed[i]),
style: SepotTheme.cryptoText,
),
backgroundColor: SepotColors.surfaceVariant,
side: const BorderSide(color: SepotColors.border),
);
}),
),
],
),
),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(ctx.l10n.commonClose),
), ),
], ],
), ),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(ctx.l10n.commonClose),
),
],
), ),
); );
} }
/// Vypnutí ochrany obrazovky pouští obsah (i frázi) do snímků a náhledu
/// v přepínači aplikací — stejné oslabení jako delší auto-zámek.
Future<void> _setScreenSecurity(
BuildContext context, WidgetRef ref, bool enabled) async {
if (!enabled &&
!await confirmSensitiveAction(context, ref,
reason: context.l10n.reauthReasonScreenSecurity)) {
return;
}
await ref.read(screenSecurityProvider.notifier).set(enabled);
}
void _showAbout(BuildContext context) { void _showAbout(BuildContext context) {
showDialog( showDialog(
context: context, context: context,
@@ -751,6 +814,10 @@ class SettingsScreen extends ConsumerWidget {
), ),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
if (!await confirmSensitiveAction(ctx, ref,
reason: ctx.l10n.reauthReasonDeleteAccount)) {
return;
}
final messaging = ref.read(messagingServiceProvider); final messaging = ref.read(messagingServiceProvider);
// Serverová deregistrace PŘED lokálním wipe — podpis // Serverová deregistrace PŘED lokálním wipe — podpis
// požadavku potřebuje identity klíč, který wipe zahodí. // požadavku potřebuje identity klíč, který wipe zahodí.
+141
View File
@@ -1481,6 +1481,15 @@
"@pinUnlockError": { "@pinUnlockError": {
"description": "Červená chybová hláška na odemykací obrazovce při jiné chybě odemčení než špatný PIN" "description": "Červená chybová hláška na odemykací obrazovce při jiné chybě odemčení než špatný PIN"
}, },
"pinLockedFor": "Příliš mnoho pokusů. Zkus to za {seconds} s.",
"@pinLockedFor": {
"description": "Hláška na odemykací obrazovce po sérii špatných PINů; {seconds} = počet sekund do dalšího pokusu",
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"pushTitle": "Push notifikace", "pushTitle": "Push notifikace",
"@pushTitle": { "@pushTitle": {
"description": "Titulek SimpleDialog výběru režimu pushů. Stejný text jako title ListTile v nastavení (ř. 184) → stejný klíč. | Title ListTile v nastavení — stejný text a klíč jako titulek dialogu (ř. 44)" "description": "Titulek SimpleDialog výběru režimu pushů. Stejný text jako title ListTile v nastavení (ř. 184) → stejný klíč. | Title ListTile v nastavení — stejný text a klíč jako titulek dialogu (ř. 44)"
@@ -2330,5 +2339,137 @@
"type": "String" "type": "String"
} }
} }
},
"reauthTitle": "Potvrď, že jsi to ty",
"@reauthTitle": {
"description": "Nadpis výzvy k PINu před citlivou akcí v odemčené appce"
},
"reauthPinRequired": "Tahle akce potřebuje PIN. Nejdřív si ho nastav.",
"@reauthPinRequired": {
"description": "Snackbar: citlivá akce bez nastaveného PINu"
},
"reauthSetPin": "Nastavit PIN",
"@reauthSetPin": {
"description": "Akce snackbaru: nastavit PIN"
},
"reauthReasonSeed": "Zobrazení 24 slov pro obnovu účtu",
"@reauthReasonSeed": {
"description": "Důvod ve výzvě k PINu/biometrii: zobrazení obnovovací fráze"
},
"reauthReasonBackupExport": "Export zálohy",
"@reauthReasonBackupExport": {
"description": "Důvod: export zálohy"
},
"reauthReasonBackupImport": "Obnova ze zálohy",
"@reauthReasonBackupImport": {
"description": "Důvod: obnova ze zálohy"
},
"reauthReasonDecoyPin": "Změna nátlakového PINu",
"@reauthReasonDecoyPin": {
"description": "Důvod: nastavení nebo zrušení nátlakového PINu"
},
"reauthReasonAutoLock": "Prodloužení automatického zámku",
"@reauthReasonAutoLock": {
"description": "Důvod: prodloužení nebo vypnutí automatického zámku"
},
"reauthReasonScreenSecurity": "Vypnutí ochrany obrazovky",
"@reauthReasonScreenSecurity": {
"description": "Důvod: vypnutí ochrany obrazovky"
},
"reauthReasonDeleteAccount": "Smazání účtu a všech dat",
"@reauthReasonDeleteAccount": {
"description": "Důvod: smazání účtu"
},
"reauthReasonBiometrics": "Zapnutí biometrie",
"@reauthReasonBiometrics": {
"description": "Důvod: zapnutí biometrického odemčení"
},
"contactRequestName": "{name} (neznámý)",
"@contactRequestName": {
"description": "Jméno neznámého odesílatele (žádost o zprávu) všude v UI; {name} si zvolil sám",
"placeholders": {
"name": {
"type": "String"
}
}
},
"pushMessageRequest": "Žádost o zprávu od neznámého odesílatele",
"@pushMessageRequest": {
"description": "Text notifikace pro zprávu od neznámého odesílatele — bez jména, které si zvolil"
},
"requestTitle": "Žádost o zprávu",
"@requestTitle": {
"description": "Nadpis panelu / štítek žádosti o zprávu"
},
"requestBody": "„{name}“ není ve tvých kontaktech. Jméno i fotku si zvolil sám, takže nemusí jít o toho, za koho se vydává. Dokud ho nepřijmeš, neuvidí, že jsi zprávu četl, a nemůže ti volat.",
"@requestBody": {
"description": "Panel žádosti o zprávu; {name} = jméno, které si odesílatel zvolil",
"placeholders": {
"name": {
"type": "String"
}
}
},
"requestLookalikeWarning": "Pozor: stejně se jmenuje tvůj kontakt {name}. Tohle je jiný klíč, tedy jiný telefon nebo jiný člověk. Než odpovíš, ověř si to jinou cestou (zavolej, potkejte se).",
"@requestLookalikeWarning": {
"description": "Varování v panelu žádosti: jméno se plete s existujícím kontaktem {name}",
"placeholders": {
"name": {
"type": "String"
}
}
},
"requestAccept": "Přijmout",
"@requestAccept": {
"description": "Tlačítko: přijmout žádost o zprávu"
},
"requestAcceptTitle": "Přijmout jako kontakt",
"@requestAcceptTitle": {
"description": "Nadpis dialogu přijetí žádosti"
},
"requestAcceptBody": "Pojmenuj kontakt sám. Jméno, které si odesílatel zvolil, se nepřebírá automaticky.",
"@requestAcceptBody": {
"description": "Text dialogu přijetí: kontakt pojmenuje uživatel sám"
},
"requestAcceptNameLabel": "Jméno kontaktu",
"@requestAcceptNameLabel": {
"description": "Popisek pole se jménem kontaktu"
},
"requestNameEmpty": "Zadej jméno.",
"@requestNameEmpty": {
"description": "Chyba: prázdné jméno"
},
"requestNameTaken": "Tak (nebo skoro tak) se už jmenuje jiný kontakt. Zvol jméno, které je nezamění.",
"@requestNameTaken": {
"description": "Chyba: jméno se plete s jiným kontaktem"
},
"noticeIdentityChanged": "Bezpečnostní kód kontaktu {name} se změnil. Pod jeho adresou se ozval jiný klíč: nový telefon, přeinstalace, nebo podvrh. Ověření je zrušené. Než pošleš něco citlivého, porovnejte kód znovu.",
"@noticeIdentityChanged": {
"description": "Upozornění v konverzaci: kontaktu {name} se změnil identity klíč",
"placeholders": {
"name": {
"type": "String"
}
}
},
"noticeLookalikeRequest": "Neznámý odesílatel si dal jméno „{claimed}“, které se plete s kontaktem {name}. Tento kontakt to není, zpráva přišla z jiného klíče. Najdeš ji v chatech jako žádost o zprávu.",
"@noticeLookalikeRequest": {
"description": "Upozornění v konverzaci existujícího kontaktu {name}: neznámý si zvolil podobné jméno {claimed}",
"placeholders": {
"claimed": {
"type": "String"
},
"name": {
"type": "String"
}
}
},
"noticeVerifyAction": "Ověřit kód",
"@noticeVerifyAction": {
"description": "Tlačítko u upozornění na změnu kódu"
},
"noticePreview": "Bezpečnostní upozornění",
"@noticePreview": {
"description": "Náhled v seznamu chatů, když je poslední položkou bezpečnostní upozornění"
} }
} }
+28 -1
View File
@@ -315,6 +315,7 @@
"pinUnlockWithBiometrics": "Mit Biometrie entsperren", "pinUnlockWithBiometrics": "Mit Biometrie entsperren",
"pinWrong2": "Falsche PIN", "pinWrong2": "Falsche PIN",
"pinUnlockError": "Fehler beim Entsperren", "pinUnlockError": "Fehler beim Entsperren",
"pinLockedFor": "Zu viele Versuche. Versuche es in {seconds} s erneut.",
"pushTitle": "Push-Benachrichtigungen", "pushTitle": "Push-Benachrichtigungen",
"pushModeOffDesc": "Nachrichten werden erst beim Öffnen der App abgerufen.", "pushModeOffDesc": "Nachrichten werden erst beim Öffnen der App abgerufen.",
"pushModeUp": "UnifiedPush (ohne Google)", "pushModeUp": "UnifiedPush (ohne Google)",
@@ -485,5 +486,31 @@
"profilePhotoPick": "Foto auswählen", "profilePhotoPick": "Foto auswählen",
"profilePhotoRemove": "Foto entfernen", "profilePhotoRemove": "Foto entfernen",
"profilePhotoHint": "Nur deine Chatpartner sehen es — Ende-zu-Ende-verschlüsselt wie Nachrichten. Standort und Fotodaten werden nicht gesendet.", "profilePhotoHint": "Nur deine Chatpartner sehen es — Ende-zu-Ende-verschlüsselt wie Nachrichten. Standort und Fotodaten werden nicht gesendet.",
"profilePhotoFailed": "Foto konnte nicht gesetzt werden: {error}" "profilePhotoFailed": "Foto konnte nicht gesetzt werden: {error}",
"reauthTitle": "Bestätige, dass du es bist",
"reauthPinRequired": "Diese Aktion erfordert eine PIN. Lege zuerst eine fest.",
"reauthSetPin": "PIN festlegen",
"reauthReasonSeed": "24 Wiederherstellungswörter anzeigen",
"reauthReasonBackupExport": "Backup exportieren",
"reauthReasonBackupImport": "Aus Backup wiederherstellen",
"reauthReasonDecoyPin": "Notfall-PIN ändern",
"reauthReasonAutoLock": "Automatische Sperre verlängern",
"reauthReasonScreenSecurity": "Bildschirmschutz ausschalten",
"reauthReasonDeleteAccount": "Konto und alle Daten löschen",
"reauthReasonBiometrics": "Biometrie einschalten",
"contactRequestName": "{name} (unbekannt)",
"pushMessageRequest": "Nachrichtenanfrage von unbekanntem Absender",
"requestTitle": "Nachrichtenanfrage",
"requestBody": "„{name}“ ist nicht in deinen Kontakten. Name und Foto hat die Person selbst gewählt, sie ist also vielleicht nicht, wer sie vorgibt zu sein. Bis du annimmst, sieht sie nicht, dass du die Nachricht gelesen hast, und kann dich nicht anrufen.",
"requestLookalikeWarning": "Achtung: Dein Kontakt {name} heißt genauso. Das ist ein anderer Schlüssel, also ein anderes Telefon oder eine andere Person. Prüfe das auf anderem Weg, bevor du antwortest (anrufen, persönlich treffen).",
"requestAccept": "Annehmen",
"requestAcceptTitle": "Als Kontakt annehmen",
"requestAcceptBody": "Benenne den Kontakt selbst. Der vom Absender gewählte Name wird nicht automatisch übernommen.",
"requestAcceptNameLabel": "Kontaktname",
"requestNameEmpty": "Gib einen Namen ein.",
"requestNameTaken": "Ein anderer Kontakt heißt schon so (oder fast so). Wähle einen Namen, der nicht verwechselt werden kann.",
"noticeIdentityChanged": "Der Sicherheitscode von {name} hat sich geändert. Unter dieser Adresse meldet sich ein anderer Schlüssel: neues Telefon, Neuinstallation oder Fälschung. Die Verifizierung wurde aufgehoben. Vergleicht den Code erneut, bevor du etwas Sensibles sendest.",
"noticeLookalikeRequest": "Ein unbekannter Absender nennt sich „{claimed}“, was nach deinem Kontakt {name} aussieht. Es ist nicht dieser Kontakt, die Nachricht kam von einem anderen Schlüssel. Du findest sie in den Chats als Nachrichtenanfrage.",
"noticeVerifyAction": "Code prüfen",
"noticePreview": "Sicherheitshinweis"
} }

Some files were not shown because too many files have changed in this diff Show More