11 Commits

Author SHA1 Message Date
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
129 changed files with 1951 additions and 309 deletions
+53
View File
@@ -4,6 +4,59 @@ Všechny podstatné změny v aplikaci Šepot. Formát dle
[Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
verzování dle [ajtak standardu](#verzování).
## [Nevydáno]
## [1.2.2] – 2026-10-11
### Opraveno
- **Přečtený chat se dál ukazoval jako nepřečtený.** Zpráva, která přišla do
otevřeného chatu, zůstala po návratu do seznamu tučně s odznakem; teď se
přečte hned, když je chat na obrazovce (a appka v popředí). Stažení přílohy
nebo dodatečné doručení už nevrátí přečtenou zprávu mezi nepřečtené.
- Gesto „odpověď tahem“ při zavírání chatu v ladicím buildu padalo na
vytvoření animace nad zrušeným prvkem.
- Na iPhonu hlásila dlaždice Push v Nastavení chybu (volala UnifiedPush,
který existuje jen na Androidu).
### Změněno
- **Nová ikona aplikace:** Š, které se ve spodní části rozpadá do teček.
## [1.2.1] – 2026-10-09
### Opraveno
- **Hovory se nedaly přijmout.** Příchozí signalizace hovoru se zahazovala dřív,
než se dostala k obsluze hovoru; volaný jen „zazvonil“ z budíčku a pak nic.
- **Obrazovka hovoru po zavěšení:** avatar a jméno uskakovaly vlevo nahoru.
- **Obnova ze 24 slov na novém telefonu** končila nekonečným čekáním — server
odmítal už existující identitu. Nové zařízení ji teď převezme podpisem.
- **Tichá ztráta zpráv:** úklid mizících zpráv mohl přepsat čerstvě přijatou
zprávu; automatický zámek během zpracování zprávy ji mohl nenávratně zahodit;
souběžné šifrování pro téhož příjemce dávalo obálky, které druhá strana
zahodila. Všechny tři cesty opraveny.
- **Velká fronta po delším výpadku** (stovky zpráv) se už neodpojuje v půlce
a server ji po osmi pokusech nemaže.
- **Nátlakový PIN** otevíral relaci se skutečnou identitou, frází i přístupem ke
smazání účtu. Teď je izolovaná: náhodná identita, žádná síť, žádný zásah do
klíčů; smazání v ní nechá skutečný účet být.
- Zvonění z budíčku bez skutečné nabídky končí po 12 s; server budíčky hovoru
omezuje na cíl, takže cizí člověk se znalostí adresy telefon nerozezvoní
pořád dokola.
- Dodatečné odeslání z fronty neztrácí anketu ani zmínky; budíček při
„připojeno“ bez provozu obnoví spojení.
### Bezpečnost
- Hádání PINu: po pěti chybách prodleva 30 s, dál dvojnásobná až 15 minut.
- Nátlakový a skutečný PIN se ověřují jednou derivací — čas odemčení neprozradí,
který byl zadán.
- Biometrie jen biometricky (zámek telefonu appku neodemkne); zabezpečení
obrazovky je výchozí zapnuté a po odemčení skutečně platí; na iOS klíče
nepřecházejí do iCloud Keychain ani záloh; filtr proti dotykům přes překryvná okna.
- Server: doplňování jednorázových klíčů a rotace podepsaných klíčů
(`POST /v1/keys`), omezení výdeje jednorázových klíčů na adresu, chyby v logu
bez adres push endpointů, připnutí DNS u budíčků, globální strop příloh,
limity délek, `/v1/ready`, uvolnění spojení při vypnutí, revize a zdroj
v `/v1/server-info`.
## [1.2.0] – 2026-10-01
### Nový vzhled
+4 -4
View File
@@ -4,7 +4,7 @@
The design goal is **metadata minimization with a public threat model** — never "zero metadata". The relay is deliberately dumb: it cannot read message content, does not learn who sent a message, and does not log IP addresses. What it *does* see is documented honestly below.
> Status: pre-1.0. The Android client is the primary target; iOS builds run through TestFlight. See [`CHANGELOG.md`](CHANGELOG.md).
> Status: 1.2.x. Android is on Google Play (production 1.1.2, 1.2.0 in internal testing); iOS builds run through TestFlight. See [`CHANGELOG.md`](CHANGELOG.md).
## Features
@@ -14,7 +14,7 @@ The design goal is **metadata minimization with a public threat model** — neve
- **Group chats** — up to 128 members, pairwise fan-out so every member gets full forward secrecy; the relay never learns a group exists.
- **Attachments** — images and files are encrypted client-side (AES-256-GCM) and uploaded as opaque blobs with a TTL; image metadata (EXIF/GPS) is stripped on send.
- **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers).
- **Push without Google by default** — UnifiedPush/ntfy; FCM (Android) and APNs (iOS) are opt-in. A push wake-up never carries content, sender, or count.
- **Push with or without Google** — UnifiedPush/ntfy when a distributor is installed, otherwise FCM on Android (default since 1.2.0, can be switched off) and APNs on iOS. A push wake-up never carries content, sender, or count.
- **Tor transport** (Android, via Orbot) — routes relay traffic over a `.onion` to hide the client IP. Off by default.
- **Five languages** — Czech, English, German, Russian, Italian. The UI follows the system language and can be overridden in Settings.
@@ -71,10 +71,10 @@ UI strings live in `app/lib/l10n/app_*.arb` (`cs`, `en`, `de`, `ru`, `it`) and a
## Security
Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **bezpecnost@sepot.cz** rather than opening a public issue.
Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **security@sepot.cz** rather than opening a public issue — see [`SECURITY.md`](SECURITY.md) and [`/.well-known/security.txt`](https://sepot.cz/.well-known/security.txt).
## License
Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](LICENSE).
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository, F-Droid, direct APK). That permission covers only Šepot's own code. The bundled Signal `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository and the direct APK releases). That permission covers only Šepot's own code. The bundled Signal `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
+34
View File
@@ -0,0 +1,34 @@
# Security policy
## Reporting a vulnerability
Please report security issues privately — do not open a public issue.
- E-mail: **security@sepot.cz**
- Web form and policy: <https://sepot.cz/zodpovedne-hlaseni>
- Machine-readable contact: <https://sepot.cz/.well-known/security.txt>
You will get an acknowledgement within 72 hours and a status update at least
every 14 days until the issue is resolved. We aim to ship a fix within 30 days
for issues that affect message confidentiality, authentication, or the relay's
metadata guarantees, and we credit reporters in the changelog unless they
prefer otherwise.
## Scope
- the Flutter client (`app/`), including the Signal protocol integration,
local storage (SQLCipher, PIN/biometrics, duress session) and calls;
- the relay (`rust/sepot-relay/`): authentication, routing, push wake-ups,
attachments, rate limiting, TURN credentials;
- the public relay at `relay.sepot.cz` and the website `sepot.cz`.
Please do not run denial-of-service tests, mass registrations or scans
against the public relay; a local instance (`deploy/relay/`) is enough to
reproduce almost everything.
## What the relay can and cannot see
The threat model is documented in the README: the relay sees routing tokens,
message sizes, timing and push tokens, never content or sender identity.
Reports that demonstrate the relay learning more than that are the ones we
care about most.
+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
complete corresponding source available under the AGPL through channels
without app-store restrictions (this public repository, F-Droid, direct
without app-store restrictions (this public repository, direct
APK). A written clarification for the current Rust `libsignal` has been
requested from Signal (tracking: <https://github.com/signalapp/libsignal/issues/684>).
@@ -1,5 +1,6 @@
package cz.sepot.sepot
import android.content.pm.ApplicationInfo
import android.os.Bundle
import android.view.WindowManager
import io.flutter.embedding.android.FlutterFragmentActivity
@@ -7,10 +8,20 @@ import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE,
)
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací.
// Výchozí stav; Dart přepínač „Zabezpečení obrazovky" ho může po
// odemčení vypnout (default zapnuto, jako u Signalu).
// Debug build: screenshoty povolené kvůli QA na emulátoru (adb screencap
// by u FLAG_SECURE okna selhal); release vždy chráněný.
val debuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
if (!debuggable) {
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE,
)
}
// Tapjacking: dotyky přes překryvné okno jiné appky (částečně
// průhledný overlay nad PIN klávesnicí) se zahodí.
window.decorView.filterTouchesWhenObscured = true
}
}
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"?>
<resources>
<color name="ic_launcher_background">#0a0e17</color>
<color name="ic_launcher_background">#0B0C12</color>
</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).
# Zdroj: sepot-web/koncept/logo/nokturno-ikona-B2.png; regenerace:
# Launcher icons: the "Š that fades into dots" mark (variant B, 2026-10).
# Source: assets/icon/icon-1024.png (full) and maskable-fg-512.png (adaptive foreground); regenerate with:
# dart run flutter_launcher_icons
flutter_launcher_icons:
android: true
ios: true
image_path: assets/icon/icon-1024.png
adaptive_icon_background: "#0a0e17"
adaptive_icon_background: "#0B0C12"
adaptive_icon_foreground: assets/icon/maskable-fg-512.png
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

+3 -1
View File
@@ -289,7 +289,9 @@ class _LoadingScreenState extends ConsumerState<_LoadingScreen> {
final storage = ref.read(storageProvider);
final legacyPin = await storage.hasLegacyPin();
await storage.unlockWithoutPin();
if (mounted) context.go(legacyPin ? '/pin-setup' : '/chats');
if (!mounted) return;
ref.invalidate(screenSecurityProvider);
context.go(legacyPin ? '/pin-setup' : '/chats');
}
}
+26 -3
View File
@@ -236,9 +236,15 @@ class CallController extends Notifier<CallState> {
_ringId = pushRing.id;
if (pushRing.accepted) {
await accept();
} else if (CallRinger.declinedEarly.remove(pushRing.id)) {
await reject();
return;
}
if (CallRinger.declinedEarly.remove(pushRing.id)) {
await reject();
return;
}
// Spárováno s nabídkou: aby grace z `ringFromPush` zvonění neukončila,
// nahradí se jménem volajícího (bez značky push).
await _claimPushRing(pushRing.id, name, video);
return;
}
final foreground =
@@ -249,6 +255,15 @@ class CallController extends Notifier<CallState> {
}
}
/// Převezme zvonění z budíčku: jméno volajícího místo „Šepot" a bez
/// značky push (grace v [CallRinger.ringFromPush] ho pak nechá být).
Future<void> _claimPushRing(String pushId, String name, bool video) async {
if (await CallRinger.isAccepted(pushId)) return; // uživatel už zvedl
await CallRinger.end(pushId);
_ringId = state.callId;
await CallRinger.ringFor(callId: state.callId!, name: name, video: video);
}
void _onRingAccept(String id) {
if (id == _ringId && state.phase == CallPhase.incoming) {
unawaited(accept());
@@ -389,11 +404,19 @@ class CallController extends Notifier<CallState> {
return pc;
}
/// STUN bez údajů: coturn na témž hostiteli odpovídá na STUN bez
/// přihlášení, takže i když relay TURN údaje nevydá (503, timeout),
/// jde aspoň přímé spojení přes NAT. Dřív byl seznam prázdný = jen host
/// kandidáti = hovor jen v jedné LAN.
static const _stunFallback = {
'urls': ['stun:turn.sepot.cz:3478'],
};
/// STUN i TURN běží na stejném serveru; bez údajů z relaye aspoň STUN
/// (přímé spojení, když obě strany nejsou za symetrickým NATem).
Future<List<Map<String, dynamic>>> _iceServers() async {
final turn = await ref.read(messagingServiceProvider).turnCredentials();
if (turn == null || turn.urls.isEmpty) return const [];
if (turn == null || turn.urls.isEmpty) return const [_stunFallback];
return [
{
'urls': turn.urls,
+50 -6
View File
@@ -22,6 +22,13 @@ class CallRinger {
/// Jak dlouho zvoní — stejné jako expirace budíčku na relayi.
static const ringMillis = 45000;
/// Zvonění z budíčku, ke kterému do té doby nedorazila nabídka, se
/// ukončí samo. Relay budíček hovoru nijak neověřuje (sealed sender),
/// takže kdokoli se znalostí adresy by jinak nechal telefon zvonit
/// 45 s na každou obálku s hintem CALL. Nabídka od
/// skutečného volajícího dorazí do pár sekund po připojení.
static const pushRingGrace = Duration(seconds: 12);
static StreamSubscription<CallEvent?>? _sub;
/// Id zvonění, která uživatel odmítl dřív, než dorazila nabídka.
@@ -65,12 +72,41 @@ class CallRinger {
_params(id: callId, name: name, video: video));
/// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme.
static Future<void> ringFromPush() => FlutterCallkitIncoming
.showCallkitIncoming(_params(
id: const Uuid().v4(),
name: _appName,
video: false,
extra: {pushMarker: true}));
/// Vrací až po [pushRingGrace]: pokud se zvonění do té doby nespárovalo
/// s nabídkou (přijetí nebo ukončení ho z aktivních hovorů odebere, nebo
/// ho [CallController] převezme), tiše se ukončí. Background isolate FCM
/// handleru tím zůstane naživu přesně tak dlouho, jak je potřeba.
static Future<void> ringFromPush() async {
final id = const Uuid().v4();
await FlutterCallkitIncoming.showCallkitIncoming(_params(
id: id, name: _appName, video: false, extra: {pushMarker: true}));
await Future<void>.delayed(pushRingGrace);
await endIfUnmatched(id);
}
/// iOS: zvonění z VoIP pushe zakládá nativní AppDelegate (CallKit musí
/// zazvonit hned), takže grace z [ringFromPush] tam neběží. Po startu
/// Dartu se proto každé nepřevzaté zvonění z budíčku ohlídá odsud.
static Future<void> expireStalePushRings() async {
final calls = await FlutterCallkitIncoming.activeCalls();
for (final call in calls) {
if (call.extra?[pushMarker] == true && !call.isAccepted) {
final id = call.id;
unawaited(Future<void>.delayed(pushRingGrace, () => endIfUnmatched(id)));
}
}
}
/// Ukončí zvonění z budíčku, které nikdo nepřevzal (bez záznamu
/// o zmeškaném hovoru — nebylo od koho).
static Future<void> endIfUnmatched(String id) async {
final calls = await FlutterCallkitIncoming.activeCalls();
for (final call in calls) {
if (call.id == id && call.extra?[pushMarker] == true && !call.isAccepted) {
await FlutterCallkitIncoming.endCall(id);
}
}
}
/// Zvonění z budíčku, které čeká na nabídku (nejnovější), a jestli ho
/// uživatel už přijal — typicky na zamčené obrazovce, než appka naběhla.
@@ -84,6 +120,14 @@ class CallRinger {
return null;
}
static Future<bool> isAccepted(String id) async {
final calls = await FlutterCallkitIncoming.activeCalls();
for (final call in calls) {
if (call.id == id) return call.isAccepted;
}
return false;
}
static Future<void> connected(String id) =>
FlutterCallkitIncoming.setCallConnected(id);
+66 -3
View File
@@ -53,16 +53,20 @@ class DekWrap {
int memoryKiB = defaultMemoryKiB,
int iterations = defaultIterations,
int parallelism = defaultParallelism,
Uint8List? salt,
}) async {
if (dek.length != 32) {
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
}
_enforceFloor(memoryKiB, iterations);
final salt = _randomBytes(16);
// Volitelná sdílená sůl: nátlakový blob se stejnou solí a parametry
// jako skutečný odemyká jedinou derivací (viz unwrapFirstMatching) —
// bez ní by decoy PIN trval 2× déle a šel poznat stopkami.
final useSalt = salt ?? _randomBytes(16);
final unlockKey = await _deriveUnlockKey(
pin: pin,
salt: salt,
salt: useSalt,
memoryKiB: memoryKiB,
iterations: iterations,
parallelism: parallelism,
@@ -80,13 +84,72 @@ class DekWrap {
'$memoryKiB',
'$iterations',
'$parallelism',
base64Encode(salt),
base64Encode(useSalt),
base64Encode(box.nonce),
base64Encode(box.cipherText),
base64Encode(box.mac.bytes),
].join(':');
}
/// Sůl použitá v zabaleném blobu (pro sdílení s nátlakovým blobem).
static Uint8List saltOf(String wrapped) {
final parts = wrapped.split(':');
if (parts.length != 8 || parts[0] != _version) {
throw FormatException('Unknown wrapped DEK format', wrapped);
}
return Uint8List.fromList(base64Decode(parts[4]));
}
/// Zkusí PIN proti několika blobům (skutečný + nátlakový) a vrátí index
/// prvního, který sedí, s rozbaleným DEK. Argon2id se počítá jen jednou
/// pro každou odlišnou kombinaci parametrů a soli, takže u blobů se
/// sdílenou solí stojí odemčení stejně, ať sedí kterýkoli — čas
/// odemčení neprozradí, který PIN byl zadán. Nic nesedí →
/// [WrongPinException].
static Future<(int, Uint8List)> unwrapFirstMatching({
required List<String> wrapped,
required String pin,
}) async {
final parsed = <List<String>>[];
for (final w in wrapped) {
final parts = w.split(':');
if (parts.length != 8 || parts[0] != _version) {
throw FormatException('Unknown wrapped DEK format', w);
}
_enforceFloor(int.parse(parts[1]), int.parse(parts[2]));
parsed.add(parts);
}
final keys = <String, Uint8List>{};
for (final parts in parsed) {
final group = parts.sublist(1, 5).join(':');
keys[group] ??= await _deriveUnlockKey(
pin: pin,
salt: base64Decode(parts[4]),
memoryKiB: int.parse(parts[1]),
iterations: int.parse(parts[2]),
parallelism: int.parse(parts[3]),
);
}
final aes = AesGcm.with256bits();
for (var i = 0; i < parsed.length; i++) {
final parts = parsed[i];
try {
final dek = await aes.decrypt(
SecretBox(
base64Decode(parts[6]),
nonce: base64Decode(parts[5]),
mac: Mac(base64Decode(parts[7])),
),
secretKey: SecretKey(keys[parts.sublist(1, 5).join(':')]!),
);
return (i, Uint8List.fromList(dek));
} on SecretBoxAuthenticationError {
continue;
}
}
throw const WrongPinException();
}
/// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí
/// [WrongPinException]; na neplatném formátu [FormatException].
static Future<Uint8List> unwrap({
+86
View File
@@ -81,6 +81,32 @@ class SepotSealedMessage {
}
/// Jeden one-time prekey k publikaci na relay.
/// Nový signed prekey k nahrání na relay (rotace).
class SepotSignedPreKey {
SepotSignedPreKey({
required this.id,
required this.publicKey,
required this.signature,
});
final int id;
final Uint8List publicKey;
final Uint8List signature;
}
/// Nový last-resort Kyber prekey k nahrání na relay.
class SepotKyberPreKey {
SepotKyberPreKey({
required this.id,
required this.publicKey,
required this.signature,
});
final int id;
final Uint8List publicKey;
final Uint8List signature;
}
class SepotOneTimePreKey {
SepotOneTimePreKey({required this.id, required this.publicKey});
@@ -420,4 +446,64 @@ class SepotCrypto {
}
return result;
}
/// Nový signed prekey (rotace). Starý zůstává ve store — relace založené
/// z bundle těsně před rotací ho ještě potřebují.
Future<SepotSignedPreKey> generateSignedPreKey({
required int id,
required int timestampMs,
}) async {
final identityPrivate = PrivateKey.deserialize(
bytes: _identity.privateKey.toList(),
);
final private = PrivateKey.generate();
final public = private.getPublicKey();
final signature = identityPrivate.sign(
message: public.serialize().toList(),
);
await _stores.signedPreKey.storeSignedPreKey(
id,
SignedPreKeyRecord(
id: id,
publicKey: public,
privateKey: private,
signature: signature.toList(),
timestamp: BigInt.from(timestampMs),
),
);
return SepotSignedPreKey(
id: id,
publicKey: Uint8List.fromList(public.serialize().toList()),
signature: Uint8List.fromList(signature.toList()),
);
}
/// Nový last-resort Kyber prekey (rotace PQXDH kotvy).
Future<SepotKyberPreKey> generateKyberPreKey({
required int id,
required int timestampMs,
}) async {
final identityPrivate = PrivateKey.deserialize(
bytes: _identity.privateKey.toList(),
);
final keyPair = KyberKeyPair.generate();
final public = keyPair.getPublicKey();
final signature = identityPrivate.sign(
message: public.serialize().toList(),
);
await _stores.kyberPreKey.storeKyberPreKey(
id,
KyberPreKeyRecord.create(
id: id,
keyPair: keyPair,
signature: signature.toList(),
timestamp: BigInt.from(timestampMs),
),
);
return SepotKyberPreKey(
id: id,
publicKey: Uint8List.fromList(public.serialize().toList()),
signature: Uint8List.fromList(signature.toList()),
);
}
}
+96 -9
View File
@@ -64,9 +64,17 @@ class RelayClient {
final _deliverController = StreamController<pb.Envelope>.broadcast();
final _receiptController = StreamController<pb.Receipt>.broadcast();
final _turnController = StreamController<pb.TurnCredentials>.broadcast();
final _prekeysLowController = StreamController<int>.broadcast();
var _state = RelayConnectionState.disconnected;
/// Kdy naposled přišel aplikační rámec od relaye.
DateTime _lastFrameAt = DateTime.fromMillisecondsSinceEpoch(0);
static const _staleAfter = Duration(seconds: 20);
/// Relay hlásí docházející one-time prekeys (zbývající počet).
Stream<int> get prekeysLow => _prekeysLowController.stream;
RelayConnectionState get state => _state;
Stream<RelayConnectionState> get stateStream => _stateController.stream;
@@ -100,9 +108,18 @@ class RelayClient {
required List<SepotOneTimePreKey> oneTimePreKeys,
required Uint8List deliveryAddress,
}) async {
// Důkaz držení identity klíče: obnova ze 24 slov na novém telefonu
// narazí na relayi na už existující identitu (409) a bez tohohle
// podpisu by ji nikdy nepřevzala. Starší relay hlavičku
// ignoruje.
final proofTs = DateTime.now().millisecondsSinceEpoch;
final proof = _crypto.sign(utf8.encode('POST /v1/register\n$proofTs'));
final resp = await _transport.httpClient().post(
Uri.parse('$baseUrl/v1/register'),
headers: {'content-type': 'application/json'},
headers: {
'content-type': 'application/json',
'x-sepot-identity-proof': '$proofTs.${base64Encode(proof)}',
},
body: jsonEncode({
'identity_key': base64Encode(_crypto.identityPublicKey),
'device_id': 1,
@@ -144,6 +161,49 @@ class RelayClient {
_crypto.bindAccount(accountId);
}
/// POST /v1/keys — doplnění one-time prekeys a rotace signed / Kyber
/// prekey. Vrací počet OTP na relayi po nahrání.
Future<int> uploadKeys({
SepotSignedPreKey? signedPreKey,
SepotKyberPreKey? kyberPreKey,
List<SepotOneTimePreKey> oneTimePreKeys = const [],
}) async {
final deviceUuid = _requireDevice();
final ts = DateTime.now().millisecondsSinceEpoch;
final signature = _crypto.sign(utf8.encode('POST /v1/keys\n$ts'));
final resp = await _transport.httpClient().post(
Uri.parse('$baseUrl/v1/keys'),
headers: {
'content-type': 'application/json',
'x-sepot-auth': '$deviceUuid.$ts.${base64Encode(signature)}',
},
body: jsonEncode({
if (signedPreKey != null)
'signed_prekey': {
'key_id': signedPreKey.id,
'public_key': base64Encode(signedPreKey.publicKey),
'signature': base64Encode(signedPreKey.signature),
},
if (kyberPreKey != null)
'kyber_prekey': {
'key_id': kyberPreKey.id,
'public_key': base64Encode(kyberPreKey.publicKey),
'signature': base64Encode(kyberPreKey.signature),
'is_last_resort': true,
},
'one_time_prekeys': [
for (final otp in oneTimePreKeys)
{'key_id': otp.id, 'public_key': base64Encode(otp.publicKey)},
],
}),
);
if (resp.statusCode != 200) {
throw RelayException('prekey upload failed', resp.statusCode, resp.body);
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
return (body['one_time_prekeys'] as num?)?.toInt() ?? 0;
}
/// GET /v1/keys/{addr} — bundle protistrany (spotřebuje 1 OTP).
Future<SepotPreKeyBundle> fetchBundle(Uint8List deliveryAddress) async {
final hexAddr = deliveryAddress
@@ -280,6 +340,20 @@ class RelayClient {
_setState(RelayConnectionState.disconnected);
}
/// Budíček od relaye znamená, že nás nevidí jako živé. Spojení, které
/// hlásí „připojeno", ale [_staleAfter] nic nepřijalo, je po změně sítě
/// nejspíš zombie — zavřít a navázat hned, ne až po ping timeoutu
/// (pojistka proti zombie spojení na straně appky). Jinak jen [start].
void kick() {
if (_state == RelayConnectionState.connected &&
DateTime.now().difference(_lastFrameAt) > _staleAfter) {
_reconnectDelayS = 1;
_onDisconnected();
return;
}
start();
}
Future<void> _teardownChannel() async {
await _wsSub?.cancel();
_wsSub = null;
@@ -313,6 +387,7 @@ class RelayClient {
_wsSub = channel.stream.listen(
(data) {
final frame = pb.ServerFrame.fromBuffer(data as List<int>);
_lastFrameAt = DateTime.now();
if (!authenticated) {
if (frame.hasChallenge()) {
final signature = _crypto.sign([
@@ -345,6 +420,10 @@ class RelayClient {
if (!_turnController.isClosed) {
_turnController.add(frame.turnCredentials);
}
} else if (frame.hasPrekeysLow()) {
if (!_prekeysLowController.isClosed) {
_prekeysLowController.add(frame.prekeysLow.remaining);
}
}
},
onDone: _onDisconnected,
@@ -389,14 +468,21 @@ class RelayClient {
final receiptFuture = _receiptController.stream
.firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid))
.timeout(timeout);
_send(pb.ClientFrame(
envelope: pb.Envelope(
dstAddress: dstAddress,
msgUuid: msgUuid,
contentHint: hint,
payload: payload,
),
));
try {
_send(pb.ClientFrame(
envelope: pb.Envelope(
dstAddress: dstAddress,
msgUuid: msgUuid,
contentHint: hint,
payload: payload,
),
));
} catch (_) {
// Odeslání selhalo (kanál zavřený) — čekající future by za 10 s
// hodil neodchycený TimeoutException do zvonku chyb.
receiptFuture.ignore();
rethrow;
}
return receiptFuture;
}
@@ -455,6 +541,7 @@ class RelayClient {
_transport.close();
await _stateController.close();
await _turnController.close();
await _prekeysLowController.close();
await _deliverController.close();
await _receiptController.close();
}
+2
View File
@@ -87,6 +87,8 @@ class PushBootstrap {
if (Platform.isIOS) {
ApnsBridge.onWake = _wake;
ApnsBridge.ensureHandler();
// Zvonění z VoIP pushe bez nabídky nesmí zvonit celých 45 s.
unawaited(CallRinger.expireStalePushRings());
} else {
await UnifiedPush.initialize(
onNewEndpoint: _onNewEndpoint,
+17 -4
View File
@@ -52,7 +52,15 @@ class PushService {
/// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.)
bool get canWake => _messaging.isStarted;
void wake() => _messaging.onPushWake();
/// Budíček: běžící messaging připojí WS; po auto-locku (zamčená DB) aspoň
/// generická notifikace bez obsahu, ať se uživatel o zprávě dozví.
void wake() {
if (canWake) {
_messaging.onPushWake();
} else {
unawaited(PushNotifier.showWakeNotification());
}
}
void handleNewEndpoint(String url) {
_messaging.registerPush(_registration(
@@ -130,10 +138,14 @@ class PushService {
}
/// Seznam nainstalovaných distributorů (pro výběr v nastavení).
Future<List<String>> distributors() => UnifiedPush.getDistributors();
/// UnifiedPush existuje jen na Androidu; jinde by volání pluginu vyhodilo
/// UnimplementedError (iOS: dlaždice Push v Nastavení).
Future<List<String>> distributors() async =>
Platform.isAndroid ? UnifiedPush.getDistributors() : const [];
/// Aktuálně uložený distributor (null = push vypnutý / nevybraný).
Future<String?> currentDistributor() => UnifiedPush.getDistributor();
Future<String?> currentDistributor() async =>
Platform.isAndroid ? UnifiedPush.getDistributor() : null;
/// Vybere konkrétního distributora a zaregistruje se u něj.
Future<void> useDistributor(String distributor) async {
@@ -150,7 +162,7 @@ class PushService {
}
Future<bool> get isEnabled async =>
(await UnifiedPush.getDistributor()) != null;
Platform.isAndroid && (await UnifiedPush.getDistributor()) != null;
// ── Režimy (off / up / fcm) ─────────────────────────────────────────
@@ -159,6 +171,7 @@ class PushService {
Future<String> currentMode() async {
final mode = await PushBootstrap.readMode();
if (mode != 'off') return mode;
if (!Platform.isAndroid) return mode;
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
}
+218 -28
View File
@@ -107,6 +107,56 @@ class MessagingService {
StreamSubscription<RelayConnectionState>? _stateSub;
Timer? _registerRetryTimer;
Timer? _purgeTimer;
StreamSubscription<int>? _prekeysLowSub;
/// Typy E2E obsahu, které tahle verze umí zpracovat. Cokoli jiného se
/// ACKne a zahodí (po upgradu už nejde dešifrovat znovu). Veřejná
/// konstanta kvůli testu: v 1.2.0 tu chyběl `call` a každá nabídka
/// hovoru se tiše zahodila — hovory vůbec nešly přijmout.
static const knownContentTypes = {
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote', 'call',
..._groupControlTypes,
};
/// Prodleva dalšího pokusu o registraci; roste do [_registerRetryMax].
Duration _registerRetryDelay = const Duration(seconds: 30);
static const _registerRetryMax = Duration(minutes: 10);
/// Relay odmítl registraci jako konflikt identity (409) — typicky starší
/// relay bez převzetí identity po obnově ze 24 slov. UI může vysvětlit.
bool _registrationConflict = false;
bool get registrationConflict => _registrationConflict;
/// Rozpracované dodávky: `stop()` (auto-lock) na ně počká, jinak by DB
/// zmizela mezi dešifrováním (ratchet už postoupil) a zápisem a zpráva
/// by byla nenávratně pryč.
int _inFlight = 0;
Completer<void>? _drained;
/// Operace nad ratchet stavem jdou postupně. libsignal načte relaci,
/// šifruje a uloží ji zpět s `await` na Dart store uprostřed — dva
/// souběžné běhy nad touž relací by odvodily stejný message key a druhá
/// obálka by u příjemce propadla. Doručenky, „píše…“ i outbox běží
/// `unawaited` vedle uživatelského odeslání, takže zámek je nutný.
Future<void> _chain = Future.value();
Future<T> _serial<T>(Future<T> Function() op) {
final next = _chain.then((_) => op());
_chain = next.then((_) {}, onError: (_) {});
return next;
}
/// Nejvyšší počet znaků jména kontaktu/profilu z drátu nebo odkazu.
static const maxDisplayNameLength = 64;
static String clipName(String name) => name.length > maxDisplayNameLength
? name.substring(0, maxDisplayNameLength)
: name;
/// Kolik one-time prekeys doplnit, když relay hlásí nedostatek.
static const _otpBatch = 50;
static const _spkRotation = Duration(days: 7);
static const _kyberRotation = Duration(days: 30);
/// Běží právě dodatečné odeslání zaseklých zpráv? Reconnectů může přijít
/// několik rychle za sebou a dva souběžné průchody by tutéž zprávu poslaly
@@ -266,11 +316,13 @@ class MessagingService {
final relay = _buildRelay();
_relay = relay;
_deliverSub = relay.deliveries.listen(_onDeliver);
_prekeysLowSub = relay.prekeysLow.listen(_replenishPreKeys);
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
_stateSub = relay.stateStream.listen((state) {
if (state == RelayConnectionState.connected) {
unawaited(_flushOutbox());
unawaited(_refreshMyAvatarIfOld());
unawaited(_rotatePreKeysIfDue());
}
});
@@ -337,11 +389,13 @@ class MessagingService {
final relay = _buildRelay();
_relay = relay;
_deliverSub = relay.deliveries.listen(_onDeliver);
_prekeysLowSub = relay.prekeysLow.listen(_replenishPreKeys);
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
_stateSub = relay.stateStream.listen((state) {
if (state == RelayConnectionState.connected) {
unawaited(_flushOutbox());
unawaited(_refreshMyAvatarIfOld());
unawaited(_rotatePreKeysIfDue());
}
});
@@ -376,16 +430,109 @@ class MessagingService {
Future<void> _registerWithRetry() async {
try {
await _register();
} on RelayException catch (e) {
// 409 = identita na relayi existuje a relay neumí převzetí (starší
// verze). Opakovat má smysl (relay se aktualizuje), ale ne každých
// 30 s navždy — a UI má vědět proč.
_registrationConflict = e.statusCode == 409;
_scheduleRegisterRetry();
return;
} catch (_) {
_registerRetryTimer?.cancel();
_registerRetryTimer =
Timer(const Duration(seconds: 30), _registerWithRetry);
_scheduleRegisterRetry();
return;
}
_registrationConflict = false;
_registerRetryDelay = const Duration(seconds: 30);
_relay!.start();
await _refreshSenderCert();
}
void _scheduleRegisterRetry() {
_registerRetryTimer?.cancel();
_registerRetryTimer = Timer(_registerRetryDelay, _registerWithRetry);
final doubled = _registerRetryDelay * 2;
_registerRetryDelay =
doubled > _registerRetryMax ? _registerRetryMax : doubled;
onUpdate?.call();
}
int? _kvInt(CryptoDb db, String key) {
final raw = db.kvGet(key);
return raw == null ? null : int.tryParse(utf8.decode(raw));
}
/// Relay po AUTH hlásí docházející one-time prekeys → vygenerovat dávku
/// a nahrát. Bez toho byl účet po vyčerpání zásoby natrvalo bez OTP
/// a každá nová relace k němu běžela bez jednorázového klíče.
Future<void> _replenishPreKeys(int remaining) async {
final db = _db;
final crypto = _crypto;
final relay = _relay;
if (db == null || crypto == null || relay == null || accountId == null) {
return;
}
try {
// Registrace nahrála id 1–100; další dávky navazují.
final next = _kvInt(db, 'otp_next_id') ?? 101;
final otps = await _serial(
() => crypto.generateOneTimePreKeys(startId: next, count: _otpBatch));
await relay.uploadKeys(oneTimePreKeys: otps);
db.kvPut('otp_next_id', utf8.encode('${next + _otpBatch}'));
} catch (_) {
// Síť / starší relay bez /v1/keys — relay to zopakuje po dalším AUTH.
}
}
/// Rotace signed prekey (týdně) a last-resort Kyber prekey (měsíčně),
/// Staré klíče zůstávají ve store — relace z bundle
/// staženého těsně před rotací je ještě potřebují.
Future<void> _rotatePreKeysIfDue() async {
final db = _db;
final crypto = _crypto;
final relay = _relay;
if (db == null || crypto == null || relay == null || accountId == null) {
return;
}
final now = DateTime.now();
bool due(String key, Duration period) {
final at = _kvInt(db, key);
return at == null ||
now.difference(DateTime.fromMillisecondsSinceEpoch(at)) > period;
}
final spkDue = due('spk_rotated_at', _spkRotation);
final kyberDue = due('kyber_rotated_at', _kyberRotation);
if (!spkDue && !kyberDue) return;
try {
SepotSignedPreKey? spk;
SepotKyberPreKey? kyber;
if (spkDue) {
final id = _kvInt(db, 'spk_next_id') ?? 2;
spk = await _serial(() => crypto.generateSignedPreKey(
id: id, timestampMs: now.millisecondsSinceEpoch));
}
if (kyberDue) {
final id = _kvInt(db, 'kyber_next_id') ?? 2;
kyber = await _serial(() => crypto.generateKyberPreKey(
id: id, timestampMs: now.millisecondsSinceEpoch));
}
await relay.uploadKeys(signedPreKey: spk, kyberPreKey: kyber);
final stamp = utf8.encode('${now.millisecondsSinceEpoch}');
db.transaction(() {
if (spk != null) {
db.kvPut('spk_next_id', utf8.encode('${spk.id + 1}'));
db.kvPut('spk_rotated_at', stamp);
}
if (kyber != null) {
db.kvPut('kyber_next_id', utf8.encode('${kyber.id + 1}'));
db.kvPut('kyber_rotated_at', stamp);
}
});
} catch (_) {
// Starší relay bez /v1/keys nebo offline — zkusí se po dalším connectu.
}
}
Future<void> _register() async {
final db = _db!;
final crypto = _crypto!;
@@ -410,10 +557,17 @@ class MessagingService {
deliveryAddress: deliveryAddress,
);
final stamp = utf8.encode('$nowMs');
db.transaction(() {
db.kvPut('account_id', utf8.encode(reg.accountId));
db.kvPut('device_uuid', utf8.encode(reg.deviceUuid));
db.kvPut('delivery_address', deliveryAddress);
// Id pro doplňování a rotace klíčů navazují na registraci.
db.kvPut('otp_next_id', utf8.encode('101'));
db.kvPut('spk_next_id', utf8.encode('2'));
db.kvPut('kyber_next_id', utf8.encode('2'));
db.kvPut('spk_rotated_at', stamp);
db.kvPut('kyber_rotated_at', stamp);
});
onUpdate?.call();
}
@@ -2009,6 +2163,10 @@ class MessagingService {
'txt': caption ?? '',
're': ?message.replyToId,
'ttl': ?message.disappearAfterSeconds,
// Anketa a zmínky patří k téže zprávě i při dodatečném odeslání —
// bez nich by po reconnectu dorazil jen holý záložní text.
'poll': ?message.poll?.toWire(),
if (message.mentions.isNotEmpty) 'mn': message.mentions,
'p': _profile(),
};
}
@@ -2053,22 +2211,27 @@ class MessagingService {
}
final peerAddr = ProtocolAddress(name: peerAid, deviceId: 1);
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
if (!await crypto.hasSession(peerAddr)) {
if (ephemeral) return null;
final bundle = await relay.fetchBundle(peerDa);
if (!_listEquals(bundle.identityKey, peer.identityKey)) {
throw const IdentityKeyMismatchError();
// Krypto část jde přes [_serial] (viz komentář u pole): relace se
// načte, zašifruje a uloží bez souběhu. Odeslání po síti už zámek
// nedrží — čekání na receipt by blokovalo všechny ostatní.
final sealed = await _serial<Uint8List?>(() async {
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
if (!await crypto.hasSession(peerAddr)) {
if (ephemeral) return null;
final bundle = await relay.fetchBundle(peerDa);
if (!_listEquals(bundle.identityKey, peer.identityKey)) {
throw const IdentityKeyMismatchError();
}
await crypto.processRemoteBundle(peerAddr, bundle);
}
await crypto.processRemoteBundle(peerAddr, bundle);
}
final sealed = await crypto.sealedEncrypt(
recipient: peerAddr,
plaintext: utf8.encode(jsonEncode(content)),
senderCertificate: await _senderCertificate(),
);
return crypto.sealedEncrypt(
recipient: peerAddr,
plaintext: utf8.encode(jsonEncode(content)),
senderCertificate: await _senderCertificate(),
);
});
if (sealed == null) return null;
final receipt = await relay.sendEnvelope(
dstAddress: peerDa,
@@ -2084,10 +2247,25 @@ class MessagingService {
// ── Příjem ──────────────────────────────────────────────────────────
Future<void> _onDeliver(pb.Envelope envelope) async {
/// Dodávky se zpracovávají postupně (viz [_serial]) a evidují jako
/// rozpracované, aby na ně [stop] počkal.
void _onDeliver(pb.Envelope envelope) {
_inFlight++;
unawaited(_serial(() => _handleDelivery(envelope))
.then((_) {}, onError: (_) {})
.whenComplete(() {
_inFlight--;
if (_inFlight == 0) _drained?.complete();
}));
}
Future<void> _handleDelivery(pb.Envelope envelope) async {
final crypto = _crypto;
final trustRoot = _trustRoot;
if (crypto == null || trustRoot == null) return;
// Zamčené úložiště (auto-lock stihl zamknout dřív, než jsme se dostali
// na řadu): nedešifrovat — ratchet by postoupil a zápis by selhal.
// Relay obálku doručí znovu po odemčení.
if (crypto == null || trustRoot == null || !storage.isUnlocked) return;
final SepotSealedMessage opened;
try {
@@ -2110,10 +2288,7 @@ class MessagingService {
final content =
jsonDecode(utf8.decode(opened.plaintext)) as Map<String, dynamic>;
final type = content['t'] as String?;
const known = {
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote',
..._groupControlTypes,
};
const known = knownContentTypes;
if (content['v'] != 1 || !known.contains(type)) {
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný
// a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
@@ -2228,13 +2403,18 @@ class MessagingService {
final replyTo = content['re'] as String?;
final Message message;
if (type == 'txt') {
final text = content['txt'];
if (text is! String) {
final raw = content['txt'];
if (raw is! String) {
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
_relay!.ack(envelope.msgUuid);
return;
}
// Strop délky z drátu: obálka unese 256 KiB a konverzace je jeden
// JSON blob — čtvrtmegabajtová bublina by ji trvale zpomalila.
const maxWireText = AppConstants.maxMessageLength * 4;
final text =
raw.length > maxWireText ? raw.substring(0, maxWireText) : raw;
message = Message(
id: messageId,
senderId: contact.publicIdentity.fingerprint.display,
@@ -2567,7 +2747,7 @@ class MessagingService {
// Prázdné jméno = UI si doplní lokalizovaný fallback
final name = (profile?['n'] as String?)?.trim();
final identity = PublicIdentity(
displayName: (name == null || name.isEmpty) ? '' : name,
displayName: (name == null || name.isEmpty) ? '' : clipName(name),
identityKey: opened.senderIdentityKey,
createdAt: DateTime.now(),
accountId: opened.senderName,
@@ -2603,7 +2783,7 @@ class MessagingService {
/// Probuzení z push budíčku: ujisti se, že jsme připojení — fronta se
/// vyleje po AUTH (relay pošle DELIVER stream). Idempotentní.
void onPushWake() {
_relay?.start();
_relay?.kick();
}
/// Deregistrace účtu na relayi (GDPR čl. 17 / store „smazání účtu").
@@ -2631,7 +2811,17 @@ class MessagingService {
_purgeTimer?.cancel();
_purgeTimer = null;
await _deliverSub?.cancel();
await _prekeysLowSub?.cancel();
await _stateSub?.cancel();
// Rozpracované dodávky doběhnou (dešifrováno → zapsáno → ACK), teprve
// pak se úložiště smí zamknout. Strop, ať zaseklá dodávka nezablokuje
// zámek navždy.
if (_inFlight > 0) {
final drained = _drained ??= Completer<void>();
await drained.future
.timeout(const Duration(seconds: 8), onTimeout: () {});
}
_drained = null;
await _relay?.dispose();
// DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit
_db = null;
+136 -29
View File
@@ -30,6 +30,16 @@ import 'package:sepot/models/message.dart';
/// při onboardingu, který DB rovnou otevře).
/// Nátlakový PIN je shodný se skutečným — nelze nastavit.
/// Příliš mnoho špatných PINů za sebou — další pokus až za [remaining].
class PinLockedException implements Exception {
const PinLockedException(this.remaining);
final Duration remaining;
@override
String toString() => 'PIN locked for ${remaining.inSeconds}s';
}
class DecoyPinConflict implements Exception {
const DecoyPinConflict();
@override
@@ -51,6 +61,13 @@ class SecureStorageService {
static const _legacyPinHashKey = 'pin_hash';
static const _decoyWrappedDekKey = 'wrapped_dek_decoy';
// Brzda hádání PINu: počet chyb za sebou a čas, do kdy je zámek uzamčený.
// V keystore (ne v DB) — DB je právě to, co se hádáním otevírá.
static const _pinFailCountKey = 'pin_fail_count';
static const _pinLockUntilKey = 'pin_lock_until';
static const _pinFreeAttempts = 5;
static const _pinLockBase = Duration(seconds: 30);
static const _pinLockMax = Duration(minutes: 15);
final FlutterSecureStorage _secureStorage;
final String? _dbPathOverride;
@@ -60,12 +77,24 @@ class SecureStorageService {
bool _decoyMode = false;
/// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB.
/// V tomhle režimu úložiště nevydá skutečnou identitu ani frázi,
/// nestartuje síť a nic z keystore nemění (viz jednotlivé metody).
bool get isDecoyMode => _decoyMode;
/// Identita ukazovaná v nátlakové relaci: náhodná, jen v paměti.
LocalIdentity? _decoyIdentity;
void _requireRealSession() {
if (_decoyMode) throw StateError('Not available in a duress session');
}
SecureStorageService({String? dbPath})
: _secureStorage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
// this_device: identita a klíče nesmí putovat v iCloud Keychain
// ani v záloze na jiný telefon — obnova jde přes 24 slov.
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device),
),
_dbPathOverride = dbPath;
@@ -98,21 +127,59 @@ class SecureStorageService {
if (wrapped == null) {
throw StateError('No PIN set — use unlockWithoutPin');
}
final locked = await pinLockRemaining();
if (locked != null) throw PinLockedException(locked);
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
try {
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: pin);
await _openDb(dek);
// Oba bloby jednou derivací (sdílená sůl) — čas odemčení neprozradí,
// který PIN padl.
final (index, dek) = await DekWrap.unwrapFirstMatching(
wrapped: [wrapped, ?decoy],
pin: pin,
);
await _openDb(dek, decoy: index == 1);
await _resetPinFailures();
} on WrongPinException {
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
if (decoy == null) rethrow;
// Když ani decoy nesedí, unwrap vyhodí WrongPinException dál.
final dek = await DekWrap.unwrap(wrapped: decoy, pin: pin);
await _openDb(dek, decoy: true);
await _notePinFailure();
rethrow;
}
}
/// Zbývající zámek po sérii špatných PINů; null = volno.
Future<Duration?> pinLockRemaining() async {
final raw = await _secureStorage.read(key: _pinLockUntilKey);
final until = raw == null ? null : int.tryParse(raw);
if (until == null) return null;
final remaining = DateTime.fromMillisecondsSinceEpoch(until)
.difference(DateTime.now());
return remaining > Duration.zero ? remaining : null;
}
Future<void> _notePinFailure() async {
final raw = await _secureStorage.read(key: _pinFailCountKey);
final failures = (raw == null ? 0 : int.tryParse(raw) ?? 0) + 1;
await _secureStorage.write(key: _pinFailCountKey, value: '$failures');
if (failures < _pinFreeAttempts) return;
// 30 s po páté chybě, pak dvojnásobně až do 15 minut: šest číslic
// (10⁶) tak trvá roky, Argon2id sám by brzdil jen ~2 s na pokus.
var lock = _pinLockBase * (1 << (failures - _pinFreeAttempts));
if (lock > _pinLockMax) lock = _pinLockMax;
await _secureStorage.write(
key: _pinLockUntilKey,
value: '${DateTime.now().add(lock).millisecondsSinceEpoch}',
);
}
Future<void> _resetPinFailures() async {
await _secureStorage.delete(key: _pinFailCountKey);
await _secureStorage.delete(key: _pinLockUntilKey);
}
/// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže.
/// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře.
Future<void> enablePin(String pin) async {
_requireRealSession();
final dek = _dek ?? await _getOrCreateRawDek();
final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
@@ -124,6 +191,7 @@ class SecureStorageService {
/// Vypne PIN: DEK se vrátí raw do OS keystore.
Future<void> disablePin() async {
_requireRealSession();
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
await _secureStorage.delete(key: _wrappedDekKey);
@@ -151,6 +219,7 @@ class SecureStorageService {
required String oldPin,
required String newPin,
}) async {
_requireRealSession();
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
if (wrapped == null) throw StateError('No PIN set');
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin);
@@ -165,7 +234,11 @@ class SecureStorageService {
}
}
final rewrapped = await DekWrap.wrap(dek: dek, pin: newPin);
// Sůl zůstává: nátlakový blob ji sdílí (jedna derivace pro oba PINy)
// a bez jeho PINu by se přebalit nedal. Jedna sůl pro dva PINy téhož
// uživatele nic neoslabuje — sůl brání předpočítání napříč uživateli.
final rewrapped = await DekWrap.wrap(
dek: dek, pin: newPin, salt: DekWrap.saltOf(wrapped));
await _secureStorage.write(key: _wrappedDekKey, value: rewrapped);
}
@@ -176,6 +249,7 @@ class SecureStorageService {
/// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy
/// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit.
Future<void> enableDecoyPin(String decoyPin) async {
_requireRealSession();
final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
if (realWrapped == null) {
throw StateError('Set a regular PIN first');
@@ -193,7 +267,9 @@ class SecureStorageService {
// Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB
final decoyPath = await _dbPath(decoy: true);
CryptoDb.open(path: decoyPath, dek: decoyDek).close();
final wrapped = await DekWrap.wrap(dek: decoyDek, pin: decoyPin);
// Stejná sůl jako skutečný blob → jedna Argon2id derivace pro oba PINy.
final wrapped = await DekWrap.wrap(
dek: decoyDek, pin: decoyPin, salt: DekWrap.saltOf(realWrapped));
await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped);
}
@@ -203,11 +279,20 @@ class SecureStorageService {
/// Zruší nátlakový PIN a smaže decoy DB.
Future<void> disableDecoyPin() async {
_requireRealSession();
await _secureStorage.delete(key: _decoyWrappedDekKey);
try {
final f = File(await _dbPath(decoy: true));
if (f.existsSync()) f.deleteSync();
} catch (_) {}
await _deleteDbFiles(await _dbPath(decoy: true));
}
Future<void> _deleteDbFiles(String path) async {
for (final suffix in ['', '-wal', '-shm']) {
try {
final f = File('$path$suffix');
if (f.existsSync()) f.deleteSync();
} catch (_) {
// best-effort úklid
}
}
}
/// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme
@@ -222,6 +307,8 @@ class SecureStorageService {
_db?.close();
_db = null;
_dek = null;
_decoyMode = false;
_decoyIdentity = null;
}
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
@@ -293,6 +380,11 @@ class SecureStorageService {
/// Načti lokální identitu
Future<LocalIdentity?> loadIdentity() async {
if (_decoyMode) {
// Nátlaková relace ukazuje náhodnou identitu jen z paměti — skutečný
// klíč z keystore se do ní nikdy nedostane (nátlakový PIN musí být izolovaný).
return _decoyIdentity ??= await LocalIdentity.create('');
}
final json = await _secureStorage.read(key: _identityKey);
if (json == null) return null;
try {
@@ -320,6 +412,7 @@ class SecureStorageService {
}
Future<List<String>?> loadSeedPhrase() async {
if (_decoyMode) return null;
final json = await _secureStorage.read(key: _seedPhraseKey);
if (json == null) return null;
return (jsonDecode(json) as List).cast<String>();
@@ -331,6 +424,7 @@ class SecureStorageService {
/// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings).
/// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním.
Future<void> enableBiometrics() async {
_requireRealSession();
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
await _secureStorage.write(
key: _biometricDekKey,
@@ -342,6 +436,7 @@ class SecureStorageService {
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
/// PIN-only model: DEK existuje jen za Argon2id).
Future<void> disableBiometrics() async {
_requireRealSession();
await _secureStorage.delete(key: _biometricDekKey);
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
}
@@ -475,24 +570,29 @@ class SecureStorageService {
if (!isUnlocked) return false;
var changed = false;
final now = DateTime.now();
for (final conv in await loadConversations()) {
final expired = conv.messages
.where((m) =>
m.disappearAfterSeconds != null &&
m.timestamp
.add(Duration(seconds: m.disappearAfterSeconds!))
.isBefore(now))
.toList();
bool isExpired(Message m) =>
m.disappearAfterSeconds != null &&
m.timestamp
.add(Duration(seconds: m.disappearAfterSeconds!))
.isBefore(now);
for (final snapshot in await loadConversations()) {
if (!snapshot.messages.any(isExpired)) continue;
// Konverzace se před zápisem načte znovu: mezi snímkem a zápisem
// běží event loop (mazání blobů, příjem zpráv) a starý snímek by
// přepsal zprávu přijatou mezitím — už ACKnutou, tedy navždy pryč.
final conv = await loadConversation(snapshot.contactId);
if (conv == null) continue;
final expired = conv.messages.where(isExpired).toList();
if (expired.isEmpty) continue;
changed = true;
conv.messages.removeWhere(isExpired);
await saveConversation(conv);
for (final m in expired) {
final blobId = m.attachment?.blobId;
if (blobId != null) {
await AttachmentStore().delete(blobId);
}
conv.messages.remove(m);
}
await saveConversation(conv);
}
return changed;
}
@@ -512,12 +612,19 @@ class SecureStorageService {
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
/// i lokální bloby příloh.
Future<void> deleteAll() async {
final path = await _dbPath();
lock();
for (final suffix in ['', '-wal', '-shm']) {
final f = File('$path$suffix');
if (f.existsSync()) f.deleteSync();
if (_decoyMode) {
// Nátlaková relace: „smazat" smí jen prázdnou decoy DB — skutečná
// data a keystore zůstávají (dřív to smazalo reálný účet).
final decoyPath = await _dbPath(decoy: true);
lock();
await _deleteDbFiles(decoyPath);
return;
}
final path = await _dbPath();
final decoyPath = await _dbPath(decoy: true);
lock();
await _deleteDbFiles(path);
await _deleteDbFiles(decoyPath);
await AttachmentStore().deleteAll();
await _secureStorage.deleteAll();
}
+20 -2
View File
@@ -4,10 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:local_auth/local_auth.dart';
import 'package:sepot/core/crypto/dek_wrap.dart';
import 'package:sepot/core/storage/secure_storage.dart' show PinLockedException;
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/messaging_provider.dart';
import 'package:sepot/providers/push_provider.dart';
import 'package:sepot/providers/settings_provider.dart';
import 'package:sepot/providers/storage_provider.dart';
import 'package:sepot/widgets/sepot_logo.dart';
@@ -41,7 +43,10 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
try {
final ok = await LocalAuthentication().authenticate(
localizedReason: unlockReason,
options: const AuthenticationOptions(stickyAuth: true),
// biometricOnly: zámek telefonu (PIN/vzor zařízení) nesmí Šepot
// odemknout — obešel by Argon2id ochranu DEK slabším faktorem.
options: const AuthenticationOptions(
stickyAuth: true, biometricOnly: true),
);
if (!ok) return; // uživatel zrušil → zůstává PIN
await storage.unlockWithBiometrics();
@@ -222,6 +227,9 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
void _restartMessagingAfterUnlock() {
ref.invalidate(messagingStartProvider);
ref.invalidate(pushInitProvider);
// Zabezpečení obrazovky se čte z DB — před odemčením provider nic
// neaplikoval, teď má skutečnou hodnotu.
ref.invalidate(screenSecurityProvider);
}
Future<void> _verifyPin() async {
@@ -234,12 +242,22 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
_restartMessagingAfterUnlock();
if (mounted) context.go('/chats');
} on WrongPinException {
final locked = await ref.read(storageProvider).pinLockRemaining();
if (!mounted) return;
setState(() {
_error = context.l10n.pinWrong2;
_error = locked == null
? context.l10n.pinWrong2
: context.l10n.pinLockedFor(locked.inSeconds);
_pin = '';
_isVerifying = false;
});
HapticFeedback.heavyImpact();
} on PinLockedException catch (e) {
setState(() {
_error = context.l10n.pinLockedFor(e.remaining.inSeconds);
_pin = '';
_isVerifying = false;
});
} on StateError {
// Wrapped DEK neexistuje — nekonzistentní stav, zpět na onboarding
if (mounted) context.go('/onboarding');
+8 -3
View File
@@ -89,9 +89,13 @@ class _CallScreenState extends ConsumerState<CallScreen> {
RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
),
),
SafeArea(
child: Column(
children: [
// Positioned.fill: bez něj si Stack dá sloupci volné rozměry a ve
// fázi „ukončeno" (žádný ovládací řádek přes celou šířku) se sloupec
// smrskne na šířku jména a zarovná vlevo nahoru — avatar uskočí.
Positioned.fill(
child: SafeArea(
child: Column(
children: [
const SizedBox(height: 48),
if (!showVideo) ...[
SepotAvatar(
@@ -136,6 +140,7 @@ class _CallScreenState extends ConsumerState<CallScreen> {
_InCallControls(call: call, controller: controller),
const SizedBox(height: 32),
],
),
),
),
if (showVideo && !call.cameraOff)
@@ -64,7 +64,8 @@ class ConversationScreen extends ConsumerStatefulWidget {
ConsumerState<ConversationScreen> createState() => _ConversationScreenState();
}
class _ConversationScreenState extends ConsumerState<ConversationScreen> {
class _ConversationScreenState extends ConsumerState<ConversationScreen>
with WidgetsBindingObserver {
final _messageController = TextEditingController();
final _scrollController = ScrollController();
final _searchController = TextEditingController();
@@ -133,9 +134,43 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
_drafts = ref.read(draftsProvider.notifier);
_restoreDraft();
_messageController.addListener(_scheduleDraftSave);
_conversations = ref.read(conversationsProvider.notifier);
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) => _captureUnreadAndMark());
}
/// Notifier zachycený v initState: v dispose už `ref` použít nejde.
late final ConversationsNotifier _conversations;
/// Zpráva, která přijde do otevřeného chatu, je přečtená, jen když ji
/// uživatel opravdu vidí: appka v popředí a tahle obrazovka navrchu
/// (ne pod profilem kontaktu nebo náhledem fotky).
bool get _isSeenNow =>
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed &&
(_route?.isCurrent ?? false);
// Route uložená v didChangeDependencies: lifecycle callback může přijít
// i ve chvíli, kdy je element už deaktivovaný a hledat předka nesmí.
ModalRoute<Object?>? _route;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_route = ModalRoute.of(context);
}
void _markIfSeen() {
if (!mounted || !_isSeenNow) return;
_conversations.markAsRead(widget.contactId);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Zprávy, které přišly se zhasnutým displejem nad otevřeným chatem,
// uživatel uvidí až po návratu do appky.
if (state == AppLifecycleState.resumed) _markIfSeen();
}
/// Zapamatuje si první nepřečtenou zprávu a teprve pak konverzaci označí
/// za přečtenou — jinak by řádek „nové zprávy“ nikdy nebylo kam dát.
Future<void> _captureUnreadAndMark() async {
@@ -185,6 +220,10 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
// Odchod z chatu: všechno, co bylo na obrazovce, je přečtené – i zprávy
// dorazivší těsně před odchodem, které listener nestihl označit.
_conversations.markAsRead(widget.contactId);
_draftTimer?.cancel();
// Odchod z chatu uprostřed úpravy = koncept je ten odložený, ne text
// upravované zprávy.
@@ -214,6 +253,17 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
.where((c) => c.publicIdentity.id == widget.contactId)
.firstOrNull;
final conversationAsync = ref.watch(conversationProvider(widget.contactId));
// Nová příchozí zpráva v otevřeném chatu se rovnou čte; jinak by po
// návratu do seznamu svítila jako nepřečtená.
// První načtení vynechá: to obslouží _captureUnreadAndMark, které si
// nejdřív zapamatuje řádek „N nových zpráv“.
ref.listen(conversationProvider(widget.contactId), (prev, next) {
if (prev?.valueOrNull == null) return;
final conv = next.valueOrNull;
final myId = ref.read(identityProvider).valueOrNull?.id;
if (conv == null || myId == null || conv.unreadCount(myId) == 0) return;
_markIfSeen();
});
final identity = ref.watch(identityProvider).valueOrNull;
// Skupinová konverzace: contactId == gid
final group = ref.watch(groupProvider(widget.contactId)).valueOrNull;
@@ -2150,7 +2200,11 @@ class _SwipeToReplyState extends State<_SwipeToReply>
static const _max = 84.0;
static const _trigger = 58.0;
late final AnimationController _back = AnimationController(
// Kontroler vzniká až při prvním tahu. Dřív ho `late final` vytvořil
// teprve dispose() u bublin, které nikdo netáhl – nad deaktivovaným
// elementem, což v debug buildu padá na hledání TickerMode.
AnimationController? _backCtl;
AnimationController get _back => _backCtl ??= AnimationController(
vsync: this, duration: const Duration(milliseconds: 180))
..addListener(() => setState(() => _dx = _from * (1 - _back.value)));
double _dx = 0;
@@ -2159,7 +2213,7 @@ class _SwipeToReplyState extends State<_SwipeToReply>
@override
void dispose() {
_back.dispose();
_backCtl?.dispose();
super.dispose();
}
@@ -55,7 +55,8 @@ class _BiometricsTileState extends ConsumerState<BiometricsTile> {
}
final ok = await auth.authenticate(
localizedReason: l10n.biometricsEnrollPromptReason,
options: const AuthenticationOptions(stickyAuth: true),
options: const AuthenticationOptions(
stickyAuth: true, biometricOnly: true),
);
if (!ok) return;
await storage.enableBiometrics();
+73 -57
View File
@@ -38,6 +38,9 @@ class SettingsScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final identityAsync = ref.watch(identityProvider);
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(
appBar: AppBar(
@@ -63,13 +66,15 @@ class SettingsScreen extends ConsumerWidget {
const SizedBox(height: 8),
// Sekce Zabezpečení
_buildSectionHeader(l10n.settingsSectionSecurity),
_buildTile(
icon: Icons.pin_outlined,
title: l10n.settingsChangePinTitle,
subtitle: l10n.settingsChangePinSubtitle,
onTap: () => context.push('/change-pin'),
),
const BiometricsTile(),
if (!decoy) ...[
_buildTile(
icon: Icons.pin_outlined,
title: l10n.settingsChangePinTitle,
subtitle: l10n.settingsChangePinSubtitle,
onTap: () => context.push('/change-pin'),
),
const BiometricsTile(),
],
_buildTile(
icon: Icons.timer_outlined,
title: l10n.settingsAutoLockTitle,
@@ -80,24 +85,25 @@ class SettingsScreen extends ConsumerWidget {
),
onTap: () => _nastavAutoLock(context, ref),
),
_buildTile(
icon: Icons.password_outlined,
title: l10n.settingsDecoyPinTitle,
subtitle: ref.watch(hasDecoyPinProvider).valueOrNull == true
? l10n.settingsDecoyPinSetSubtitle
: l10n.settingsDecoyPinUnsetSubtitle,
trailing: ref.watch(hasDecoyPinProvider).valueOrNull == true
? TextButton(
onPressed: () => _disableDecoyPin(context, ref),
child: Text(l10n.commonCancel),
)
: null,
onTap: () {
if (ref.read(hasDecoyPinProvider).valueOrNull != true) {
_setDecoyPin(context, ref);
}
},
),
if (!decoy)
_buildTile(
icon: Icons.password_outlined,
title: l10n.settingsDecoyPinTitle,
subtitle: ref.watch(hasDecoyPinProvider).valueOrNull == true
? l10n.settingsDecoyPinSetSubtitle
: l10n.settingsDecoyPinUnsetSubtitle,
trailing: ref.watch(hasDecoyPinProvider).valueOrNull == true
? TextButton(
onPressed: () => _disableDecoyPin(context, ref),
child: Text(l10n.commonCancel),
)
: null,
onTap: () {
if (ref.read(hasDecoyPinProvider).valueOrNull != true) {
_setDecoyPin(context, ref);
}
},
),
const SizedBox(height: 8),
// Sekce Notifikace
_buildSectionHeader(l10n.settingsSectionNotifications),
@@ -153,25 +159,27 @@ class SettingsScreen extends ConsumerWidget {
),
const SizedBox(height: 8),
// Sekce Záloha
_buildSectionHeader(l10n.settingsSectionBackup),
_buildTile(
icon: Icons.key_outlined,
title: l10n.settingsSeedPhraseTitle,
subtitle: l10n.settingsSeedPhraseSubtitle,
onTap: () => _showSeedPhrase(context, ref),
),
_buildTile(
icon: Icons.backup_outlined,
title: l10n.settingsBackupExportTitle,
subtitle: l10n.settingsBackupExportSubtitle,
onTap: () => _exportBackup(context, ref),
),
_buildTile(
icon: Icons.restore_outlined,
title: l10n.settingsBackupImportTitle,
subtitle: l10n.settingsBackupImportSubtitle,
onTap: () => _importBackup(context, ref),
),
if (!decoy) ...[
_buildSectionHeader(l10n.settingsSectionBackup),
_buildTile(
icon: Icons.key_outlined,
title: l10n.settingsSeedPhraseTitle,
subtitle: l10n.settingsSeedPhraseSubtitle,
onTap: () => _showSeedPhrase(context, ref),
),
_buildTile(
icon: Icons.backup_outlined,
title: l10n.settingsBackupExportTitle,
subtitle: l10n.settingsBackupExportSubtitle,
onTap: () => _exportBackup(context, ref),
),
_buildTile(
icon: Icons.restore_outlined,
title: l10n.settingsBackupImportTitle,
subtitle: l10n.settingsBackupImportSubtitle,
onTap: () => _importBackup(context, ref),
),
],
// „Web klient" byl slib funkce, která neexistuje (multi-device je
// vlna 3). Apple Guideline 2.1 takové dlaždice bere jako nedodělek.
const SizedBox(height: 8),
@@ -230,17 +238,18 @@ class SettingsScreen extends ConsumerWidget {
),
const SizedBox(height: 24),
// Smazat účet
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton(
onPressed: () => _confirmDeleteAccount(context, ref),
style: OutlinedButton.styleFrom(
foregroundColor: SepotColors.error,
side: const BorderSide(color: SepotColors.error),
if (!decoy)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton(
onPressed: () => _confirmDeleteAccount(context, ref),
style: OutlinedButton.styleFrom(
foregroundColor: SepotColors.error,
side: const BorderSide(color: SepotColors.error),
),
child: Text(l10n.settingsDeleteAllDataButton),
),
child: Text(l10n.settingsDeleteAllDataButton),
),
),
const SizedBox(height: 48),
],
),
@@ -514,10 +523,17 @@ class SettingsScreen extends ConsumerWidget {
final dir = await getTemporaryDirectory();
final path = '${dir.path}/sepot-zaloha.sepotbackup';
await File(path).writeAsBytes(bytes);
await Share.shareXFiles(
[XFile(path, mimeType: 'application/octet-stream')],
subject: l10n.backupShareSubject,
);
try {
await Share.shareXFiles(
[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) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
+9
View File
@@ -1481,6 +1481,15 @@
"@pinUnlockError": {
"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": {
"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)"
+1
View File
@@ -315,6 +315,7 @@
"pinUnlockWithBiometrics": "Mit Biometrie entsperren",
"pinWrong2": "Falsche PIN",
"pinUnlockError": "Fehler beim Entsperren",
"pinLockedFor": "Zu viele Versuche. Versuche es in {seconds} s erneut.",
"pushTitle": "Push-Benachrichtigungen",
"pushModeOffDesc": "Nachrichten werden erst beim Öffnen der App abgerufen.",
"pushModeUp": "UnifiedPush (ohne Google)",
+1
View File
@@ -315,6 +315,7 @@
"pinUnlockWithBiometrics": "Unlock with biometrics",
"pinWrong2": "Wrong PIN",
"pinUnlockError": "Unlock error",
"pinLockedFor": "Too many attempts. Try again in {seconds} s.",
"pushTitle": "Push notifications",
"pushModeOffDesc": "Messages arrive only after you open the app.",
"pushModeUp": "UnifiedPush (no Google)",
+1
View File
@@ -315,6 +315,7 @@
"pinUnlockWithBiometrics": "Sblocca con biometria",
"pinWrong2": "PIN errato",
"pinUnlockError": "Errore di sblocco",
"pinLockedFor": "Troppi tentativi. Riprova tra {seconds} s.",
"pushTitle": "Notifiche push",
"pushModeOffDesc": "I messaggi verranno scaricati solo all'apertura dell'app.",
"pushModeUp": "UnifiedPush (senza Google)",
+1
View File
@@ -315,6 +315,7 @@
"pinUnlockWithBiometrics": "Разблокировать биометрией",
"pinWrong2": "Неверный PIN",
"pinUnlockError": "Ошибка разблокировки",
"pinLockedFor": "Слишком много попыток. Повторите через {seconds} с.",
"pushTitle": "Push-уведомления",
"pushModeOffDesc": "Сообщения загрузятся только после открытия приложения.",
"pushModeUp": "UnifiedPush (без Google)",
@@ -1994,6 +1994,12 @@ abstract class AppLocalizations {
/// **'Chyba odemčení'**
String get pinUnlockError;
/// Hláška na odemykací obrazovce po sérii špatných PINů; {seconds} = počet sekund do dalšího pokusu
///
/// In cs, this message translates to:
/// **'Příliš mnoho pokusů. Zkus to za {seconds} s.'**
String pinLockedFor(int seconds);
/// 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)
///
/// In cs, this message translates to:
@@ -1102,6 +1102,11 @@ class AppLocalizationsCs extends AppLocalizations {
@override
String get pinUnlockError => 'Chyba odemčení';
@override
String pinLockedFor(int seconds) {
return 'Příliš mnoho pokusů. Zkus to za $seconds s.';
}
@override
String get pushTitle => 'Push notifikace';
@@ -1121,6 +1121,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get pinUnlockError => 'Fehler beim Entsperren';
@override
String pinLockedFor(int seconds) {
return 'Zu viele Versuche. Versuche es in $seconds s erneut.';
}
@override
String get pushTitle => 'Push-Benachrichtigungen';
@@ -1101,6 +1101,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get pinUnlockError => 'Unlock error';
@override
String pinLockedFor(int seconds) {
return 'Too many attempts. Try again in $seconds s.';
}
@override
String get pushTitle => 'Push notifications';
@@ -1111,6 +1111,11 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get pinUnlockError => 'Errore di sblocco';
@override
String pinLockedFor(int seconds) {
return 'Troppi tentativi. Riprova tra $seconds s.';
}
@override
String get pushTitle => 'Notifiche push';
@@ -1118,6 +1118,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get pinUnlockError => 'Ошибка разблокировки';
@override
String pinLockedFor(int seconds) {
return 'Слишком много попыток. Повторите через $seconds с.';
}
@override
String get pushTitle => 'Push-уведомления';
+18 -8
View File
@@ -1,6 +1,8 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:sepot/core/constants/app_constants.dart';
/// Stav přenosu přílohy — čistě lokální (na drát nikdy nejde).
enum AttachmentTransferState {
/// Přijatá zpráva, blob se ještě nezačal stahovat.
@@ -94,14 +96,22 @@ class AttachmentMeta {
/// (blob_id formát hlídá AttachmentStore, tady rozsahy a typy).
factory AttachmentMeta.fromWire(Map<String, dynamic> wire) {
final size = wire['s'] as int;
if (size <= 0 || size > 512 * 1024 * 1024) {
if (size <= 0 || size > AppConstants.maxAttachmentBytes) {
throw const FormatException('implausible attachment size');
}
// Krypto pole MUSÍ být validní base64 (jinak je meta k ničemu);
// vyhodí FormatException → zpráva se zahodí jako vadná.
base64Decode(wire['k'] as String);
base64Decode(wire['n'] as String);
base64Decode(wire['d'] as String);
// Krypto pole MUSÍ být validní base64 správné délky (AES-256-GCM klíč,
// 96bitová nonce, SHA-256 digest) — jinak by vadná meta prošla a
// selhala až při otevření přílohy, kdy je obálka dávno ACKnutá.
if (base64Decode(wire['k'] as String).length != 32 ||
base64Decode(wire['n'] as String).length != 12 ||
base64Decode(wire['d'] as String).length != 32) {
throw const FormatException('invalid attachment key material');
}
final name = wire['name'] as String?;
final mime = wire['mime'] as String;
if ((name != null && name.length > 255) || mime.length > 100) {
throw const FormatException('attachment name or mime too long');
}
// Vadný náhled zprávu nezabíjí — UI ukáže placeholder a ostrá fotka
// se stáhne z blobu. base64Decode v build() by shodil celou bublinu.
var thumb = wire['th'] as String?;
@@ -118,8 +128,8 @@ class AttachmentMeta {
nonceB64: wire['n'] as String,
digestB64: wire['d'] as String,
size: size,
mime: wire['mime'] as String,
name: wire['name'] as String?,
mime: mime,
name: name,
width: wire['w'] as int?,
height: wire['h'] as int?,
thumbB64: thumb,
+9 -1
View File
@@ -55,9 +55,17 @@ class Conversation {
/// Nahraď zprávu se stejným id (změna stavu přílohy, doručenka…).
/// Neexistující id se tiše ignoruje (zpráva mohla být smazána).
///
/// Přečtení je jednosměrné: aktualizace nesmí vrátit už přečtenou zprávu
/// mezi nepřečtené. Volající často drží kopii zprávy načtenou před
/// otevřením chatu (stahování přílohy, dodatečné odeslání) a bez téhle
/// pojistky by zápis po návratu ze stahování obnovil odznak nepřečtených.
void updateMessage(Message message) {
final index = messages.indexWhere((m) => m.id == message.id);
if (index >= 0) messages[index] = message;
if (index < 0) return;
messages[index] = messages[index].isRead && !message.isRead
? message.copyWith(isRead: true)
: message;
}
/// Označ všechny zprávy jako přečtené
+13 -3
View File
@@ -146,6 +146,9 @@ class LocalIdentity {
/// kontaktu poslat první zpráva. Obě pole jsou náhodná — s identitou je
/// spojuje jen sám uživatel tím, že je sdílí ve svém odkazu.
class PublicIdentity {
/// Nejvyšší délka jména z odkazu/QR — delší odkaz je vadný vstup.
static const maxDisplayNameLength = 64;
PublicIdentity({
required this.displayName,
required this.identityKey,
@@ -214,7 +217,14 @@ class PublicIdentity {
as Map<String, dynamic>;
if (map['v'] != 2) return null;
final identityKey = base64Decode(map['ik'] as String);
if (identityKey.length != 33) return null;
// 33 B = typový bajt 0x05 + Curve25519; jiný prefix libsignal odmítne
// až při první zprávě, tady ho odmítneme hned.
if (identityKey.length != 33 || identityKey[0] != 0x05) return null;
final aid = map['aid'] as String?;
// Relay dává 32 hex znaků; starší testovací odkazy kratší — hex a strop stačí.
if (aid != null && !RegExp(r'^[0-9a-f]{8,64}$').hasMatch(aid)) return null;
final rawName = (map['n'] as String).trim();
if (rawName.length > maxDisplayNameLength) return null;
final da = map['da'] as String?;
Uint8List? deliveryAddress;
if (da != null) {
@@ -224,10 +234,10 @@ class PublicIdentity {
if (deliveryAddress.length != 32) return null;
}
return PublicIdentity(
displayName: map['n'] as String,
displayName: rawName,
identityKey: identityKey,
createdAt: DateTime.parse(map['ts'] as String),
accountId: map['aid'] as String?,
accountId: aid,
deliveryAddress: deliveryAddress,
);
} catch (_) {
@@ -55,6 +55,9 @@ final messagingStartProvider = FutureProvider<void>((ref) async {
// se provider invaliduje (viz unlock cesty) a spustí start znovu.
final storage = ref.read(storageProvider);
if (!storage.isUnlocked) return;
// Nátlaková relace: žádná síť. Messaging by se registroval skutečným
// identity klíčem (409 každých 30 s = důkaz pro pozorovatele sítě).
if (storage.isDecoyMode) return;
final service = ref.read(messagingServiceProvider);
if (service.isStarted) return;
+11 -3
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:screen_protector/screen_protector.dart';
import 'package:sepot/core/backup/backup_service.dart';
@@ -57,14 +58,21 @@ final typingIndicatorsProvider =
TypingIndicatorsNotifier.new);
/// Zabezpečení obrazovky: blok screenshotů (Android FLAG_SECURE) + skrytí
/// obsahu v přepínači appek (iOS). Default VYPNUTO — blokuje i vlastní
/// screenshoty, tak ať se uživatel rozhodne sám.
/// obsahu v přepínači appek (iOS). Výchozí ZAPNUTO jako u Signalu — stejné
/// jako nativní default v MainActivity, takže přepínač už neukazuje
/// „vypnuto" u obrazovky, která chráněná je.
class ScreenSecurityNotifier extends AsyncNotifier<bool> {
static const key = 'screen_security_enabled';
// Debug build bez ochrany: QA na emulátoru potřebuje screenshoty.
static const vychozi = !kDebugMode;
@override
Future<bool> build() async {
final v = (await ref.read(storageProvider).getSetting<bool>(key)) ?? false;
final storage = ref.read(storageProvider);
// Před odemčením se nastavení přečíst nedá; nic neaplikovat (nativní
// default platí) a po unlocku se provider invaliduje.
if (!storage.isUnlocked) return vychozi;
final v = (await storage.getSetting<bool>(key)) ?? vychozi;
await _apply(v);
return v;
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: sepot
description: "Šepot — Soukromý messenger s E2E šifrováním"
publish_to: 'none'
version: 1.2.0+23
version: 1.2.2+25
environment:
sdk: ^3.10.8
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/services/messaging_service.dart';
/// Přijímací cesta zahazuje obsah neznámého typu (ACK + drop, protože po
/// upgradu už nejde dešifrovat znovu). Každý typ, který appka sama posílá,
/// proto MUSÍ být v množině známých typů. V 1.2.0 tu chyběl `call` — každá
/// nabídka hovoru se tiše zahodila a hovory nešly přijmout.
void main() {
test('všechny odesílané typy obsahu umí přijímací cesta zpracovat', () {
const sent = {
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote', 'call',
'ginv', 'gleave', 'gmeta', 'gop', 'gsync',
};
expect(MessagingService.knownContentTypes, containsAll(sent));
});
test('signalizace hovoru je známý typ', () {
expect(MessagingService.knownContentTypes, contains('call'));
});
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/models/conversation.dart';
import 'package:sepot/models/message.dart';
/// Read state only moves forward. Updates that carry an older copy of the
/// message (attachment download finishing, delivery status) must not flip a
/// message the user has already read back to unread.
void main() {
Message zprava({bool isRead = false}) => Message(
id: 'm1',
senderId: 'alice',
recipientId: 'bob',
type: MessageType.text,
content: 'hi',
timestamp: DateTime.parse('2026-10-10T09:00:00.000'),
isRead: isRead,
);
test('stale unread copy does not undo a read message', () {
final c = Conversation(contactId: 'alice')..addMessage(zprava());
c.markAllRead('bob');
c.updateMessage(zprava().copyWith(content: 'hi (edited)'));
expect(c.messages.single.isRead, isTrue);
expect(c.messages.single.content, 'hi (edited)');
expect(c.unreadCount('bob'), 0);
});
test('update can still mark an unread message as read', () {
final c = Conversation(contactId: 'alice')..addMessage(zprava());
c.updateMessage(zprava(isRead: true));
expect(c.unreadCount('bob'), 0);
});
}

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