33 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
Julius Joska 492db55a04 chore(relay): state the logging invariant directly 2026-10-03 22:47:32 +02:00
Julius Joska 617dd05267 release: 1.2.0 — new look, group admins, calls
Bumps the app to 1.2.0 (build 23) and adds the changelog entry.

The iOS export compliance flag is now false: Šepot uses only standard,
publicly available encryption (the Signal protocol via libsignal and
SQLCipher), which qualifies for the mass-market exemption and needs no
export compliance documentation from Apple.
2026-10-03 22:44:59 +02:00
Julius Joska 5246a97db2 feat(app): redesigned interface, polls, drafts, forwarding and mentions
A new look across the app and a set of everyday messaging features.

Interface
- Home screen with Chats, Contacts and Settings tabs, a large title,
  search across chats and message text (diacritics-insensitive), filters
  and pinned chats.
- Conversation view with a translucent header and composer, grouped
  bubbles, author avatars and colours in groups, swipe to reply, an
  "N new messages" marker, a jump-to-latest button and in-chat search.
- Contact and group profiles: verification, shared photos, groups in
  common, disappearing messages, mute, block; members, admin badges,
  adding people, rename and leave for groups.
- Full-screen photo viewer that pages through the whole conversation.
- A stable avatar colour per person and a clearly visible read state.

Messaging
- Polls in chats and groups, single or multiple choice, with votes that
  can be changed or withdrawn; older clients see them as text.
- Drafts survive leaving a chat and show in the chat list.
- Forwarding of messages, photos, files and voice messages.
- Notes to self: a local-only, encrypted chat that never reaches the
  relay.
- @mentions in groups that notify even when the group is muted.
- Typing indicators sent as ephemeral messages, opt-in and mutual like
  read receipts.
- Delivery and read times per message and per group member.
- Profile photos, cropped, resized and stripped of metadata, delivered
  encrypted to contacts.
- Voice message playback at 1.5x and 2x.
- Call buttons in 1:1 chats and call log entries.

Fixes
- Muted groups no longer notify: muting was checked against the
  author's chat instead of the group.
- A blocked contact could unblock itself by changing its delivery
  address.
- Read receipts no longer regress to delivered when a late delivery
  receipt arrives.
- A message marked as failed corrects itself once it is delivered.
- Double ticks in groups now mean delivered to every member.
2026-10-03 22:44:53 +02:00
Julius Joska 933a74c900 feat(app): 1:1 voice and video calls
End-to-end encrypted calls between two contacts over WebRTC. Call
signalling (offer, answer, hang-up) travels as ordinary Signal-encrypted
messages through the relay; media is DTLS-SRTP, peer to peer when
possible and through the operator's TURN server otherwise.

- Non-trickle ICE (candidates gathered for up to 3 s), 45 s ring
  timeout, busy handling, and a call log entry in the conversation.
- The system call UI rings even when the app is closed: CallKit from a
  PushKit VoIP push on iOS, a full-screen incoming call from an FCM
  wake-up on Android. The wake-up carries no caller; the app learns who
  is calling from the queued offer once it is unlocked.
- New permissions: camera for video, audio routing and Bluetooth
  headsets, network state; iOS background modes audio and voip.

Push on Android: a fresh install now picks UnifiedPush when a
distributor is installed and FCM otherwise, instead of leaving wake-ups
off. Without a wake-up Android puts the app to sleep and messages wait
until it is opened. Users can switch at any time in Settings.
2026-10-03 22:44:39 +02:00
Julius Joska 9a638545dd feat(app): admin-signed group membership
Until now any member could change a group's roster or name. Groups now
have admins, and every change to membership, roles or the name is an
operation signed by an admin's identity key (XEdDSA). Recipients verify
the signature against the admin's key from their own contacts, never a
key carried in the same message; the relay plays no part.

- Operations form a hash chain with an epoch; gaps or forks are held back
  and resolved by a signed state from an admin.
- Groups created by older versions migrate with the creator as admin; if
  the creator has left, the smallest fingerprint takes over
  deterministically on every device.
- Mixed groups with older clients keep working in a compatibility mode
  until every member advertises admin support; that capability can only
  be added, never withdrawn.
- The last admin cannot step down or be removed.

The verification rules live in a network-free policy module with its own
tests.
2026-10-03 22:44:11 +02:00
Julius Joska 69687d0acf feat(relay): ephemeral delivery, call wake-ups and TURN credentials
Protocol and relay groundwork for typing indicators and 1:1 calls.

- ContentHint.EPHEMERAL: delivered only to an online recipient, never
  queued or pushed; the sender gets Receipt.STATUS_DROPPED otherwise.
- ContentHint.CALL: a call offer is queued like a normal message, but an
  offline recipient is woken immediately — a PushKit VoIP push on iOS
  (priority 10, 45 s expiry) or a short-lived FCM data message on
  Android. The relay learns that a device is being called, not by whom.
- RegisterPush.voip_token stores the iOS VoIP token next to the regular
  APNs token (migration 0004).
- TurnRequest/TurnCredentials: logged-in devices receive time-limited
  coturn credentials (use-auth-secret REST scheme, HMAC-SHA1), so the
  TURN server is never an open relay. Configured with SEPOT_TURN_URLS and
  SEPOT_TURN_SECRET or SEPOT_TURN_SECRET_FILE; without them calls fall
  back to direct connections and STUN.
2026-10-03 22:43:53 +02:00
Julius Joska 4e3d7de32c fix(app): strip EXIF and text metadata from sent images
The image library copies EXIF through resize and orientation baking, and
the JPEG/PNG encoders write it back out. Re-encoding alone therefore did
not remove metadata: sent photos and their thumbnails could carry GPS
coordinates, the camera model and the capture time.

- Clear EXIF and text chunks on both the full image and the thumbnail
  right before encoding.
- Treat decoder exceptions on malformed data like an undecodable image.
- Add canStripMetadata() so the UI can warn before a format the library
  cannot read (HEIC, RAW) goes out as an untouched document.
2026-10-03 22:43:43 +02:00
Julius Joska 414d39c835 fix(relay): let APNs hold wake-ups for devices that are offline
The APNs wake-up was sent with apns-expiration 0, so a phone without
connectivity at that moment (tunnel, flight) never received it and did
not learn about the message until someone opened the app. APNs now keeps
the wake-up for as long as the envelope stays in the relay queue.

Priority stays at 5: APNs rejects priority 10 for background pushes.
Immediate delivery on iOS needs a visible alert push handled by a
Notification Service Extension, which is an app change.
2026-09-29 18:30:11 +02:00
Julius Joska 2fd4c2e988 perf(relay): disable Nagle's algorithm on accepted connections
axum does not set TCP_NODELAY. Right after AUTH the relay writes
AUTH_OK and immediately the first queued DELIVER; with Nagle enabled the
second write waited for the peer's delayed ACK, adding about 40 ms to
the path a woken-up app takes to fetch its messages.
2026-09-29 18:30:11 +02:00
Julius Joska e5d0e54b80 fix(relay): keep a reconnected device online when its stale connection times out
After a network change (Wi-Fi to mobile data) the app reconnects right
away, while the relay only notices the old connection is dead once the
idle timeout expires. When that stale handler finally exited it removed
the device from the online map by address alone, which dropped the new,
healthy connection. From then on every message was stored as offline and
only a push wake-up could fetch it, even though the app was connected.

The online map is now its own type: a newly authenticated connection
takes over delivery for the address, and a closing connection removes
the entry only if it still points at itself. Tests cover the registry
and the full route after a stale connection ends.
2026-09-29 18:30:11 +02:00
Julius Joska bd51fef5cc fix(relay): add the attempt_count column the delivery cap relies on
The delivery-attempt cap reads and increments message_queue.attempt_count,
but the published init schema never created that column. On a fresh
database every queue flush failed, so queued messages were never handed
out after a reconnect.

The column is added in a new migration with IF NOT EXISTS, so installs
that already carry it from an older init schema migrate as a no-op.
2026-09-29 18:30:11 +02:00
Julius Joska 79ad411d7e fix(app): read the version from the installed package
The version was a hand-written constant that had to be kept in sync with
`version:` in pubspec.yaml. It was not: the constant still said 1.1.0 while
the shipped build was 1.1.2+22, so every abuse report carried a version that
no build ever had — and reports could not be traced back to a build.

AppInfo reads versionName / CFBundleShortVersionString, which Flutter derives
from the pubspec, so the two cannot drift apart again. Reports now carry the
build number as well; when the version is not known yet the field is omitted
rather than guessed.

Also adds the timestamp regression test that covers MessagingService.wireSentAt,
which shipped in 1.1.2 without its test.
2026-09-04 10:18:37 +02:00
Julius Joska f903e21e3a release: 1.1.2 — reliable message delivery 2026-08-13 12:33:24 +02:00
Julius Joska 923a6a01bd fix(app): say it out loud when wake-ups do not work
Registration failures from the distributor were swallowed, so push looked
enabled while nothing arrived. Without wake-ups Android suspends the app
and messages only show up once the user opens it — which looks like broken
delivery even though the relay has them queued.

The failure is now remembered and surfaced in Settings, and the chat list
carries a dismissible line explaining the state.
2026-08-13 12:23:30 +02:00
Julius Joska 9985b79aa6 fix(app): ping the relay, retry stuck messages, drop duplicates
A message sent into a dead socket ended up as failed — attachments even
stayed on "sending" forever — and only the user could send it again by
hand. Mobile NAT drops idle TCP silently, so neither side knew.

- pingInterval on the WebSocket, so the client notices a dead link itself
- outbox: after every reconnect, stuck messages are re-sent (batch of 30,
  up to a day old); permanent errors are not retried
- sendAttachment gained the try/catch it was missing, otherwise the
  attachment never reached the outbox
- de-duplicate by message id: a retry re-sends the SAME message, which
  would otherwise show up twice on the other side
2026-08-13 12:23:24 +02:00
Julius Joska b82f50f888 fix(relay): keep-alive on WebSocket, cap delivery attempts, honest receipts
Messages arrived late or not at all, for two independent reasons.

Dead connections: neither side sent a ping, and after AUTH the server
waited for a frame without a timeout. Mobile NAT drops idle TCP silently,
so the client wrote into a void while the relay still held the device in
its online map — and therefore never sent a push wake-up either. The ping
is a protocol-level one rather than v1::Ping, because dart:io answers
those on its own; the fix works with clients already in the wild.

Poisoned queue: a client decrypts an envelope (ratchet advances), stores
it and acknowledges it. If that ACK is lost, a second decryption attempt
can only fail, so the envelope is never acknowledged — and was redelivered
on every connect until its 30-day TTL, without ever reaching the recipient.
attempt_count existed from the first migration but was never counted; it
now bounds redelivery.

Receipts reported DELIVERED_ONLINE whenever a write into the bounded mpsc
channel succeeded, which it does even for a long-dead socket. Online is now
claimed only for devices that recently sent something.

Push send failures were logged at debug while the relay runs at info, so a
rejected wake-up looked exactly like a working one.
2026-08-13 12:23:17 +02:00
Julius Joska 4f2cbdbdc4 harden(relay): gate forwarded-IP on trusted proxies, bound queue flush
Rate limiting and the per-IP WebSocket cap trusted X-Forwarded-For and
X-Real-Ip unconditionally. Onion traffic bypasses the reverse proxy, so a
client could set the header itself and slip past every limit. Forwarded
headers are now honoured only from peers inside SEPOT_TRUSTED_PROXIES;
everyone else is keyed by peer address, and onion clients share one bucket.

The queue flush also read the whole backlog into memory at once, and the
certificate authority kept the trust-root private key in the database even
though it is only ever needed offline.
2026-08-13 12:23:07 +02:00
Julius Joska 90a1390716 fix: reliable first-message send, QR routing, auto-lock recovery
Fixes reported by testers and found in an app review:

- First message right after install no longer fails. Before the app
  finished registering with the relay, the outgoing message could crash on
  a null delivery address. The profile field is now optional and the send
  path throws a clear "still connecting, try again shortly" error while
  registration completes in the background.
- The QR code and share link now appear only once the delivery address is
  ready. Previously a link generated before the first connection lacked
  routing, so the other side could not send the first message. The screen
  now shows a connecting state and reactively swaps in the QR when ready.
- Incomplete contact links are rejected on import instead of failing at
  first send, with a clear prompt to ask for a fresh link.
- Auto-lock no longer silences the app: after locking and unlocking,
  messaging is restarted (it previously stayed dead until app restart).
- Group messages are marked failed on a non-per-member send error instead
  of hanging in the "sending" state forever.
- Hardened the relay client against a rare duplicate connection when
  toggling Tor or locking mid-connect, and fromShareString now rejects a
  delivery address of the wrong length.
- Pin unifiedpush below 6.2.0 to drop the webcrypto dependency (its native
  framework lacks LC_ENCRYPTION_INFO and blocked the iOS upload).
- Skip Android release lint (network dependency check hangs on the build
  host and adds no value for a Flutter app).
2026-07-18 19:53:41 +02:00
Julius Joska 7d7f589fc5 release: 1.1.0 — photo preview, Settings fix, incoming-content hardening
Version 1.1.0 (build 20). Also fixes the hardcoded version string shown
in Settings → About (it still said 0.3.2).
2026-07-13 08:37:16 +02:00
Julius Joska 641d70b76e fix: Settings crash, photo preview before sending, incoming-content hardening
Settings crashed with a TypeError: the profile card passed a
SepotFingerprint object where the localized string formatter expects
a String (hidden behind a `dynamic` parameter). Also fixes RangeError
in avatar initials (empty names, double spaces) and switches initials
to grapheme clusters so emoji don't get split.

New photo preview screen: a captured or picked photo no longer sends
immediately — it opens in a preview with freehand drawing (7 colours,
undo), draggable text overlay, 90° rotation and a caption field.
Nothing leaves the device until Send is tapped.

Incoming-content hardening: decryption and content processing are now
separated in the delivery path. Once an envelope is decrypted the
ratchet has advanced, so redelivery can never help — permanently
malformed content (FormatException/TypeError) is acked and dropped
instead of poisoning the queue on every reconnect. Attachment metadata
now validates base64 of the crypto fields up front, and a corrupt
inline thumbnail no longer breaks bubble rendering.

PNG without real transparency (screenshots, edited photos) is now
re-encoded as JPEG — several times smaller on the wire.

Tests: 116 (new widget tests for Settings and the photo preview,
AttachmentMeta wire validation).
2026-07-13 08:15:01 +02:00
Julius Joska a60aaca326 release: 1.0.0 — five languages, Tor transport, group chats
First production release. See CHANGELOG.md for details.
2026-07-11 23:56:30 +02:00
Julius Joska 92eb316a83 tests: make design-rationale comments self-contained
Test comments referenced internal design documents that are not part
of this repository. The relevant invariants are now stated inline.
2026-07-11 20:02:56 +02:00
217 changed files with 20656 additions and 2253 deletions
+229
View File
@@ -6,6 +6,235 @@ verzování dle [ajtak standardu](#verzování).
## [Nevydáno] ## [Nevydáno]
## [1.2.2] – 2026-10-11
### Opraveno
- **Přečtený chat se dál ukazoval jako nepřečtený.** Zpráva, která přišla do
otevřeného chatu, zůstala po návratu do seznamu tučně s odznakem; teď se
přečte hned, když je chat na obrazovce (a appka v popředí). Stažení přílohy
nebo dodatečné doručení už nevrátí přečtenou zprávu mezi nepřečtené.
- Gesto „odpověď tahem“ při zavírání chatu v ladicím buildu padalo na
vytvoření animace nad zrušeným prvkem.
- Na iPhonu hlásila dlaždice Push v Nastavení chybu (volala UnifiedPush,
který existuje jen na Androidu).
### Změněno
- **Nová ikona aplikace:** Š, které se ve spodní části rozpadá do teček.
## [1.2.1] – 2026-10-09
### Opraveno
- **Hovory se nedaly přijmout.** Příchozí signalizace hovoru se zahazovala dřív,
než se dostala k obsluze hovoru; volaný jen „zazvonil“ z budíčku a pak nic.
- **Obrazovka hovoru po zavěšení:** avatar a jméno uskakovaly vlevo nahoru.
- **Obnova ze 24 slov na novém telefonu** končila nekonečným čekáním — server
odmítal už existující identitu. Nové zařízení ji teď převezme podpisem.
- **Tichá ztráta zpráv:** úklid mizících zpráv mohl přepsat čerstvě přijatou
zprávu; automatický zámek během zpracování zprávy ji mohl nenávratně zahodit;
souběžné šifrování pro téhož příjemce dávalo obálky, které druhá strana
zahodila. Všechny tři cesty opraveny.
- **Velká fronta po delším výpadku** (stovky zpráv) se už neodpojuje v půlce
a server ji po osmi pokusech nemaže.
- **Nátlakový PIN** otevíral relaci se skutečnou identitou, frází i přístupem ke
smazání účtu. Teď je izolovaná: náhodná identita, žádná síť, žádný zásah do
klíčů; smazání v ní nechá skutečný účet být.
- Zvonění z budíčku bez skutečné nabídky končí po 12 s; server budíčky hovoru
omezuje na cíl, takže cizí člověk se znalostí adresy telefon nerozezvoní
pořád dokola.
- Dodatečné odeslání z fronty neztrácí anketu ani zmínky; budíček při
„připojeno“ bez provozu obnoví spojení.
### Bezpečnost
- Hádání PINu: po pěti chybách prodleva 30 s, dál dvojnásobná až 15 minut.
- Nátlakový a skutečný PIN se ověřují jednou derivací — čas odemčení neprozradí,
který byl zadán.
- Biometrie jen biometricky (zámek telefonu appku neodemkne); zabezpečení
obrazovky je výchozí zapnuté a po odemčení skutečně platí; na iOS klíče
nepřecházejí do iCloud Keychain ani záloh; filtr proti dotykům přes překryvná okna.
- Server: doplňování jednorázových klíčů a rotace podepsaných klíčů
(`POST /v1/keys`), omezení výdeje jednorázových klíčů na adresu, chyby v logu
bez adres push endpointů, připnutí DNS u budíčků, globální strop příloh,
limity délek, `/v1/ready`, uvolnění spojení při vypnutí, revize a zdroj
v `/v1/server-info`.
## [1.2.0] – 2026-10-01
### Nový vzhled
- **Domovská obrazovka s kartami Chaty, Kontakty a Nastavení.** Velký
nadpis, hledání v chatech i v obsahu zpráv (najde „klic“ i „klíč“),
filtry Vše / Nepřečtené / Skupiny, připnuté chaty nahoře.
- **Konverzace s hloubkou.** Průsvitná hlavička a pole pro psaní nad jemnou
tapetou, zprávy od stejného člověka se skládají do bloků, ve skupině
avatar a jméno autora v jeho barvě.
- **Odpověď tahem doprava**, řádek „N nových zpráv“ na místě, kde jsi
skončil, a tlačítko na nejnovější zprávy s počtem nových.
- **Hledání v konverzaci** se zvýrazněním a přeskakováním mezi výsledky;
klepnutí na citaci skočí na původní zprávu.
- **Profil kontaktu a skupiny** po klepnutí na jméno nebo avatar: ověření,
sdílené fotky, společné skupiny, mizící zprávy, ztlumení, blokování;
u skupiny členové, přidání lidí, přejmenování a odchod.
- **Fotky přes celou obrazovku s listováním** všemi fotkami konverzace.
- **Každý člověk má vlastní barvu avataru** — všude stejnou, i po
přejmenování.
- **„Přečteno“ je poznat na první pohled** — plná pilulka místo
modrošedých fajfek, které na korálové bublině skoro nebyly vidět.
### Přidáno
- **Hovory a videohovory** 1:1, šifrované end-to-end. Tlačítka v hlavičce
chatu; telefon zazvoní i se zavřenou aplikací (na iPhonu přes CallKit).
Když přímé spojení nejde, hovor šifrovaně přepošle náš server.
V chatu zůstane záznam „Hovor · 3:12“ nebo „Zmeškaný hovor“.
- **Rozepsaná zpráva zůstane.** Když odejdeš z chatu, text v poli
počká, a v seznamu chatů je vidět jako „Koncept: …“.
- **Přeposlání** zprávy, fotky, souboru i hlasovky do jiného chatu nebo
skupiny (podržet zprávu → Přeposlat).
- **Poznámky pro sebe** v kartě Kontakty: chat sám se sebou na odkazy,
fotky a poznámky. Zůstává jen v telefonu, šifrovaně, nic nejde na server.
- **@zmínky ve skupinách.** Po napsání „@“ se nabídnou členové; zmíněný
dostane upozornění, i když má skupinu ztlumenou.
- **Rychlejší přehrávání hlasovek** — 1,5× a 2×, rychlost si Šepot pamatuje.
- **Správci skupin.** Zakladatel je správce a může udělat správcem
i další členy. Odebrat člena, měnit správce a přejmenovat skupinu smí
jen správce; každou takovou změnu podepíše svým klíčem a ostatní telefony
podpis ověří, takže ji nikdo nemůže podvrhnout ani za správce vydávat.
U členů je odznak „Správce“, ostatní vidí akce neaktivní s vysvětlením.
Dokud má někdo ve skupině starší verzi Šepotu, přidávat lidi smí dál
každý, aby se skupina nerozdělila; se staršími verzemi skupiny fungují
jako dosud. Existující skupiny se převedou samy, bez jediné zprávy.
- **Profilová fotka.** V Nastavení klepni na svůj avatar. Fotka se ořízne,
zmenší a zbaví metadat a tvým kontaktům dorazí šifrovaně s další
zprávou; u nich se ukáže všude místo iniciál.
- **Ankety** v chatu i ve skupině (plus → Anketa): jedna nebo víc
odpovědí, pruhy výsledků a kdo jak hlasoval, hlas jde změnit i zrušit.
Starší verze Šepotu uvidí otázku a možnosti jako text.
- **„píše…“** v hlavičce chatu, v seznamu chatů a jako tři tečky pod
poslední zprávou. Stejně jako potvrzení přečtení je volitelné (výchozí
vypnuto) a oboustranné. Offline protistraně se nic neposílá do fronty ani
ji to nebudí — server takové oznámení doručí jen živému spojení.
- **Čas doručení a přečtení.** Pod poslední odeslanou zprávou je vidět, kdy
dorazila a kdy ji druhá strana přečetla. Po podržení zprávy ukáže „Info
o zprávě“ čas odeslání, doručení a přečtení — ve skupině u každého člena
zvlášť. Čas přečtení se ukazuje jen lidem, kteří potvrzení přečtení
sdílejí oboustranně (výchozí vypnuto).
- **Přidání lidí do skupiny.** Do existující skupiny jde nově přidat
kontakty z nabídky skupiny nebo ze seznamu členů.
### Změněno
- **Notifikace jsou na Androidu zapnuté od začátku.** Bez nich systém
Šepot na pozadí uspí a zprávy čekají, dokud aplikaci neotevřeš. Kdo má
UnifiedPush distributor (např. ntfy), dostane ho; ostatním se zapne
budíček přes Google (FCM). Google přitom vidí jen to, že má telefon
probudit — ne obsah ani odesílatele. Přepnout či vypnout jde v Nastavení.
- **Dvojitá fajfka ve skupině** svítí až ve chvíli, kdy zpráva dorazila
všem členům, ne jen prvnímu.
### Opraveno
- **Ztlumená skupina dál posílala upozornění.** Ztlumení se u skupinové
zprávy kontrolovalo v chatu s autorem, ne ve skupině.
- **Odesílané fotky nesly polohu (GPS) a údaje o telefonu.** Zpracování
fotky před odesláním metadata EXIF nečekaně zachovávalo — v odeslané
fotce i v jejím náhledu. Nově se před odesláním vždy odstraní.
- **Zablokovaný kontakt se mohl sám odblokovat**, když změnil adresu pro
doručování.
- **„Přečteno“ se už nevrací na „doručeno“.** Když potvrzení o doručení
dorazilo po potvrzení přečtení (typicky po výpadku spojení), zpráva se
tvářila jako nepřečtená.
- **Zpráva označená jako neodeslaná** se po doručení sama opraví na
doručenou.
- **Nastavení ukazovalo starou verzi aplikace.** Číslo verze bylo v kódu
napsané ručně a při vydání 1.1.1 a 1.1.2 se ho zapomnělo přepsat, takže
se všude hlásila 1.1.0 — včetně hlášení zneužití, u kterých pak nešlo
dohledat, ze které verze přišla. Verze se nově čte z nainstalovaného
balíčku a k hlášení se přikládá i číslo buildu.
## [1.1.2] – 2026-08-13
### Opraveno
- **Zprávy už nečekají na otevření aplikace.** Když mobilní síť tiše
zahodila nečinné spojení, Šepot o tom nevěděl: tvářil se připojeně, ale
odeslaná zpráva skončila chybou a příchozí nedorazily — a všechny se pak
vysypaly naráz po restartu aplikace. Spojení se teď hlídá pravidelným
pingem na obou stranách, takže výpadek se pozná a obnoví v řádu sekund.
- **Neodeslaná zpráva se pošle sama.** Dosud zůstala označená jako
neodeslaná (u příloh dokonce natrvalo na „odesílá se") a poslat znovu ji
musel člověk ručně. Nově se po obnovení spojení odešle sama; opakované
doručení téže zprávy druhá strana pozná a nezobrazí ji dvakrát.
- **Zprávy, které nešly doručit, se přestaly hromadit.** Když se cestou
ztratilo potvrzení o doručení, obálka na serveru zůstala a znovu a znovu
se posílala, aniž by ji šlo přečíst. Server teď takové zprávy po několika
marných pokusech zahodí, místo aby jimi zdržoval doručování ostatních.
### Přidáno
- **Upozornění, když nechodí notifikace.** Bez nich Android aplikaci na
pozadí uspí a zprávy uvidíš, až ji otevřeš. Šepot to teď řekne rovnou v
seznamu chatů a nabídne nastavení; hlášku jde odmítnout. Selhání
registrace u distributora se už nezamlčí.
## [1.1.1] – 2026-07-18
### Opraveno
- **Odeslání první zprávy hned po instalaci už neselže.** Krátce po
založení účtu, než se aplikace stihla připojit k relayi, mohla zpráva
spadnout s hláškou „Odeslání selhalo". Nově se v tu chvíli ukáže
srozumitelné „Šepot se ještě připojuje — zkus to za chvíli" a připojení
proběhne na pozadí.
- **QR kód a odkaz se ukážou až s doručovací adresou.** Když sis chtěl
ukázat vlastní QR dřív, než se aplikace poprvé připojila, vygeneroval se
neúplný odkaz — protistrana pak nemohla poslat první zprávu („Kontakt
nemá doručovací adresu"). Nově se místo neúplného kódu ukáže stav
připojování a QR se objeví, jakmile je hotové.
- **Neúplný odkaz kontaktu se odmítne při přidání**, ne až při prvním
odeslání — s jasnou výzvou požádat protistranu o nový odkaz.
- **Zamčení po nečinnosti už neumlčí aplikaci.** Po automatickém zamknutí
a opětovném odemčení se zprávy zase přijímají i odesílají — dřív mohl
messaging zůstat po odemčení vypnutý až do restartu aplikace.
- **Skupinová zpráva se při chybě neodeslání označí jako neodeslaná**
místo aby zůstala navždy ve stavu „odesílá se".
- Ošetřeno vzácné zdvojení spojení s relayem při rychlém přepnutí Toru
nebo zamčení během připojování.
### Změněno
- Interní úklid závislostí notifikací (odstraněna nepoužívaná větev, která
komplikovala sestavení verze pro iOS).
## [1.1.0] – 2026-07-13
### Přidáno
- **Náhled fotky před odesláním.** Vyfocená nebo vybraná fotka už
neodchází okamžitě — otevře se náhled, kde jde fotku otočit, kreslit
po ní prstem (7 barev, krok zpět), přidat na ni text (tažením se
přesouvá, klepnutím upraví) a připojit popisek. Odejde až po klepnutí
na Odeslat; zavření náhledu nic nepošle.
### Opraveno
- **Nastavení se nevykreslilo** — obrazovka místo obsahu ukazovala
chybovou hlášku „Tahle obrazovka se nevykreslila". Profilová karta
předávala do překladu otisk klíče jako objekt místo textu.
- Avatar s iniciálami spadl na prázdném jménu nebo jménu s dvojitou
mezerou; iniciály se nově skládají po grafémech, takže nerozpůlí emoji.
- **Písma se dodávají v aplikaci** (Inter, JetBrains Mono — OFL).
Aplikace dřív stahovala fonty za běhu z Google CDN — síťový požadavek
na cizí server navíc a binárka odlišná od zveřejněného zdroje.
- **Trvale vadná příchozí zpráva už neotravuje frontu.** Zpráva, jejíž
obsah nejde po dešifrování zpracovat (poškozená pole od upraveného
klienta), se potvrdí a zahodí — dřív se doručovala donekonečna při
každém připojení. Nedešifrovatelné obálky fronta dál drží.
- Příloha s poškozeným náhledem od protistrany shodila vykreslení
bubliny; vadný náhled se nově zahodí při příjmu a base64 krypto polí
příloh se validuje.
- Zachycení výměny důvěryhodního kořene relaye (signál MITM) na
pozadí — dřív tichá neošetřená výjimka, teď příznak čitelný pro UI.
- PNG bez průhlednosti (screenshoty, upravené fotky) se posílají jako
JPEG — několikanásobně menší přenos; PNG zůstává jen pro obrázky se
skutečnou průhledností.
## [1.0.0] – 2026-07-11
První ostrá verze. Šepot je od 11. 7. 2026 open source — kompletní
zdrojový kód aplikace i serveru je veřejný na
[gitea.ajtak.it/julius.joska/sepot](https://gitea.ajtak.it/julius.joska/sepot)
(zrcadlo [github.com/juliusjoska/sepot](https://github.com/juliusjoska/sepot)).
### Přidáno ### Přidáno
- **Aplikace mluví pěti jazyky: česky, anglicky, německy, rusky - **Aplikace mluví pěti jazyky: česky, anglicky, německy, rusky
a italsky.** Jazyk se řídí systémem (neznámé jazyky spadnou na a italsky.** Jazyk se řídí systémem (neznámé jazyky spadnou na
+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. The design goal is **metadata minimization with a public threat model** — never "zero metadata". The relay is deliberately dumb: it cannot read message content, does not learn who sent a message, and does not log IP addresses. What it *does* see is documented honestly below.
> Status: pre-1.0. The Android client is the primary target; iOS builds run through TestFlight. See [`CHANGELOG.md`](CHANGELOG.md). > Status: 1.2.x. Android is on Google Play (production 1.1.2, 1.2.0 in internal testing); iOS builds run through TestFlight. See [`CHANGELOG.md`](CHANGELOG.md).
## Features ## Features
@@ -14,7 +14,7 @@ The design goal is **metadata minimization with a public threat model** — neve
- **Group chats** — up to 128 members, pairwise fan-out so every member gets full forward secrecy; the relay never learns a group exists. - **Group chats** — up to 128 members, pairwise fan-out so every member gets full forward secrecy; the relay never learns a group exists.
- **Attachments** — images and files are encrypted client-side (AES-256-GCM) and uploaded as opaque blobs with a TTL; image metadata (EXIF/GPS) is stripped on send. - **Attachments** — images and files are encrypted client-side (AES-256-GCM) and uploaded as opaque blobs with a TTL; image metadata (EXIF/GPS) is stripped on send.
- **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers). - **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers).
- **Push without Google by default** — UnifiedPush/ntfy; FCM (Android) and APNs (iOS) are opt-in. A push wake-up never carries content, sender, or count. - **Push with or without Google** — UnifiedPush/ntfy when a distributor is installed, otherwise FCM on Android (default since 1.2.0, can be switched off) and APNs on iOS. A push wake-up never carries content, sender, or count.
- **Tor transport** (Android, via Orbot) — routes relay traffic over a `.onion` to hide the client IP. Off by default. - **Tor transport** (Android, via Orbot) — routes relay traffic over a `.onion` to hide the client IP. Off by default.
- **Five languages** — Czech, English, German, Russian, Italian. The UI follows the system language and can be overridden in Settings. - **Five languages** — Czech, English, German, Russian, Italian. The UI follows the system language and can be overridden in Settings.
@@ -71,10 +71,10 @@ UI strings live in `app/lib/l10n/app_*.arb` (`cs`, `en`, `de`, `ru`, `it`) and a
## Security ## Security
Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **bezpecnost@sepot.cz** rather than opening a public issue. Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **security@sepot.cz** rather than opening a public issue — see [`SECURITY.md`](SECURITY.md) and [`/.well-known/security.txt`](https://sepot.cz/.well-known/security.txt).
## License ## License
Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](LICENSE). Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](LICENSE).
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository, F-Droid, direct APK). That permission covers only Šepot's own code. The bundled Signal `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md). The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository and the direct APK releases). That permission covers only Šepot's own code. The bundled Signal `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
+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 Š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>).
+8
View File
@@ -42,6 +42,14 @@ android {
jvmTarget = JavaVersion.VERSION_17.toString() jvmTarget = JavaVersion.VERSION_17.toString()
} }
// Lint na release buildu vynechán: jeho síťové kontroly (novější verze
// závislostí) na omezené build síti visí donekonečna a pro Flutter appku
// nemají přínos. Analýzu Dartu dělá `flutter analyze`, Kotlin je minimální.
lint {
checkReleaseBuilds = false
abortOnError = false
}
defaultConfig { defaultConfig {
applicationId = "cz.sepot.sepot" applicationId = "cz.sepot.sepot"
// libsignal binárky vyžadují Android 7.0+ (API 24) // libsignal binárky vyžadují Android 7.0+ (API 24)
+8 -1
View File
@@ -1,8 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- Nahrávání hlasových zpráv --> <!-- Nahrávání hlasových zpráv a hovory -->
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Hovory (WebRTC): kamera u videohovoru, přepínání reproduktoru
a Bluetooth sluchátka, sledování změny sítě během hovoru. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Firebase Messaging tiše přidává AD_ID oprávnění; Šepot inzertní ID <!-- Firebase Messaging tiše přidává AD_ID oprávnění; Šepot inzertní ID
nepoužívá (žádné reklamy/tracking) → odebíráme, ať sedí Play deklarace. --> nepoužívá (žádné reklamy/tracking) → odebíráme, ať sedí Play deklarace. -->
<uses-permission android:name="com.google.android.gms.permission.AD_ID" <uses-permission android:name="com.google.android.gms.permission.AD_ID"
@@ -1,5 +1,6 @@
package cz.sepot.sepot package cz.sepot.sepot
import android.content.pm.ApplicationInfo
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager import android.view.WindowManager
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterFragmentActivity
@@ -7,10 +8,20 @@ import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity() { class MainActivity : FlutterFragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací // Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací.
// Výchozí stav; Dart přepínač „Zabezpečení obrazovky" ho může po
// odemčení vypnout (default zapnuto, jako u Signalu).
// Debug build: screenshoty povolené kvůli QA na emulátoru (adb screencap
// by u FLAG_SECURE okna selhal); release vždy chráněný.
val debuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
if (!debuggable) {
window.setFlags( window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE,
) )
} }
// Tapjacking: dotyky přes překryvné okno jiné appky (částečně
// průhledný overlay nad PIN klávesnicí) se zahodí.
window.decorView.filterTouchesWhenObscured = true
}
} }
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
+95 -2
View File
@@ -1,8 +1,14 @@
import CallKit
import Flutter import Flutter
import PushKit
import UIKit import UIKit
import WebRTC
import flutter_callkit_incoming
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
// PushKit registry musí žít po celou dobu běhu appky, jinak VoIP pushe nechodí.
private var voipRegistry: PKPushRegistry?
private var apnsChannel: FlutterMethodChannel? private var apnsChannel: FlutterMethodChannel?
// Token dorazí asynchronně z APNs; když si o něj Dart řekne dřív, podržíme ho. // Token dorazí asynchronně z APNs; když si o něj Dart řekne dřív, podržíme ho.
private var pendingToken: String? private var pendingToken: String?
@@ -29,13 +35,100 @@ import UIKit
apnsChannel = channel apnsChannel = channel
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
// Hovory: VoIP push z relaye → CallKit.
let registry = PKPushRegistry(queue: DispatchQueue.main)
registry.delegate = self
registry.desiredPushTypes = [.voIP]
voipRegistry = registry
// Zvukovou relaci u hovoru řídí CallKit; WebRTC ji smí zapnout až po
// jeho aktivaci (didActivateAudioSession), jinak hovor z uzamčené
// obrazovky nemá zvuk.
RTCAudioSession.sharedInstance().useManualAudio = true
RTCAudioSession.sharedInstance().isAudioEnabled = false
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
// MARK: - PushKit (VoIP)
func pushRegistry(_ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType) {
let token = credentials.token.map { String(format: "%02x", $0) }.joined()
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(token)
}
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
}
// iOS vyžaduje, aby každý VoIP push HNED skončil hovorem v CallKitu — jinak
// appku ukončí a po opakování VoIP pushe přestane doručovat. Relay neví,
// kdo volá (sealed sender), proto zvoní „Šepot“; Dart zvonění spáruje
// s nabídkou podle `extra.push`, až ji po připojení dešifruje.
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
guard type == .voIP else {
completion()
return
}
let data = flutter_callkit_incoming.Data(
id: UUID().uuidString, nameCaller: "Šepot", handle: "Šepot", type: 0)
data.appName = "Šepot"
data.duration = 45000
data.extra = ["push": true]
data.handleType = "generic"
data.supportsVideo = true
data.maximumCallGroups = 1
data.maximumCallsPerCallGroup = 1
data.supportsHolding = false
data.supportsDTMF = false
data.includesCallsInRecents = false
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming(data, fromPushKit: true) {
completion()
}
// Běží-li Dart, ať se hned připojí a vyzvedne nabídku z fronty.
apnsChannel?.invokeMethod("onWake", arguments: nil)
}
// MARK: - CallKit
// Rozhodnutí (přijmout/odmítnout) řeší Dart přes události pluginu;
// tady jen potvrdit akci CallKitu, jinak by ji systém po čase zrušil.
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
action.fulfill()
}
func onDecline(_ call: Call, _ action: CXEndCallAction) {
action.fulfill()
}
func onEnd(_ call: Call, _ action: CXEndCallAction) {
action.fulfill()
}
func onTimeOut(_ call: Call) {}
func didActivateAudioSession(_ audioSession: AVAudioSession) {
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
RTCAudioSession.sharedInstance().isAudioEnabled = true
}
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession)
RTCAudioSession.sharedInstance().isAudioEnabled = false
}
func providerDidReset() {}
// APNs vydal device token → předáme Dartu (ten ho pošle relayi). // APNs vydal device token → předáme Dartu (ten ho pošle relayi).
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Foundation.Data
) { ) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined() let token = deviceToken.map { String(format: "%02x", $0) }.joined()
pendingToken = token pendingToken = token
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

+5 -3
View File
@@ -5,9 +5,11 @@
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>remote-notification</string> <string>remote-notification</string>
<string>audio</string>
<string>voip</string>
</array> </array>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>The camera is used to scan contacts' QR codes and to take photos for attachments.</string> <string>The camera is used to scan contacts' QR codes, take photos for attachments and for video calls.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>The photo library is used to pick photos to send in a conversation.</string> <string>The photo library is used to pick photos to send in a conversation.</string>
<key>NSFaceIDUsageDescription</key> <key>NSFaceIDUsageDescription</key>
@@ -41,7 +43,7 @@
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<true/> <false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
@@ -66,6 +68,6 @@
<key>UIStatusBarHidden</key> <key>UIStatusBarHidden</key>
<false/> <false/>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Šepot needs the microphone to record voice messages.</string> <string>Šepot needs the microphone for voice messages and calls.</string>
</dict> </dict>
</plist> </plist>
+2 -2
View File
@@ -1,5 +1,5 @@
/* Šepot — texty oprávnění iOS (čeština) */ /* Šepot — texty oprávnění iOS (čeština) */
"NSCameraUsageDescription" = "Kamera slouží ke skenování QR kódů kontaktů a focení příloh."; "NSCameraUsageDescription" = "Kamera slouží ke skenování QR kódů kontaktů, focení příloh a videohovorům.";
"NSPhotoLibraryUsageDescription" = "Galerie slouží k výběru fotek pro odeslání v konverzaci."; "NSPhotoLibraryUsageDescription" = "Galerie slouží k výběru fotek pro odeslání v konverzaci.";
"NSFaceIDUsageDescription" = "Face ID odemyká aplikaci místo PINu."; "NSFaceIDUsageDescription" = "Face ID odemyká aplikaci místo PINu.";
"NSMicrophoneUsageDescription" = "Šepot potřebuje mikrofon pro nahrávání hlasových zpráv."; "NSMicrophoneUsageDescription" = "Šepot potřebuje mikrofon pro hlasové zprávy a hovory.";
+2 -2
View File
@@ -1,5 +1,5 @@
/* Šepot — iOS-Berechtigungstexte (Deutsch) */ /* Šepot — iOS-Berechtigungstexte (Deutsch) */
"NSCameraUsageDescription" = "Die Kamera dient zum Scannen der QR-Codes deiner Kontakte und zum Aufnehmen von Fotos für Anhänge."; "NSCameraUsageDescription" = "Die Kamera dient zum Scannen der QR-Codes deiner Kontakte, zum Aufnehmen von Fotos für Anhänge und für Videoanrufe.";
"NSPhotoLibraryUsageDescription" = "Die Fotomediathek dient zum Auswählen von Fotos, die du in einer Unterhaltung sendest."; "NSPhotoLibraryUsageDescription" = "Die Fotomediathek dient zum Auswählen von Fotos, die du in einer Unterhaltung sendest.";
"NSFaceIDUsageDescription" = "Face ID entsperrt die App anstelle der PIN."; "NSFaceIDUsageDescription" = "Face ID entsperrt die App anstelle der PIN.";
"NSMicrophoneUsageDescription" = "Šepot benötigt das Mikrofon zum Aufnehmen von Sprachnachrichten."; "NSMicrophoneUsageDescription" = "Šepot benötigt das Mikrofon für Sprachnachrichten und Anrufe.";
+2 -2
View File
@@ -1,5 +1,5 @@
/* Šepot — iOS permission strings (English) */ /* Šepot — iOS permission strings (English) */
"NSCameraUsageDescription" = "The camera is used to scan contacts' QR codes and to take photos for attachments."; "NSCameraUsageDescription" = "The camera is used to scan contacts' QR codes, take photos for attachments and for video calls.";
"NSPhotoLibraryUsageDescription" = "The photo library is used to pick photos to send in a conversation."; "NSPhotoLibraryUsageDescription" = "The photo library is used to pick photos to send in a conversation.";
"NSFaceIDUsageDescription" = "Face ID unlocks the app instead of your PIN."; "NSFaceIDUsageDescription" = "Face ID unlocks the app instead of your PIN.";
"NSMicrophoneUsageDescription" = "Šepot needs the microphone to record voice messages."; "NSMicrophoneUsageDescription" = "Šepot needs the microphone for voice messages and calls.";
+2 -2
View File
@@ -1,5 +1,5 @@
/* Šepot — testi delle autorizzazioni iOS (italiano) */ /* Šepot — testi delle autorizzazioni iOS (italiano) */
"NSCameraUsageDescription" = "La fotocamera serve a scansionare i codici QR dei contatti e a scattare foto per gli allegati."; "NSCameraUsageDescription" = "La fotocamera serve a scansionare i codici QR dei contatti, a scattare foto per gli allegati e per le videochiamate.";
"NSPhotoLibraryUsageDescription" = "La libreria foto serve a scegliere le foto da inviare in una conversazione."; "NSPhotoLibraryUsageDescription" = "La libreria foto serve a scegliere le foto da inviare in una conversazione.";
"NSFaceIDUsageDescription" = "Face ID sblocca l'app al posto del PIN."; "NSFaceIDUsageDescription" = "Face ID sblocca l'app al posto del PIN.";
"NSMicrophoneUsageDescription" = "Šepot ha bisogno del microfono per registrare i messaggi vocali."; "NSMicrophoneUsageDescription" = "Šepot ha bisogno del microfono per i messaggi vocali e le chiamate.";
+2 -2
View File
@@ -1,5 +1,5 @@
/* Šepot — тексты разрешений iOS (русский) */ /* Šepot — тексты разрешений iOS (русский) */
"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов и съёмки фотографий для вложений."; "NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов, съёмки фотографий для вложений и видеозвонков.";
"NSPhotoLibraryUsageDescription" = "Галерея используется для выбора фотографий, которые вы отправляете в чате."; "NSPhotoLibraryUsageDescription" = "Галерея используется для выбора фотографий, которые вы отправляете в чате.";
"NSFaceIDUsageDescription" = "Face ID разблокирует приложение вместо PIN-кода."; "NSFaceIDUsageDescription" = "Face ID разблокирует приложение вместо PIN-кода.";
"NSMicrophoneUsageDescription" = "Для записи голосовых сообщений приложению Šepot нужен доступ к микрофону."; "NSMicrophoneUsageDescription" = "Приложению Šepot нужен доступ к микрофону для голосовых сообщений и звонков.";
+46 -2
View File
@@ -5,7 +5,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/core/calls/call_controller.dart';
import 'package:sepot/features/auth/change_pin_screen.dart'; import 'package:sepot/features/auth/change_pin_screen.dart';
import 'package:sepot/features/calls/call_screen.dart';
import 'package:sepot/providers/messaging_provider.dart'; import 'package:sepot/providers/messaging_provider.dart';
import 'package:sepot/features/onboarding/onboarding_screen.dart'; import 'package:sepot/features/onboarding/onboarding_screen.dart';
import 'package:sepot/features/onboarding/restore_screen.dart'; import 'package:sepot/features/onboarding/restore_screen.dart';
@@ -17,6 +19,8 @@ import 'package:sepot/features/conversation/conversation_screen.dart';
import 'package:sepot/features/contacts/contact_verify_screen.dart'; import 'package:sepot/features/contacts/contact_verify_screen.dart';
import 'package:sepot/features/contacts/contacts_screen.dart'; import 'package:sepot/features/contacts/contacts_screen.dart';
import 'package:sepot/features/groups/create_group_screen.dart'; import 'package:sepot/features/groups/create_group_screen.dart';
import 'package:sepot/features/profile/contact_profile_screen.dart';
import 'package:sepot/features/profile/group_profile_screen.dart';
import 'package:sepot/features/settings/settings_screen.dart'; import 'package:sepot/features/settings/settings_screen.dart';
import 'package:sepot/features/safety/report_screen.dart'; import 'package:sepot/features/safety/report_screen.dart';
import 'package:sepot/features/qr_pairing/qr_share_screen.dart'; import 'package:sepot/features/qr_pairing/qr_share_screen.dart';
@@ -62,6 +66,21 @@ final _router = GoRouter(
path: '/conversation/:contactId', path: '/conversation/:contactId',
builder: (context, state) => ConversationScreen( builder: (context, state) => ConversationScreen(
contactId: state.pathParameters['contactId']!, contactId: state.pathParameters['contactId']!,
startSearch: state.uri.queryParameters['search'] == '1',
),
),
GoRoute(
path: '/contact/:contactId',
builder: (context, state) => ContactProfileScreen(
contactId: state.pathParameters['contactId']!,
fromChat: state.uri.queryParameters['from'] == 'chat',
),
),
GoRoute(
path: '/group/:groupId',
builder: (context, state) => GroupProfileScreen(
groupId: state.pathParameters['groupId']!,
fromChat: state.uri.queryParameters['from'] == 'chat',
), ),
), ),
GoRoute( GoRoute(
@@ -72,6 +91,11 @@ final _router = GoRouter(
path: '/create-group', path: '/create-group',
builder: (context, state) => const CreateGroupScreen(), builder: (context, state) => const CreateGroupScreen(),
), ),
GoRoute(
path: '/group/:groupId/add',
builder: (context, state) =>
CreateGroupScreen(addToGroupId: state.pathParameters['groupId']),
),
GoRoute( GoRoute(
path: '/verify/:contactId', path: '/verify/:contactId',
builder: (context, state) => ContactVerifyScreen( builder: (context, state) => ContactVerifyScreen(
@@ -101,6 +125,10 @@ final _router = GoRouter(
path: '/change-pin', path: '/change-pin',
builder: (context, state) => const ChangePinScreen(), builder: (context, state) => const ChangePinScreen(),
), ),
GoRoute(
path: '/call',
builder: (context, state) => const CallScreen(),
),
], ],
); );
@@ -116,6 +144,16 @@ class SepotApp extends ConsumerWidget {
// null = systémový jazyk; než se volba načte z keystore, jede systém. // null = systémový jazyk; než se volba načte z keystore, jede systém.
final locale = ref.watch(localeProvider).valueOrNull; final locale = ref.watch(localeProvider).valueOrNull;
// Příchozí i odchozí hovor otevře obrazovku hovoru odkudkoli.
ref.listen(callProvider.select((s) => s.phase), (previous, phase) {
final starting =
phase == CallPhase.incoming || phase == CallPhase.outgoing;
final path = _router.routerDelegate.currentConfiguration.uri.path;
if (starting && previous != phase && path != '/call') {
_router.push('/call');
}
});
return _AutoLockGate( return _AutoLockGate(
child: MaterialApp.router( child: MaterialApp.router(
onGenerateTitle: (context) => context.l10n.appTitle, onGenerateTitle: (context) => context.l10n.appTitle,
@@ -196,8 +234,12 @@ class _AutoLockGateState extends ConsumerState<_AutoLockGate>
final kde = _router.state.uri.path; final kde = _router.state.uri.path;
if (_bezZamku.contains(kde)) return; if (_bezZamku.contains(kde)) return;
// Zastavit službu a zamknout DB. messagingStartProvider ZÁMĚRNĚ
// neinvalidujeme tady — invalidace by ho eager-rebuildla ještě nad
// odemčeným storage (těsně před lock) a messaging by nastartoval nad DB,
// kterou hned zavíráme. Restart řeší odemykací obrazovky přes
// ref.invalidate(messagingStartProvider) až po úspěšném unlocku.
await ref.read(messagingServiceProvider).stop(); await ref.read(messagingServiceProvider).stop();
ref.invalidate(messagingStartProvider);
storage.lock(); storage.lock();
_router.go('/pin-lock'); _router.go('/pin-lock');
} }
@@ -247,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');
} }
} }
+44
View File
@@ -0,0 +1,44 @@
/// Verze aplikace čtená z nainstalovaného balíčku.
///
/// Proč ne konstanta: verze se dřív psala ručně do `AppConstants` a musela se
/// hlídat proti `version:` v pubspec.yaml. Nehlídala se — produkční 1.1.2+22
/// posílala v hlášeních zneužití „1.1.0" a podle hlášení tak nešlo dohledat,
/// ze kterého buildu přišlo. `package_info_plus` čte `versionName` (Android)
/// / `CFBundleShortVersionString` (iOS), které Flutter generuje přímo
/// z pubspecu — rozejít se to už nemůže a ruční editace odpadá.
library;
import 'package:package_info_plus/package_info_plus.dart';
class AppInfo {
AppInfo._();
/// Zástupný text, dokud verzi nezná (nebo když ji nešlo přečíst). Znak, ne
/// slovo — appka běží v pěti jazycích a české „neznámá" by v nich čouhalo.
static const String unknownVersion = '—';
static PackageInfo? _info;
/// Načte verzi z balíčku. Volat jednou při startu, před prvním `runApp`.
static Future<void> init() async {
_info = await PackageInfo.fromPlatform();
}
/// Verze pro uživatele: `1.1.2`.
static String get version => _info?.version ?? unknownVersion;
/// Verze pro diagnostiku: `1.1.2+22`. Build číslo je jediné, čím se hlášení
/// zneužití přiřadí konkrétnímu buildu — do UI nepatří, do hlášení ano.
/// `null`, dokud verzi nezná: hlášení pak pole vynechá, ať server dostane
/// prázdno místo vymyšlené hodnoty.
static String? get versionWithBuild {
final info = _info;
if (info == null) return null;
// iOS bez vyplněného CFBundleVersion vrací v buildNumber rovnou verzi
// (dokumentované chování balíčku) — nelepit „1.1.2+1.1.2".
if (info.buildNumber.isEmpty || info.buildNumber == info.version) {
return info.version;
}
return '${info.version}+${info.buildNumber}';
}
}
+535
View File
@@ -0,0 +1,535 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' hide MessageType;
import 'package:sepot/core/calls/call_ringer.dart';
import 'package:sepot/models/call_log.dart';
import 'package:sepot/models/contact.dart';
import 'package:sepot/models/message.dart';
import 'package:sepot/providers/conversations_provider.dart';
import 'package:sepot/providers/identity_provider.dart';
import 'package:sepot/providers/messaging_provider.dart';
import 'package:sepot/providers/push_provider.dart';
import 'package:sepot/providers/storage_provider.dart';
import 'package:uuid/uuid.dart';
/// Fáze hovoru.
enum CallPhase { idle, outgoing, incoming, connecting, active, ended }
class CallState {
const CallState({
this.phase = CallPhase.idle,
this.callId,
this.contactId,
this.contactName = '',
this.video = false,
this.outgoing = false,
this.connectedAt,
this.muted = false,
this.speaker = false,
this.cameraOff = false,
this.outcome,
});
final CallPhase phase;
final String? callId;
final String? contactId;
final String contactName;
final bool video;
final bool outgoing;
final DateTime? connectedAt;
final bool muted;
final bool speaker;
final bool cameraOff;
/// Proč hovor skončil (jen ve fázi [CallPhase.ended]).
final CallOutcome? outcome;
bool get busy => phase != CallPhase.idle && phase != CallPhase.ended;
CallState copyWith({
CallPhase? phase,
DateTime? connectedAt,
bool? muted,
bool? speaker,
bool? cameraOff,
CallOutcome? outcome,
}) =>
CallState(
phase: phase ?? this.phase,
callId: callId,
contactId: contactId,
contactName: contactName,
video: video,
outgoing: outgoing,
connectedAt: connectedAt ?? this.connectedAt,
muted: muted ?? this.muted,
speaker: speaker ?? this.speaker,
cameraOff: cameraOff ?? this.cameraOff,
outcome: outcome ?? this.outcome,
);
}
/// Řídí jeden hovor 1:1: WebRTC spojení, signalizaci přes E2E kanál
/// Šepotu a záznam výsledku do chatu. Druhý souběžný hovor se odmítne
/// jako `busy`.
class CallController extends Notifier<CallState> {
/// Nabídka starší než tohle už nezvoní — jen se zapíše jako zmeškaná.
/// Stejná mez jako expirace VoIP pushe na relayi.
static const ringTimeout = Duration(seconds: 45);
/// ICE bez trickle: nabídka jde až po sběru kandidátů, nejdéle po této
/// době (jinak by pomalé rozhraní zdrželo zvonění).
static const _iceGatherTimeout = Duration(seconds: 3);
/// Jak dlouho je vidět obrazovka „Hovor skončil“, než se zavře.
static const endedLinger = Duration(seconds: 2);
RTCPeerConnection? _pc;
MediaStream? _localStream;
String? _pendingOfferSdp;
Contact? _peer;
Timer? _ringTimer;
/// Id systémového zvonění (CallKit / Android) k běžícímu příchozímu
/// hovoru; null = zvoní jen obrazovka appky.
String? _ringId;
final localRenderer = RTCVideoRenderer();
final remoteRenderer = RTCVideoRenderer();
bool _renderersReady = false;
@override
CallState build() {
CallRinger.listen(
onAccept: _onRingAccept,
onDecline: _onRingDecline,
onEnded: _onRingEnded,
onVoipToken: () => ref.read(pushServiceProvider).refreshVoipToken(),
);
ref.onDispose(() {
unawaited(_cleanup());
localRenderer.dispose();
remoteRenderer.dispose();
});
return const CallState();
}
// ── Odchozí ────────────────────────────────────────────────────────
Future<void> startCall(Contact contact, {required bool video}) async {
if (state.busy) return;
_peer = contact;
state = CallState(
phase: CallPhase.outgoing,
callId: const Uuid().v4(),
contactId: contact.id,
contactName: contact.displayName,
video: video,
outgoing: true,
speaker: video,
);
try {
await _openMedia(video: video);
final pc = await _createPeerConnection();
final offer = await pc.createOffer();
await pc.setLocalDescription(offer);
final sdp = await _gatheredSdp(pc);
await ref.read(messagingServiceProvider).sendCallSignal(
contact,
{'cid': state.callId, 'k': 'offer', 'sdp': sdp, 'vid': video},
ring: true,
);
_ringTimer = Timer(ringTimeout, () {
if (state.phase == CallPhase.outgoing) {
unawaited(_hangup(CallOutcome.unanswered, notify: true));
}
});
} catch (_) {
await _finish(CallOutcome.failed);
}
}
// ── Příchozí ───────────────────────────────────────────────────────
/// Obsluha příchozí signalizace z [MessagingService.onCallSignal].
Future<void> onSignal(Contact contact, Map<String, dynamic> signal) async {
final cid = signal['cid'];
final kind = signal['k'];
if (cid is! String || kind is! String) return;
if (kind == 'offer') {
await _onOffer(contact, cid, signal);
return;
}
// Ostatní patří jen k právě běžícímu hovoru s tímtéž člověkem.
if (cid != state.callId || contact.id != state.contactId) return;
switch (kind) {
case 'answer':
final sdp = signal['sdp'];
if (sdp is! String || state.phase != CallPhase.outgoing) return;
_ringTimer?.cancel();
state = state.copyWith(phase: CallPhase.connecting);
await _pc?.setRemoteDescription(RTCSessionDescription(sdp, 'answer'));
case 'hangup':
final answered = state.connectedAt != null;
await _finish(answered
? CallOutcome.completed
: state.outgoing
? CallOutcome.rejected
: CallOutcome.missed);
case 'reject':
await _finish(CallOutcome.rejected);
case 'busy':
await _finish(CallOutcome.busy);
}
}
Future<void> _onOffer(
Contact contact, String cid, Map<String, dynamic> signal) async {
final sdp = signal['sdp'];
if (sdp is! String) return;
final video = signal['vid'] == true;
final sentAt = DateTime.fromMillisecondsSinceEpoch(
(signal['ts'] as num?)?.toInt() ?? 0);
// Pozdě doručená nabídka (telefon byl mimo signál) — nezvonit.
if (DateTime.now().difference(sentAt) > ringTimeout) {
await _log(contact.id,
CallLog(outcome: CallOutcome.missed, outgoing: false, video: video),
at: sentAt);
return;
}
if (state.busy) {
if (cid != state.callId) {
await ref
.read(messagingServiceProvider)
.sendCallSignal(contact, {'cid': cid, 'k': 'busy'});
}
return;
}
_peer = contact;
_pendingOfferSdp = sdp;
state = CallState(
phase: CallPhase.incoming,
callId: cid,
contactId: contact.id,
contactName: contact.displayName,
video: video,
speaker: video,
);
_ringTimer = Timer(ringTimeout, () {
if (state.phase == CallPhase.incoming) {
unawaited(_finish(CallOutcome.missed));
}
});
await _attachSystemRing(cid, contact.displayName, video);
}
/// Spáruje hovor se systémovým zvoněním. Zvonění z budíčku relaye už
/// běží (možná ho uživatel mezitím přijal na zamčené obrazovce); jinak
/// se zvonění spustí, jen když appka není v popředí — v popředí stačí
/// vlastní obrazovka hovoru.
Future<void> _attachSystemRing(String cid, String name, bool video) async {
final pushRing = await CallRinger.pendingPushRing();
if (pushRing != null) {
_ringId = pushRing.id;
if (pushRing.accepted) {
await accept();
return;
}
if (CallRinger.declinedEarly.remove(pushRing.id)) {
await reject();
return;
}
// Spárováno s nabídkou: aby grace z `ringFromPush` zvonění neukončila,
// nahradí se jménem volajícího (bez značky push).
await _claimPushRing(pushRing.id, name, video);
return;
}
final foreground =
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed;
if (!foreground) {
_ringId = cid;
await CallRinger.ringFor(callId: cid, name: name, video: video);
}
}
/// Převezme zvonění z budíčku: jméno volajícího místo „Šepot" a bez
/// značky push (grace v [CallRinger.ringFromPush] ho pak nechá být).
Future<void> _claimPushRing(String pushId, String name, bool video) async {
if (await CallRinger.isAccepted(pushId)) return; // uživatel už zvedl
await CallRinger.end(pushId);
_ringId = state.callId;
await CallRinger.ringFor(callId: state.callId!, name: name, video: video);
}
void _onRingAccept(String id) {
if (id == _ringId && state.phase == CallPhase.incoming) {
unawaited(accept());
}
}
void _onRingDecline(String id) {
if (id == _ringId && state.phase == CallPhase.incoming) {
unawaited(reject());
} else if (!state.busy) {
CallRinger.declinedEarly.add(id);
}
}
/// Zavěšení ze systémového rozhraní (zamčená obrazovka, sluchátka).
void _onRingEnded(String id) {
if (id == _ringId && state.busy) unawaited(hangUp());
}
Future<void> accept() async {
final peer = _peer;
final offer = _pendingOfferSdp;
if (state.phase != CallPhase.incoming || peer == null || offer == null) {
return;
}
_ringTimer?.cancel();
state = state.copyWith(phase: CallPhase.connecting);
try {
await _openMedia(video: state.video);
final pc = await _createPeerConnection();
await pc.setRemoteDescription(RTCSessionDescription(offer, 'offer'));
final answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
final sdp = await _gatheredSdp(pc);
await ref.read(messagingServiceProvider).sendCallSignal(
peer, {'cid': state.callId, 'k': 'answer', 'sdp': sdp});
} catch (_) {
await _hangup(CallOutcome.failed, notify: true);
}
}
Future<void> reject() async {
if (state.phase != CallPhase.incoming) return;
await _hangup(CallOutcome.rejected, notify: true, kind: 'reject');
}
/// Zavěšení v jakékoli fázi (vytáčení, spojování, hovor).
Future<void> hangUp() async {
if (!state.busy) return;
final outcome = state.connectedAt != null
? CallOutcome.completed
: state.outgoing
? CallOutcome.unanswered
: CallOutcome.rejected;
await _hangup(outcome, notify: true);
}
// ── Ovládání během hovoru ──────────────────────────────────────────
void toggleMute() {
final muted = !state.muted;
for (final t in _localStream?.getAudioTracks() ?? const []) {
t.enabled = !muted;
}
state = state.copyWith(muted: muted);
}
Future<void> toggleSpeaker() async {
final speaker = !state.speaker;
await Helper.setSpeakerphoneOn(speaker);
state = state.copyWith(speaker: speaker);
}
void toggleCamera() {
final off = !state.cameraOff;
for (final t in _localStream?.getVideoTracks() ?? const []) {
t.enabled = !off;
}
state = state.copyWith(cameraOff: off);
}
Future<void> switchCamera() async {
final track = _localStream?.getVideoTracks().firstOrNull;
if (track != null) await Helper.switchCamera(track);
}
// ── WebRTC ─────────────────────────────────────────────────────────
Future<void> _openMedia({required bool video}) async {
if (!_renderersReady) {
await localRenderer.initialize();
await remoteRenderer.initialize();
_renderersReady = true;
}
final stream = await navigator.mediaDevices.getUserMedia({
'audio': true,
'video': video ? {'facingMode': 'user'} : false,
});
_localStream = stream;
localRenderer.srcObject = video ? stream : null;
await Helper.setSpeakerphoneOn(video);
}
Future<RTCPeerConnection> _createPeerConnection() async {
final pc = await createPeerConnection({
'iceServers': await _iceServers(),
'sdpSemantics': 'unified-plan',
});
final local = _localStream;
for (final track in local?.getTracks() ?? const <MediaStreamTrack>[]) {
await pc.addTrack(track, local!);
}
pc.onTrack = (event) {
if (event.streams.isNotEmpty) {
remoteRenderer.srcObject = event.streams.first;
}
};
pc.onConnectionState = (s) {
switch (s) {
case RTCPeerConnectionState.RTCPeerConnectionStateConnected:
if (state.connectedAt == null) {
state = state.copyWith(
phase: CallPhase.active, connectedAt: DateTime.now());
final ring = _ringId;
if (ring != null) unawaited(CallRinger.connected(ring));
}
case RTCPeerConnectionState.RTCPeerConnectionStateFailed:
unawaited(_hangup(
state.connectedAt != null
? CallOutcome.completed
: CallOutcome.failed,
notify: true));
default:
break;
}
};
_pc = pc;
return pc;
}
/// STUN bez údajů: coturn na témž hostiteli odpovídá na STUN bez
/// přihlášení, takže i když relay TURN údaje nevydá (503, timeout),
/// jde aspoň přímé spojení přes NAT. Dřív byl seznam prázdný = jen host
/// kandidáti = hovor jen v jedné LAN.
static const _stunFallback = {
'urls': ['stun:turn.sepot.cz:3478'],
};
/// STUN i TURN běží na stejném serveru; bez údajů z relaye aspoň STUN
/// (přímé spojení, když obě strany nejsou za symetrickým NATem).
Future<List<Map<String, dynamic>>> _iceServers() async {
final turn = await ref.read(messagingServiceProvider).turnCredentials();
if (turn == null || turn.urls.isEmpty) return const [_stunFallback];
return [
{
'urls': turn.urls,
'username': turn.username,
'credential': turn.credential,
},
];
}
/// SDP po sběru ICE kandidátů (non-trickle).
Future<String> _gatheredSdp(RTCPeerConnection pc) async {
final done = Completer<void>();
pc.onIceGatheringState = (s) {
if (s == RTCIceGatheringState.RTCIceGatheringStateComplete &&
!done.isCompleted) {
done.complete();
}
};
if (pc.iceGatheringState ==
RTCIceGatheringState.RTCIceGatheringStateComplete) {
done.complete();
}
await done.future.timeout(_iceGatherTimeout, onTimeout: () {});
final local = await pc.getLocalDescription();
final sdp = local?.sdp;
if (sdp == null) throw StateError('no local description');
return sdp;
}
// ── Konec hovoru ───────────────────────────────────────────────────
Future<void> _hangup(CallOutcome outcome,
{required bool notify, String kind = 'hangup'}) async {
final peer = _peer;
final cid = state.callId;
if (notify && peer != null && cid != null) {
try {
await ref
.read(messagingServiceProvider)
.sendCallSignal(peer, {'cid': cid, 'k': kind});
} catch (_) {
// Protistrana zavěšení nedostala — její hovor skončí sám, až
// spojení vypadne (ICE failed) nebo vyprší zvonění.
}
}
await _finish(outcome);
}
Future<void> _finish(CallOutcome outcome) async {
if (state.phase == CallPhase.idle || state.phase == CallPhase.ended) {
return;
}
final connectedAt = state.connectedAt;
final contactId = state.contactId;
final log = CallLog(
outcome: outcome,
outgoing: state.outgoing,
video: state.video,
durationSeconds: connectedAt == null
? 0
: DateTime.now().difference(connectedAt).inSeconds,
);
state = state.copyWith(phase: CallPhase.ended, outcome: outcome);
await _cleanup();
if (contactId != null) await _log(contactId, log);
Timer(endedLinger, () {
if (state.phase == CallPhase.ended) state = const CallState();
});
}
Future<void> _cleanup() async {
_ringTimer?.cancel();
_ringTimer = null;
final ring = _ringId;
_ringId = null;
// Konec zvonění vyvolá událost „ukončeno“ — to už je stav mimo hovor,
// takže ji _onRingEnded ignoruje.
if (ring != null) unawaited(CallRinger.end(ring));
_pendingOfferSdp = null;
_peer = null;
for (final t in _localStream?.getTracks() ?? const <MediaStreamTrack>[]) {
await t.stop();
}
await _localStream?.dispose();
_localStream = null;
await _pc?.close();
_pc = null;
if (_renderersReady) {
localRenderer.srcObject = null;
remoteRenderer.srcObject = null;
}
}
/// Řádek „Hovor · 3:12“ do chatu. Jen lokálně — nic se neposílá.
Future<void> _log(String contactId, CallLog log, {DateTime? at}) async {
final myId = ref.read(identityProvider).valueOrNull?.id;
if (myId == null) return;
final message = Message(
id: const Uuid().v4(),
senderId: log.outgoing ? myId : contactId,
recipientId: log.outgoing ? contactId : myId,
type: MessageType.text,
timestamp: at ?? DateTime.now(),
// Záznam hovoru není nepřečtená zpráva — kromě zmeškaného.
isRead: log.outcome != CallOutcome.missed,
isDelivered: true,
callLog: log,
);
await ref.read(storageProvider).addMessage(contactId, message);
ref.invalidate(conversationsProvider);
ref.invalidate(conversationProvider(contactId));
}
}
final callProvider =
NotifierProvider<CallController, CallState>(CallController.new);
+191
View File
@@ -0,0 +1,191 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter_callkit_incoming/entities/entities.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:uuid/uuid.dart';
/// Systémové zvonění (CallKit na iOS, celoobrazovkové na Androidu) pro
/// hovory, které přijdou, když appka není v popředí.
///
/// Budíček z relaye nenese, kdo volá — relay to neví. Zvonění z budíčku
/// proto ukáže „Šepot“ a označí se `extra.push`; až appka dešifruje
/// nabídku, [CallController] ho spáruje s hovorem podle toho.
class CallRinger {
CallRinger._();
static const _appName = 'Šepot';
/// Zvonění vzniklé z budíčku, ke kterému ještě nedorazila nabídka.
static const pushMarker = 'push';
/// Jak dlouho zvoní — stejné jako expirace budíčku na relayi.
static const ringMillis = 45000;
/// Zvonění z budíčku, ke kterému do té doby nedorazila nabídka, se
/// ukončí samo. Relay budíček hovoru nijak neověřuje (sealed sender),
/// takže kdokoli se znalostí adresy by jinak nechal telefon zvonit
/// 45 s na každou obálku s hintem CALL. Nabídka od
/// skutečného volajícího dorazí do pár sekund po připojení.
static const pushRingGrace = Duration(seconds: 12);
static StreamSubscription<CallEvent?>? _sub;
/// Id zvonění, která uživatel odmítl dřív, než dorazila nabídka.
/// (Přijetí si pamatuje samo zvonění — `isAccepted` v [pendingPushRing].)
static final declinedEarly = <String>{};
static void listen({
required void Function(String id) onAccept,
required void Function(String id) onDecline,
required void Function(String id) onEnded,
required void Function() onVoipToken,
}) {
_sub ??= FlutterCallkitIncoming.onEvent.listen((event) {
switch (event) {
case CallEventActionCallAccept(:final callKitParams):
onAccept(callKitParams.id);
case CallEventActionCallDecline(:final callKitParams):
onDecline(callKitParams.id);
case CallEventActionCallEnded(:final callKitParams):
onEnded(callKitParams.id);
case CallEventActionDidUpdateDevicePushTokenVoip():
onVoipToken();
default:
break;
}
});
}
static Future<String?> voipToken() async {
final token = await FlutterCallkitIncoming.getDevicePushTokenVoIP();
return token != null && token.isNotEmpty ? token : null;
}
/// Zvonění pro dešifrovanou nabídku (appka běží na pozadí).
static Future<void> ringFor({
required String callId,
required String name,
required bool video,
}) =>
FlutterCallkitIncoming.showCallkitIncoming(
_params(id: callId, name: name, video: video));
/// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme.
/// Vrací až po [pushRingGrace]: pokud se zvonění do té doby nespárovalo
/// s nabídkou (přijetí nebo ukončení ho z aktivních hovorů odebere, nebo
/// ho [CallController] převezme), tiše se ukončí. Background isolate FCM
/// handleru tím zůstane naživu přesně tak dlouho, jak je potřeba.
static Future<void> ringFromPush() async {
final id = const Uuid().v4();
await FlutterCallkitIncoming.showCallkitIncoming(_params(
id: id, name: _appName, video: false, extra: {pushMarker: true}));
await Future<void>.delayed(pushRingGrace);
await endIfUnmatched(id);
}
/// iOS: zvonění z VoIP pushe zakládá nativní AppDelegate (CallKit musí
/// zazvonit hned), takže grace z [ringFromPush] tam neběží. Po startu
/// Dartu se proto každé nepřevzaté zvonění z budíčku ohlídá odsud.
static Future<void> expireStalePushRings() async {
final calls = await FlutterCallkitIncoming.activeCalls();
for (final call in calls) {
if (call.extra?[pushMarker] == true && !call.isAccepted) {
final id = call.id;
unawaited(Future<void>.delayed(pushRingGrace, () => endIfUnmatched(id)));
}
}
}
/// Ukončí zvonění z budíčku, které nikdo nepřevzal (bez záznamu
/// o zmeškaném hovoru — nebylo od koho).
static Future<void> endIfUnmatched(String id) async {
final calls = await FlutterCallkitIncoming.activeCalls();
for (final call in calls) {
if (call.id == id && call.extra?[pushMarker] == true && !call.isAccepted) {
await FlutterCallkitIncoming.endCall(id);
}
}
}
/// Zvonění z budíčku, které čeká na nabídku (nejnovější), a jestli ho
/// uživatel už přijal — typicky na zamčené obrazovce, než appka naběhla.
static Future<({String id, bool accepted})?> pendingPushRing() async {
final calls = await FlutterCallkitIncoming.activeCalls();
for (final call in calls.reversed) {
if (call.extra?[pushMarker] == true) {
return (id: call.id, accepted: call.isAccepted);
}
}
return null;
}
static Future<bool> isAccepted(String id) async {
final calls = await FlutterCallkitIncoming.activeCalls();
for (final call in calls) {
if (call.id == id) return call.isAccepted;
}
return false;
}
static Future<void> connected(String id) =>
FlutterCallkitIncoming.setCallConnected(id);
static Future<void> end(String id) => FlutterCallkitIncoming.endCall(id);
/// Texty zvonění. Zvonění z budíčku běží v izolátu na pozadí bez
/// lokalizace appky, proto vlastní malý slovník podle jazyka systému.
static const _texts = {
'cs': ('Přijmout', 'Odmítnout', 'Příchozí hovory'),
'de': ('Annehmen', 'Ablehnen', 'Eingehende Anrufe'),
'it': ('Accetta', 'Rifiuta', 'Chiamate in arrivo'),
'ru': ('Принять', 'Отклонить', 'Входящие звонки'),
'en': ('Accept', 'Decline', 'Incoming calls'),
};
static (String, String, String) get _localTexts =>
_texts[PlatformDispatcher.instance.locale.languageCode] ??
_texts['en']!;
static CallKitParams _params({
required String id,
required String name,
required bool video,
Map<String, dynamic>? extra,
}) =>
CallKitParams(
id: id,
nameCaller: name,
appName: _appName,
handle: _appName,
type: video ? 1 : 0,
duration: ringMillis,
extra: extra ?? const {},
missedCallNotification: const NotificationParams(
showNotification: false,
),
android: AndroidParams(
isCustomNotification: true,
isShowLogo: false,
ringtonePath: 'system_ringtone_default',
backgroundColor: '#0B0C12',
actionColor: '#7FAE8C',
textColor: '#F0EEE8',
incomingCallNotificationChannelName: _localTexts.$3,
textAccept: _localTexts.$1,
textDecline: _localTexts.$2,
isShowFullLockedScreen: true,
),
ios: const IOSParams(
handleType: 'generic',
supportsVideo: true,
maximumCallGroups: 1,
maximumCallsPerCallGroup: 1,
supportsDTMF: false,
supportsHolding: false,
supportsGrouping: false,
supportsUngrouping: false,
ringtonePath: 'system_ringtone_default',
),
);
}
+4 -2
View File
@@ -3,10 +3,12 @@ class AppConstants {
AppConstants._(); AppConstants._();
static const String appName = 'Šepot'; static const String appName = 'Šepot';
/// Musí sedět s `version:` v pubspec.yaml (bez build čísla).
static const String appVersion = '0.3.2';
static const String appAuthor = 'ajtak.it'; static const String appAuthor = 'ajtak.it';
/// Id konverzace „Poznámky pro sebe“. Nekoliduje s fingerprintem (hex)
/// ani s gid skupiny (UUID); podle něj se pozná, že nic nejde do sítě.
static const String notesConversationId = 'notes';
// Relay server (REST + WS na jednom portu). Přepsatelné při buildu: // Relay server (REST + WS na jednom portu). Přepsatelné při buildu:
// flutter build --dart-define=SEPOT_RELAY=http://<dev-relay-host>:8470 // flutter build --dart-define=SEPOT_RELAY=http://<dev-relay-host>:8470
static const String relayUrl = String.fromEnvironment( static const String relayUrl = String.fromEnvironment(
+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({
+168
View File
@@ -0,0 +1,168 @@
/// Admin-signed membership pro skupiny.
///
/// Původní skupiny nemají role: roster update přijímají od kteréhokoli člena. V2 přidává
/// admin/member role a KAŽDOU změnu členství/role/jména podepisuje admin svým
/// identity klíčem (XEdDSA). Ostatní klienti podpis ověří proti identity klíči
/// admina z rosteru — server tu roli nehraje (sealed sender, hloupý relay).
///
/// Podpis pokrývá VÝSLEDNÝ STAV (setříděné množiny členů a adminů + jméno +
/// epocha) zřetězený na hash předchozího stavu (`prev`) — admin autorizuje
/// konkrétní výsledek navázaný na známou historii, ne jen delta. Tím padá
/// nejednoznačnost i fork: kdo nesedí na `prev`, ví, že se rozešel a musí
/// resync.
///
/// Kanonická serializace je záměrně délkově-prefixovaná: přesun bajtů mezi poli
/// (fingerprint ↔ jméno apod.) MUSÍ změnit podepisované bajty, jinak by šlo
/// zaměnit význam podpisu.
library;
import 'dart:convert';
import 'dart:typed_data';
import 'package:libsignal/libsignal.dart' show PublicKey;
import 'package:pointycastle/digests/sha256.dart';
/// Typ admin operace. Pořadí (index) je součást podpisu — NIKDY nepřehazovat,
/// jen přidávat na konec (jinak se rozbijí staré podpisy).
///
/// `sync` není změna: správce jím podepisuje svůj aktuální stav jako odpověď
/// na žádost o resync (mezera v epoše / rozchod historie u příjemce).
enum GroupAdminAction { add, remove, promote, demote, rename, sync }
/// Doménové separátory — jiný kontext = jiné podepisované bajty.
const String _opDomain = 'sepot-group-adm-v1';
const String _stateDomain = 'sepot-group-state-v1';
/// Jedna admin operace = autorizovaný přechod skupiny do výsledného stavu.
class GroupAdminOp {
GroupAdminOp({
required this.gid,
required this.epoch,
required this.action,
required this.actor,
required List<String> members,
required List<String> admins,
required this.name,
required this.prev,
}) : members = _sortedUnique(members),
admins = _sortedUnique(admins);
/// UUID skupiny (`gid` na drátu).
final String gid;
/// Epocha, kterou tato operace ustavuje (monotónní, prev + 1).
final int epoch;
final GroupAdminAction action;
/// Fingerprint podepisujícího admina. MUSÍ se rovnat sealed-sender
/// fingerprintu odesílatele (anti-spoof).
final String actor;
/// Výsledný roster (setříděné fingerprinty, včetně nezměněných členů).
final List<String> members;
/// Výsledná množina adminů (setříděné fingerprinty, podmnožina `members`).
final List<String> admins;
/// Výsledné jméno skupiny.
final String name;
/// Hash předchozího stavu (`stateHash` epochy `epoch-1`); prázdné pro
/// založení (epoch 0).
final Uint8List prev;
/// Podepisované bajty — kanonické, délkově-prefixované, deterministické.
Uint8List canonicalBytes() {
final b = BytesBuilder(copy: false);
b.add(_lpStr(_opDomain));
b.add(_lpStr(gid));
b.add(_u64be(epoch));
b.addByte(action.index);
b.add(_lpStr(actor));
b.add(_lpList(members));
b.add(_lpList(admins));
b.add(_lpStr(name));
b.add(_lp(prev));
return b.toBytes();
}
/// Hash tohoto výsledného stavu — vstupuje jako `prev` do NÁSLEDUJÍCÍ
/// operace (řetěz proti forku).
Uint8List stateHash() => hashState(
gid: gid,
epoch: epoch,
members: members,
admins: admins,
name: name,
);
/// XEdDSA podpis operace daným signerem (typicky `SepotCrypto.sign`).
Uint8List signWith(Uint8List Function(List<int> message) signer) =>
signer(canonicalBytes());
/// Ověří podpis proti identity klíči admina (33 B serializovaný pub klíč).
/// Volající NAVÍC musí ověřit, že `actor` je v aktuální množině adminů a že
/// `actor` == sealed-sender fingerprint — to zde nevidíme.
bool verify(Uint8List signature, Uint8List actorIdentityKey) {
final pk = PublicKey.deserialize(bytes: actorIdentityKey.toList());
return pk.verify(
message: canonicalBytes().toList(),
signature: signature.toList(),
);
}
}
/// Hash stavu skupiny (setříděné množiny) — sdílený mezi op.stateHash a
/// výpočtem `prev` u příjemce.
Uint8List hashState({
required String gid,
required int epoch,
required List<String> members,
required List<String> admins,
required String name,
}) {
final b = BytesBuilder(copy: false);
b.add(_lpStr(_stateDomain));
b.add(_lpStr(gid));
b.add(_u64be(epoch));
b.add(_lpList(_sortedUnique(members)));
b.add(_lpList(_sortedUnique(admins)));
b.add(_lpStr(name));
return Uint8List.fromList(SHA256Digest().process(b.toBytes()));
}
// ── Kanonické kódování ────────────────────────────────────────────────────
List<String> _sortedUnique(List<String> xs) {
final s = xs.toSet().toList()..sort();
return s;
}
Uint8List _lp(List<int> bytes) {
final b = BytesBuilder(copy: false);
b.add(_u32be(bytes.length));
b.add(bytes);
return b.toBytes();
}
Uint8List _lpStr(String s) => _lp(utf8.encode(s));
Uint8List _lpList(List<String> items) {
final b = BytesBuilder(copy: false);
b.add(_u32be(items.length));
for (final item in items) {
b.add(_lpStr(item));
}
return b.toBytes();
}
Uint8List _u32be(int n) {
final d = ByteData(4)..setUint32(0, n, Endian.big);
return d.buffer.asUint8List();
}
Uint8List _u64be(int n) {
final d = ByteData(8)..setUint64(0, n, Endian.big);
return d.buffer.asUint8List();
}
+86
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});
@@ -420,4 +446,64 @@ class SepotCrypto {
} }
return result; return result;
} }
/// Nový signed prekey (rotace). Starý zůstává ve store — relace založené
/// z bundle těsně před rotací ho ještě potřebují.
Future<SepotSignedPreKey> generateSignedPreKey({
required int id,
required int timestampMs,
}) async {
final identityPrivate = PrivateKey.deserialize(
bytes: _identity.privateKey.toList(),
);
final private = PrivateKey.generate();
final public = private.getPublicKey();
final signature = identityPrivate.sign(
message: public.serialize().toList(),
);
await _stores.signedPreKey.storeSignedPreKey(
id,
SignedPreKeyRecord(
id: id,
publicKey: public,
privateKey: private,
signature: signature.toList(),
timestamp: BigInt.from(timestampMs),
),
);
return SepotSignedPreKey(
id: id,
publicKey: Uint8List.fromList(public.serialize().toList()),
signature: Uint8List.fromList(signature.toList()),
);
}
/// Nový last-resort Kyber prekey (rotace PQXDH kotvy).
Future<SepotKyberPreKey> generateKyberPreKey({
required int id,
required int timestampMs,
}) async {
final identityPrivate = PrivateKey.deserialize(
bytes: _identity.privateKey.toList(),
);
final keyPair = KyberKeyPair.generate();
final public = keyPair.getPublicKey();
final signature = identityPrivate.sign(
message: public.serialize().toList(),
);
await _stores.kyberPreKey.storeKyberPreKey(
id,
KyberPreKeyRecord.create(
id: id,
keyPair: keyPair,
signature: signature.toList(),
timestamp: BigInt.from(timestampMs),
),
);
return SepotKyberPreKey(
id: id,
publicKey: Uint8List.fromList(public.serialize().toList()),
signature: Uint8List.fromList(signature.toList()),
);
}
} }
+42
View File
@@ -26,6 +26,24 @@ class RelayUnavailableError extends SepotUserError {
String toString() => 'relay unavailable'; String toString() => 'relay unavailable';
} }
/// Vlastní registrace na relay ještě nedoběhla — nemáme sender adresu.
/// Přechodný stav po čerstvé instalaci; registrace běží na pozadí.
class NotYetRegisteredError extends SepotUserError {
const NotYetRegisteredError();
@override
String toString() => 'not registered on relay yet';
}
/// Odkaz/QR kontaktu je neúplný (chybí routing aid+da) — vygenerovaný
/// dřív, než měla protistrana dokončenou registraci na relay.
class ContactLinkIncompleteError extends SepotUserError {
const ContactLinkIncompleteError();
@override
String toString() => 'contact link is missing routing (incomplete)';
}
/// Příloha přesahuje serverový limit. /// Příloha přesahuje serverový limit.
class AttachmentTooLargeError extends SepotUserError { class AttachmentTooLargeError extends SepotUserError {
const AttachmentTooLargeError(this.maxBytes); const AttachmentTooLargeError(this.maxBytes);
@@ -38,6 +56,30 @@ class AttachmentTooLargeError extends SepotUserError {
String toString() => 'attachment too large (max $maxBytes bytes)'; String toString() => 'attachment too large (max $maxBytes bytes)';
} }
/// Příloha pro přeposlání už v telefonu není (smazaná, mizící, nestažená).
class AttachmentUnavailableError extends SepotUserError {
const AttachmentUnavailableError();
@override
String toString() => 'attachment not available locally';
}
/// Změnu skupiny smí udělat jen správce.
class NotGroupAdminError extends SepotUserError {
const NotGroupAdminError();
@override
String toString() => 'only a group admin can do this';
}
/// Poslední správce se nesmí odebrat ani zbavit role.
class LastGroupAdminError extends SepotUserError {
const LastGroupAdminError();
@override
String toString() => 'the last group admin cannot step down';
}
/// Skupina bez jediného dalšího člena nedává smysl. /// Skupina bez jediného dalšího člena nedává smysl.
class GroupNeedsMemberError extends SepotUserError { class GroupNeedsMemberError extends SepotUserError {
const GroupNeedsMemberError(); const GroupNeedsMemberError();
+5
View File
@@ -11,9 +11,14 @@ import 'package:sepot/l10n/generated/app_localizations.dart';
String describeError(AppLocalizations l10n, Object error) => switch (error) { String describeError(AppLocalizations l10n, Object error) => switch (error) {
TrustRootChangedError() => l10n.errorTrustRootChanged, TrustRootChangedError() => l10n.errorTrustRootChanged,
RelayUnavailableError() => l10n.errorRelayUnavailable, RelayUnavailableError() => l10n.errorRelayUnavailable,
NotYetRegisteredError() => l10n.errorNotYetRegistered,
ContactLinkIncompleteError() => l10n.errorContactLinkIncomplete,
AttachmentTooLargeError(:final maxMb) => AttachmentTooLargeError(:final maxMb) =>
l10n.errorAttachmentTooLarge(maxMb), l10n.errorAttachmentTooLarge(maxMb),
AttachmentUnavailableError() => l10n.errorAttachmentUnavailable,
GroupNeedsMemberError() => l10n.errorGroupNeedsMember, GroupNeedsMemberError() => l10n.errorGroupNeedsMember,
NotGroupAdminError() => l10n.errorGroupNotAdmin,
LastGroupAdminError() => l10n.errorGroupLastAdmin,
GroupTooLargeError(:final maxMembers) => GroupTooLargeError(:final maxMembers) =>
l10n.errorGroupMaxMembers(maxMembers), l10n.errorGroupMaxMembers(maxMembers),
MemberNoDeliveryAddressError(:final displayName) => MemberNoDeliveryAddressError(:final displayName) =>
+123 -3
View File
@@ -63,9 +63,18 @@ class RelayClient {
StreamController<RelayConnectionState>.broadcast(); StreamController<RelayConnectionState>.broadcast();
final _deliverController = StreamController<pb.Envelope>.broadcast(); final _deliverController = StreamController<pb.Envelope>.broadcast();
final _receiptController = StreamController<pb.Receipt>.broadcast(); final _receiptController = StreamController<pb.Receipt>.broadcast();
final _turnController = StreamController<pb.TurnCredentials>.broadcast();
final _prekeysLowController = StreamController<int>.broadcast();
var _state = RelayConnectionState.disconnected; var _state = RelayConnectionState.disconnected;
/// Kdy naposled přišel aplikační rámec od relaye.
DateTime _lastFrameAt = DateTime.fromMillisecondsSinceEpoch(0);
static const _staleAfter = Duration(seconds: 20);
/// Relay hlásí docházející one-time prekeys (zbývající počet).
Stream<int> get prekeysLow => _prekeysLowController.stream;
RelayConnectionState get state => _state; RelayConnectionState get state => _state;
Stream<RelayConnectionState> get stateStream => _stateController.stream; Stream<RelayConnectionState> get stateStream => _stateController.stream;
@@ -75,6 +84,9 @@ class RelayClient {
void _setState(RelayConnectionState s) { void _setState(RelayConnectionState s) {
_state = s; _state = s;
// Po dispose() je controller zavřený — pozdní přechod z doběhnutého
// _connect (zombie) by jinak hodil StateError do listenerů.
if (_stateController.isClosed) return;
_stateController.add(s); _stateController.add(s);
} }
@@ -96,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,
@@ -140,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
@@ -276,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;
@@ -292,6 +370,14 @@ class RelayClient {
try { try {
final channel = _transport.connectWs(Uri.parse('$_wsUrl/v1/ws')); final channel = _transport.connectWs(Uri.parse('$_wsUrl/v1/ws'));
await channel.ready; await channel.ready;
// Během `ready` (přes Tor může viset dlouho) mohl přijít stop()/dispose()
// — Tor toggle nebo auto-lock. Bez téhle kontroly by vzniklo zombie
// autentizované spojení (relay má 2× jedno zařízení, DELIVERy padají do
// zavřených controllerů). Nezakládat ho, jen zavřít.
if (!_shouldRun) {
await channel.sink.close();
return;
}
_channel = channel; _channel = channel;
// AUTH: výzva-odpověď podepsaná identity klíčem (XEdDSA) // AUTH: výzva-odpověď podepsaná identity klíčem (XEdDSA)
@@ -301,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([
@@ -322,10 +409,22 @@ class RelayClient {
return; return;
} }
if (frame.hasDeliver()) { if (frame.hasDeliver()) {
if (!_deliverController.isClosed) {
_deliverController.add(frame.deliver); _deliverController.add(frame.deliver);
}
} else if (frame.hasReceipt()) { } else if (frame.hasReceipt()) {
if (!_receiptController.isClosed) {
_receiptController.add(frame.receipt); _receiptController.add(frame.receipt);
} }
} else if (frame.hasTurnCredentials()) {
if (!_turnController.isClosed) {
_turnController.add(frame.turnCredentials);
}
} else if (frame.hasPrekeysLow()) {
if (!_prekeysLowController.isClosed) {
_prekeysLowController.add(frame.prekeysLow.remaining);
}
}
}, },
onDone: _onDisconnected, onDone: _onDisconnected,
onError: (Object _) => _onDisconnected(), onError: (Object _) => _onDisconnected(),
@@ -358,27 +457,46 @@ class RelayClient {
} }
/// Odešle sealed-sender obálku. Vrací Receipt (STORED_OFFLINE / /// Odešle sealed-sender obálku. Vrací Receipt (STORED_OFFLINE /
/// DELIVERED_ONLINE) spárovaný přes msg_uuid. /// DELIVERED_ONLINE, u EPHEMERAL i DROPPED) spárovaný přes msg_uuid.
Future<pb.Receipt> sendEnvelope({ Future<pb.Receipt> sendEnvelope({
required Uint8List dstAddress, required Uint8List dstAddress,
required Uint8List payload, required Uint8List payload,
pb.ContentHint hint = pb.ContentHint.CONTENT_HINT_DEFAULT,
Duration timeout = const Duration(seconds: 10), Duration timeout = const Duration(seconds: 10),
}) async { }) async {
final msgUuid = randomBytes(16); final msgUuid = randomBytes(16);
final receiptFuture = _receiptController.stream final receiptFuture = _receiptController.stream
.firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid)) .firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid))
.timeout(timeout); .timeout(timeout);
try {
_send(pb.ClientFrame( _send(pb.ClientFrame(
envelope: pb.Envelope( envelope: pb.Envelope(
dstAddress: dstAddress, dstAddress: dstAddress,
msgUuid: msgUuid, msgUuid: msgUuid,
contentHint: pb.ContentHint.CONTENT_HINT_DEFAULT, contentHint: hint,
payload: payload, payload: payload,
), ),
)); ));
} catch (_) {
// Odeslání selhalo (kanál zavřený) — čekající future by za 10 s
// hodil neodchycený TimeoutException do zvonku chyb.
receiptFuture.ignore();
rethrow;
}
return receiptFuture; return receiptFuture;
} }
/// Dočasné údaje k TURN serveru pro hovor. Relay je vydá jen přihlášenému
/// zařízení; bez TURN na relayi (503) doběhne timeout a hovor zkusí jen
/// přímé spojení přes STUN.
Future<pb.TurnCredentials> requestTurn({
Duration timeout = const Duration(seconds: 5),
}) {
final future = _turnController.stream.first.timeout(timeout);
_send(pb.ClientFrame(turnRequest: pb.TurnRequest()));
return future;
}
/// Potvrdí zpracování obálky — relay ji smaže z fronty. Volat až PO /// Potvrdí zpracování obálky — relay ji smaže z fronty. Volat až PO
/// uložení zprávy na disk. /// uložení zprávy na disk.
void ack(List<int> msgUuid) { void ack(List<int> msgUuid) {
@@ -422,6 +540,8 @@ class RelayClient {
await stop(); await stop();
_transport.close(); _transport.close();
await _stateController.close(); await _stateController.close();
await _turnController.close();
await _prekeysLowController.close();
await _deliverController.close(); await _deliverController.close();
await _receiptController.close(); await _receiptController.close();
} }
+12 -2
View File
@@ -21,6 +21,15 @@ import 'package:socks5_proxy/socks_client.dart';
import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
/// Jak často posílat WS ping a jak dlouho čekat na pong.
///
/// Bez pingu se mrtvé spojení tváří jako živé: mobilní NAT zahodí nečinné TCP
/// tiše, appka dál hlásí „připojeno" a píše do prázdna — zprávy pak odejdou až
/// po restartu appky, všechny naráz. `dart:io` při nastaveném intervalu ping
/// posílá a když pong nedorazí včas, spojení zavře → [RelayClient] se
/// reconnectne sám. Relay pinguje protisměrně po 30 s (ws.rs PING_INTERVAL).
const kWsPingInterval = Duration(seconds: 30);
/// Abstrakce nad tím, jak klient otevírá spojení k relayi. /// Abstrakce nad tím, jak klient otevírá spojení k relayi.
abstract class RelayTransport { abstract class RelayTransport {
/// Sdílený HTTP klient pro REST (registrace, prekey bundle, přílohy…). /// Sdílený HTTP klient pro REST (registrace, prekey bundle, přílohy…).
@@ -41,7 +50,8 @@ class DirectTransport implements RelayTransport {
http.Client httpClient() => _client ??= http.Client(); http.Client httpClient() => _client ??= http.Client();
@override @override
WebSocketChannel connectWs(Uri url) => WebSocketChannel.connect(url); WebSocketChannel connectWs(Uri url) =>
IOWebSocketChannel.connect(url, pingInterval: kWsPingInterval);
@override @override
void close() { void close() {
@@ -84,7 +94,7 @@ class TorTransport implements RelayTransport {
@override @override
WebSocketChannel connectWs(Uri url) => WebSocketChannel connectWs(Uri url) =>
IOWebSocketChannel.connect(_withExplicitPort(url), IOWebSocketChannel.connect(_withExplicitPort(url),
customClient: _sharedHttpClient()); customClient: _sharedHttpClient(), pingInterval: kWsPingInterval);
/// `Uri` pro `ws`/`wss` nezná výchozí port → `url.port` vrací 0 a SOCKS /// `Uri` pro `ws`/`wss` nezná výchozí port → `url.port` vrací 0 a SOCKS
/// CONNECT by cílil na port 0 (serverError). Doplníme ho podle schématu /// CONNECT by cílil na port 0 (serverError). Doplníme ho podle schématu
+28 -3
View File
@@ -20,6 +20,7 @@ import 'dart:io' show Platform;
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:sepot/core/calls/call_ringer.dart';
import 'package:sepot/core/push/apns_ios.dart'; import 'package:sepot/core/push/apns_ios.dart';
import 'package:sepot/core/push/push_notifier.dart'; import 'package:sepot/core/push/push_notifier.dart';
import 'package:sepot/core/push/push_service.dart'; import 'package:sepot/core/push/push_service.dart';
@@ -28,8 +29,14 @@ import 'package:unifiedpush/unifiedpush.dart';
/// FCM budíček se zabitou appkou — plugin spouští handler ve vlastním /// FCM budíček se zabitou appkou — plugin spouští handler ve vlastním
/// background isolate. Stejný model jako UnifiedPush killed-state: /// background isolate. Stejný model jako UnifiedPush killed-state:
/// DB je zamčená, nic nejde dešifrovat → jen generická notifikace. /// DB je zamčená, nic nejde dešifrovat → jen generická notifikace.
/// Budíček hovoru (`k=call`) místo ní rovnou zazvoní; kdo
/// volá, appka zjistí až po odemčení z nabídky ve frontě.
@pragma('vm:entry-point') @pragma('vm:entry-point')
Future<void> sepotFcmBackgroundWake(RemoteMessage message) async { Future<void> sepotFcmBackgroundWake(RemoteMessage message) async {
if (message.data['k'] == 'call') {
await CallRinger.ringFromPush();
return;
}
await Firebase.initializeApp(); await Firebase.initializeApp();
await PushNotifier.showWakeNotification(); await PushNotifier.showWakeNotification();
} }
@@ -45,18 +52,32 @@ class PushBootstrap {
static const _pendingEndpointKey = 'pending_push_endpoint'; static const _pendingEndpointKey = 'pending_push_endpoint';
static const _modeKey = 'push_mode'; // 'off' | 'up' | 'fcm' static const _modeKey = 'push_mode'; // 'off' | 'up' | 'fcm'
static const _registrationFailedKey = 'push_registration_failed';
static const _store = FlutterSecureStorage( static const _store = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true), aOptions: AndroidOptions(encryptedSharedPreferences: true),
); );
/// Režim pushů žije v OS keystore (NE v SQLCipher DB) — musí být /// Režim pushů žije v OS keystore (NE v SQLCipher DB) — musí být
/// čitelný před unlockem, aby main() věděl, zda registrovat FCM. /// čitelný před unlockem, aby main() věděl, zda registrovat FCM.
static Future<String> readMode() async => static Future<String> readMode() async => await readStoredMode() ?? 'off';
await _store.read(key: _modeKey) ?? 'off';
/// Uložený režim, nebo null, když ho uživatel ani appka ještě nevybrali
/// — to odlišuje čerstvou instalaci od vědomého „vypnuto".
static Future<String?> readStoredMode() => _store.read(key: _modeKey);
static Future<void> writeMode(String mode) => static Future<void> writeMode(String mode) =>
_store.write(key: _modeKey, value: mode); _store.write(key: _modeKey, value: mode);
/// Odmítl distributor registraci? Selhání se dřív jen spolklo, takže
/// uživatel měl v Nastavení „push zapnutý", ale budíčky nechodily a
/// zprávy čekaly, než appku otevře. Nastavení si to přečte a řekne to.
static Future<bool> registrationFailed() async =>
await _store.read(key: _registrationFailedKey) == '1';
static Future<void> _setRegistrationFailed(bool failed) => failed
? _store.write(key: _registrationFailedKey, value: '1')
: _store.delete(key: _registrationFailedKey);
static Future<void> init(List<String> args) async { static Future<void> init(List<String> args) async {
backgroundStart = args.contains('--unifiedpush-bg'); backgroundStart = args.contains('--unifiedpush-bg');
@@ -66,10 +87,13 @@ class PushBootstrap {
if (Platform.isIOS) { if (Platform.isIOS) {
ApnsBridge.onWake = _wake; ApnsBridge.onWake = _wake;
ApnsBridge.ensureHandler(); ApnsBridge.ensureHandler();
// Zvonění z VoIP pushe bez nabídky nesmí zvonit celých 45 s.
unawaited(CallRinger.expireStalePushRings());
} else { } else {
await UnifiedPush.initialize( await UnifiedPush.initialize(
onNewEndpoint: _onNewEndpoint, onNewEndpoint: _onNewEndpoint,
onRegistrationFailed: (_, _) {}, onRegistrationFailed: (_, _) =>
unawaited(_setRegistrationFailed(true)),
onUnregistered: _onUnregistered, onUnregistered: _onUnregistered,
onMessage: _onMessage, onMessage: _onMessage,
); );
@@ -114,6 +138,7 @@ class PushBootstrap {
} }
static void _onNewEndpoint(PushEndpoint endpoint, String instance) { static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
unawaited(_setRegistrationFailed(false)); // registrace se povedla
final s = _service; final s = _service;
if (s != null) { if (s != null) {
s.handleNewEndpoint(endpoint.url); s.handleNewEndpoint(endpoint.url);
+69 -11
View File
@@ -13,8 +13,11 @@
/// killed-state startu `--unifiedpush-bg` bez UI); tahle třída je /// killed-state startu `--unifiedpush-bg` bez UI); tahle třída je
/// most bootstrap ↔ messaging + API pro nastavení. /// most bootstrap ↔ messaging + API pro nastavení.
/// ///
/// FCM „fast mode" (opt-in) je druhá cesta (viz [setModeFcm]); default je /// Výchozí režim na Androidu vybírá [restoreMode] při prvním startu: s
/// degooglovaný UnifiedPush. /// UnifiedPush distributorem UnifiedPush, jinak FCM. Bez budíčku Android
/// appku na pozadí uspí a zprávy čekají, než ji člověk otevře — to je pro
/// messenger horší než token u Googlu (obsah ani odesílatel budíčkem nejde).
/// Uživatel může kdykoli přepnout v Nastavení.
library; library;
import 'dart:async'; import 'dart:async';
@@ -24,6 +27,7 @@ import 'dart:typed_data';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:sepot/core/calls/call_ringer.dart';
import 'package:sepot/core/push/apns_ios.dart'; import 'package:sepot/core/push/apns_ios.dart';
import 'package:sepot/core/push/push_bootstrap.dart'; import 'package:sepot/core/push/push_bootstrap.dart';
import 'package:sepot/core/push/push_notifier.dart'; import 'package:sepot/core/push/push_notifier.dart';
@@ -48,7 +52,15 @@ class PushService {
/// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.) /// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.)
bool get canWake => _messaging.isStarted; bool get canWake => _messaging.isStarted;
void wake() => _messaging.onPushWake(); /// Budíček: běžící messaging připojí WS; po auto-locku (zamčená DB) aspoň
/// generická notifikace bez obsahu, ať se uživatel o zprávě dozví.
void wake() {
if (canWake) {
_messaging.onPushWake();
} else {
unawaited(PushNotifier.showWakeNotification());
}
}
void handleNewEndpoint(String url) { void handleNewEndpoint(String url) {
_messaging.registerPush(_registration( _messaging.registerPush(_registration(
@@ -80,11 +92,25 @@ class PushService {
} }
} }
void _registerApnsToken(String token) { String? _apnsToken;
_messaging.registerPush(_registration(
/// APNs token pro zprávy + VoIP token pro hovory v jedné registraci
/// (relay drží obojí u zařízení).
Future<void> _registerApnsToken(String token) async {
_apnsToken = token;
final voip = await CallRinger.voipToken();
final reg = _registration(
pb.RegisterPush_Platform.PLATFORM_APNS, pb.RegisterPush_Platform.PLATFORM_APNS,
Uint8List.fromList(utf8.encode(token)), Uint8List.fromList(utf8.encode(token)),
)); );
if (voip != null) reg.voipToken = utf8.encode(voip);
_messaging.registerPush(reg);
}
/// PushKit vydal (nový) VoIP token — přeregistrovat, pokud už APNs běží.
Future<void> refreshVoipToken() async {
final token = _apnsToken;
if (token != null) await _registerApnsToken(token);
} }
/// iOS nativní push (APNs) — zapne registraci u Apple; token přijde /// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
@@ -112,10 +138,14 @@ class PushService {
} }
/// Seznam nainstalovaných distributorů (pro výběr v nastavení). /// Seznam nainstalovaných distributorů (pro výběr v nastavení).
Future<List<String>> distributors() => UnifiedPush.getDistributors(); /// UnifiedPush existuje jen na Androidu; jinde by volání pluginu vyhodilo
/// UnimplementedError (iOS: dlaždice Push v Nastavení).
Future<List<String>> distributors() async =>
Platform.isAndroid ? UnifiedPush.getDistributors() : const [];
/// Aktuálně uložený distributor (null = push vypnutý / nevybraný). /// Aktuálně uložený distributor (null = push vypnutý / nevybraný).
Future<String?> currentDistributor() => UnifiedPush.getDistributor(); Future<String?> currentDistributor() async =>
Platform.isAndroid ? UnifiedPush.getDistributor() : null;
/// Vybere konkrétního distributora a zaregistruje se u něj. /// Vybere konkrétního distributora a zaregistruje se u něj.
Future<void> useDistributor(String distributor) async { Future<void> useDistributor(String distributor) async {
@@ -132,7 +162,7 @@ class PushService {
} }
Future<bool> get isEnabled async => Future<bool> get isEnabled async =>
(await UnifiedPush.getDistributor()) != null; Platform.isAndroid && (await UnifiedPush.getDistributor()) != null;
// ── Režimy (off / up / fcm) ───────────────────────────────────────── // ── Režimy (off / up / fcm) ─────────────────────────────────────────
@@ -141,9 +171,14 @@ class PushService {
Future<String> currentMode() async { Future<String> currentMode() async {
final mode = await PushBootstrap.readMode(); final mode = await PushBootstrap.readMode();
if (mode != 'off') return mode; if (mode != 'off') return mode;
if (!Platform.isAndroid) return mode;
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off'; return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
} }
/// Odmítl distributor poslední registraci? Nastavení to ukáže — jinak by
/// push vypadal zapnutý, ale budíčky by nechodily.
Future<bool> registrationFailed() => PushBootstrap.registrationFailed();
Future<void> setModeOff() async { Future<void> setModeOff() async {
await _teardownFcm(deleteToken: true); await _teardownFcm(deleteToken: true);
try { try {
@@ -162,8 +197,8 @@ class PushService {
return ok; return ok;
} }
/// FCM fast mode — OPT-IN: registrační token zařízení jde Googlu /// FCM: registrační token zařízení jde Googlu (obsah budíčků nikdy,
/// (obsah budíčků nikdy, ťuk je konstantní `{"v":1}`). /// ťuk je konstantní `{"v":1}`).
Future<void> setModeFcm() async { Future<void> setModeFcm() async {
await init(); await init();
try { try {
@@ -218,6 +253,10 @@ class PushService {
} }
return; return;
} }
if (await PushBootstrap.readStoredMode() == null) {
await _chooseDefaultMode();
return;
}
switch (await currentMode()) { switch (await currentMode()) {
case 'up': case 'up':
await enable(); await enable();
@@ -231,4 +270,23 @@ class PushService {
await init(); // aspoň attach (killed-state notifikace) await init(); // aspoň attach (killed-state notifikace)
} }
} }
/// První start na Androidu (nebo instalace z doby, kdy byl push
/// opt-in a nikdo ho nezapnul). Kdo má nainstalovaný UnifiedPush
/// distributor, vybral si degooglovanou cestu sám — dostane ji.
/// Ostatním zapneme FCM. Telefon bez Google služeb token nedostane;
/// pak zapíšeme „off", ať se to nezkouší při každém startu — seznam
/// chatů na chybějící budíčky upozorní sám.
Future<void> _chooseDefaultMode() async {
try {
if ((await currentMode()) == 'up' ||
(await distributors()).isNotEmpty) {
if (await setModeUnifiedPush()) return;
}
await setModeFcm();
} catch (_) {
await init();
await PushBootstrap.writeMode('off');
}
}
} }
+6 -1
View File
@@ -21,6 +21,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:sepot/core/app_info.dart';
import 'package:sepot/core/constants/app_constants.dart'; import 'package:sepot/core/constants/app_constants.dart';
import 'package:sepot/core/errors.dart'; import 'package:sepot/core/errors.dart';
@@ -67,12 +68,16 @@ class ReportService {
throw const ReportEmptyDescriptionError(); throw const ReportEmptyDescriptionError();
} }
// S build číslem, ne jen „1.1.2" — jinak nejde poznat, ze kterého buildu
// hlášení přišlo. Neznámou verzi radši vynechat než poslat vymyšlenou.
final verze = AppInfo.versionWithBuild;
final body = <String, dynamic>{ final body = <String, dynamic>{
'kategorie': category.wire, 'kategorie': category.wire,
'popis': popis, 'popis': popis,
'zdroj': 'app', 'zdroj': 'app',
'platforma': Platform.operatingSystem, 'platforma': Platform.operatingSystem,
'verze': AppConstants.appVersion, 'verze': ?verze,
if (reportedFingerprint != null && reportedFingerprint.isNotEmpty) if (reportedFingerprint != null && reportedFingerprint.isNotEmpty)
'nahlasenyOtisk': reportedFingerprint, 'nahlasenyOtisk': reportedFingerprint,
if (replyTo != null && replyTo.trim().isNotEmpty) if (replyTo != null && replyTo.trim().isNotEmpty)
+38
View File
@@ -0,0 +1,38 @@
import 'dart:isolate';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:image/image.dart' as img;
import 'package:sepot/core/services/image_processor.dart';
/// Strana profilové fotky v pixelech — víc se v appce nikde nezobrazí.
const avatarSide = 256;
/// Vybranou fotku otočí podle EXIF, ořízne na středový čtverec, zmenší
/// a znovu zakóduje do JPEG bez metadat (EXIF, GPS, model telefonu).
/// Null = obrázek nejde přečíst.
Future<Uint8List?> prepareAvatarJpeg(Uint8List input) => Isolate.run(() {
final img.Image? decoded;
try {
decoded = img.decodeImage(input);
} on Object {
// Dekodéry na poškozených datech háží místo null.
return null;
}
if (decoded == null) return null;
final upright = img.bakeOrientation(decoded);
final side = math.min(upright.width, upright.height);
final square = img.copyCrop(
upright,
x: (upright.width - side) ~/ 2,
y: (upright.height - side) ~/ 2,
width: side,
height: side,
);
final small = img.copyResize(square,
width: avatarSide,
height: avatarSide,
interpolation: img.Interpolation.average);
ImageProcessor.stripMetadata(small);
return Uint8List.fromList(img.encodeJpg(small, quality: 85));
});
@@ -0,0 +1,284 @@
/// Pravidla správců skupin oddělená od sítě a
/// úložiště: drátová podoba `gop`, ověření příchozí operace v pořadí 1–6
/// ze specifikace, tvorba odchozí operace s invarianty a podržení operací
/// z budoucích epoch.
///
/// Ověření podpisu se předává jako funkce — produkce volá libsignal
/// (`GroupAdminOp.verify`), testy pravidel tak běží i bez nativní knihovny.
library;
import 'dart:convert';
import 'dart:typed_data';
import 'package:sepot/core/crypto/group_admin.dart';
import 'package:sepot/core/errors.dart';
import 'package:sepot/models/group.dart';
/// Ověří podpis [sig] operace [op] identity klíčem [identityKey].
typedef GroupOpSignatureCheck = bool Function(
GroupAdminOp op, Uint8List sig, Uint8List identityKey);
bool _libsignalVerify(GroupAdminOp op, Uint8List sig, Uint8List ik) {
try {
return op.verify(sig, ik);
} catch (_) {
// Nedeserializovatelný klíč/podpis z drátu = neplatný podpis, ne pád
// příjmové cesty.
return false;
}
}
/// Výsledek ověření příchozí operace.
enum GopVerdict {
/// Platná a navazuje — aplikovat.
apply,
/// `by` nesedí se sealed-sender autorem (krok 1).
spoof,
/// Porušené invarianty nebo cizí skupina — nikdy nepůjde aplikovat.
invalid,
/// Podepsal někdo, kdo u mě není správce (krok 2).
notAdmin,
/// Podpis nesedí nebo neznám identity klíč podepisujícího (krok 3).
badSignature,
/// Epocha přeskakuje — chybí mi mezistav (krok 4, `>`).
gap,
/// Epochu už mám nebo jsem dál (krok 4, `<=`).
stale,
/// Navazuje na jiný předchozí stav než můj (krok 5).
fork,
}
/// Drátová podoba `gop`: pole tabulky ze specifikace. Samostatně jako
/// `t: 'gop'`, v kompatibilním režimu vnořená pod klíčem `gop` v legacy
/// `ginv`/`gmeta` (v1 klient neznámé pole ignoruje a použije stín).
class GroupOpMessage {
GroupOpMessage({required this.op, required this.sig, this.roster});
final GroupAdminOp op;
final Uint8List sig;
/// Roster entry `[{f,n,ik,aid,da,v}]` — u přidání nových členů a u
/// pozvánky, ať nový člen zná klíče a routing ostatních.
final List<Map<String, dynamic>>? roster;
Map<String, dynamic> toWire() => {
'gid': op.gid,
'epoch': op.epoch,
'op': op.action.name,
'by': op.actor,
'members': op.members,
'admins': op.admins,
'gname': op.name,
'prev': base64Encode(op.prev),
'sig': base64Encode(sig),
'roster': ?roster,
};
/// Null = poškozená nebo neznámá podoba (neznámý typ operace z novější
/// verze se nedá ověřit, protože jeho index je součást podpisu).
static GroupOpMessage? fromWire(Object? raw) {
if (raw is! Map) return null;
try {
final m = raw.cast<String, dynamic>();
final action = GroupAdminAction.values
.where((a) => a.name == m['op'])
.firstOrNull;
final epoch = m['epoch'];
if (action == null || epoch is! int || epoch < 0) return null;
final rosterRaw = m['roster'];
return GroupOpMessage(
op: GroupAdminOp(
gid: m['gid'] as String,
epoch: epoch,
action: action,
actor: m['by'] as String,
members: (m['members'] as List).cast<String>().toList(),
admins: (m['admins'] as List).cast<String>().toList(),
name: m['gname'] as String,
prev: base64Decode(m['prev'] as String),
),
sig: base64Decode(m['sig'] as String),
roster: rosterRaw is List
? [
for (final e in rosterRaw)
if (e is Map) e.cast<String, dynamic>(),
]
: null,
);
} on Object {
// Špatné typy polí (TypeError), vadné base64 (FormatException).
return null;
}
}
}
/// Invarianty stavu, které musí držet každá operace bez ohledu na autora.
bool _stateValid(GroupAdminOp op) =>
op.gid.isNotEmpty &&
op.gid.length <= 64 &&
op.name.trim().isNotEmpty &&
op.members.length <= Group.maxMembers &&
op.admins.isNotEmpty &&
op.admins.every(op.members.contains);
/// Ověří příchozí operaci proti lokálnímu stavu [local] v pořadí kroků 1–6
/// (očíslované v těle funkce).
///
/// [actorIdentityKey] = identity klíč `by` z mých kontaktů (TOFU — nikdy
/// z téže zprávy, jinak by si útočník klíč přinesl sám).
GopVerdict checkGroupOp({
required Group local,
required GroupOpMessage msg,
required String senderFp,
required String me,
required Uint8List? actorIdentityKey,
GroupOpSignatureCheck verify = _libsignalVerify,
}) {
final op = msg.op;
// 1. Autor = sealed sender.
if (op.actor != senderFp) return GopVerdict.spoof;
if (op.gid != local.id || !_stateValid(op)) return GopVerdict.invalid;
// 2. Oprávnění. U odhadnutých správců (viz Group.provisional) stačí, že
// podepisující je člen a v podepsaném stavu správcem.
final provisional = local.isProvisionalFor(me);
final authorized = provisional
? local.isMember(op.actor) && op.admins.contains(op.actor)
: local.isAdmin(op.actor);
if (!authorized) return GopVerdict.notAdmin;
// 3. Podpis.
if (actorIdentityKey == null || !verify(op, msg.sig, actorIdentityKey)) {
return GopVerdict.badSignature;
}
// 4. Epocha. Stav od správce (sync) i kotva odhadnuté skupiny nemají na
// co navázat — rozhoduje jen to, že posouvají dopředu. Ostrá nerovnost
// u sync brání přeskakování mezi dvěma stejně starými větvemi.
if (op.action == GroupAdminAction.sync) {
return op.epoch > local.epoch ? GopVerdict.apply : GopVerdict.stale;
}
if (provisional) {
return op.epoch >= local.epoch ? GopVerdict.apply : GopVerdict.stale;
}
if (op.epoch <= local.epoch) return GopVerdict.stale;
if (op.epoch > local.epoch + 1) return GopVerdict.gap;
// 5. Řetěz: `prev` musí být hash mého aktuálního stavu.
final mine = hashState(
gid: local.id,
epoch: local.epoch,
members: local.memberIds,
admins: local.admins,
name: local.name,
);
if (!_bytesEqual(op.prev, mine)) return GopVerdict.fork;
// 6. Aplikovat (volající přes applyGroupOp).
return GopVerdict.apply;
}
/// Ověří operaci, kterou mě někdo přidává do skupiny, kterou ještě neznám.
/// Není s čím řetězit — důvěra je stejná jako u v1 pozvánky (kdo mě zve,
/// je člen), navíc musí být podepsaná správcem uvedeným v rosteru.
GopVerdict checkJoinOp({
required GroupOpMessage msg,
required String senderFp,
required String me,
required Uint8List? actorIdentityKey,
GroupOpSignatureCheck verify = _libsignalVerify,
}) {
final op = msg.op;
if (op.actor != senderFp) return GopVerdict.spoof;
if (!_stateValid(op) || !op.members.contains(me)) return GopVerdict.invalid;
if (!op.admins.contains(op.actor)) return GopVerdict.notAdmin;
if (actorIdentityKey == null || !verify(op, msg.sig, actorIdentityKey)) {
return GopVerdict.badSignature;
}
return GopVerdict.apply;
}
/// Přepíše lokální stav výsledkem ověřené operace. Když ve výsledku
/// nejsem, správce mě odebral — skupina se chová jako opuštěná.
void applyGroupOp(Group group, GroupAdminOp op, String me) {
group
..memberIds = [...op.members]
..admins = [...op.admins]
..name = op.name
..epoch = op.epoch
..provisional = false;
if (!op.members.contains(me)) group.left = true;
}
/// Hash aktuálního stavu skupiny (vstupuje jako `prev` další operace).
Uint8List currentStateHash(Group g) => hashState(
gid: g.id,
epoch: g.epoch,
members: g.memberIds,
admins: g.admins,
name: g.name,
);
/// Postaví odchozí operaci s kontrolou oprávnění a invariantů. Hází
/// [NotGroupAdminError] / [LastGroupAdminError] dřív, než cokoli odejde.
GroupAdminOp buildGroupOp({
required Group group,
required String me,
required GroupAdminAction action,
List<String>? members,
List<String>? admins,
String? name,
}) {
if (!group.isAdmin(me)) throw const NotGroupAdminError();
final newMembers = members ?? group.memberIds;
final newAdmins = admins ?? group.admins;
if (newMembers.toSet().length > Group.maxMembers) {
throw const GroupTooLargeError(Group.maxMembers);
}
// Poslední správce se nesmí odebrat ani zbavit role — skupina by zůstala
// bez nikoho, kdo smí podepsat další změnu.
if (newAdmins.isEmpty) throw const LastGroupAdminError();
if (!newAdmins.every(newMembers.contains)) {
throw StateError('group admins must be members');
}
final isSync = action == GroupAdminAction.sync;
return GroupAdminOp(
gid: group.id,
epoch: isSync ? group.epoch : group.epoch + 1,
action: action,
actor: me,
members: newMembers,
admins: newAdmins,
name: name ?? group.name,
// Sync nic neřetězí, jen potvrzuje stav — viz checkGroupOp krok 4.
prev: isSync ? Uint8List(0) : currentStateHash(group),
);
}
/// Podrží operaci na později. Při přetečení se zahodí ty z nejvzdálenější
/// budoucnosti — podvržené operace s absurdní epochou tak nevytlačí
/// skutečně chybějící mezistav.
void holdGroupOp(Group group, PendingGroupOp pending) {
final sig = pending.wire['sig'];
if (group.pendingOps.any((p) => p.wire['sig'] == sig)) return;
group.pendingOps = [...group.pendingOps, pending]
..sort((a, b) => a.epoch.compareTo(b.epoch));
if (group.pendingOps.length > Group.maxPendingOps) {
group.pendingOps = group.pendingOps.sublist(0, Group.maxPendingOps);
}
}
bool _bytesEqual(List<int> a, List<int> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
+46 -4
View File
@@ -43,6 +43,19 @@ class ImageProcessor {
/// sealed-sender obálku) — držet výrazně pod tím. /// sealed-sender obálku) — držet výrazně pod tím.
static const int thumbMaxBytes = 48 * 1024; static const int thumbMaxBytes = 48 * 1024;
/// Jestli z obrázku umíme odstranit metadata. Formát, který knihovna
/// nepřečte (HEIC z galerie odeslaný jako dokument, RAW…), by odešel
/// v originále včetně GPS — UI se proto nejdřív zeptá uživatele.
static Future<bool> canStripMetadata(Uint8List bytes) {
return Isolate.run(() {
try {
return img.decodeImage(bytes) != null;
} on Object {
return false;
}
});
}
/// Zpracuje obrázek v izolátu (dekódování megapixelů by blokovalo UI). /// Zpracuje obrázek v izolátu (dekódování megapixelů by blokovalo UI).
/// Vrací null, když bajty nejsou dekódovatelný obrázek — volající je /// Vrací null, když bajty nejsou dekódovatelný obrázek — volající je
/// pak pošle jako dokument. /// pak pošle jako dokument.
@@ -51,7 +64,13 @@ class ImageProcessor {
String? mimeHint, String? mimeHint,
}) { }) {
return Isolate.run(() { return Isolate.run(() {
final decoded = img.decodeImage(original); final img.Image? decoded;
try {
decoded = img.decodeImage(original);
} on Object {
// Dekodéry na poškozených datech háží místo null.
return null;
}
if (decoded == null) return null; if (decoded == null) return null;
// GIF beze změny — re-encode by zabil animaci // GIF beze změny — re-encode by zabil animaci
@@ -72,9 +91,12 @@ class ImageProcessor {
: img.copyResize(image, height: maxDimension); : img.copyResize(image, height: maxDimension);
} }
// PNG s průhledností zůstává PNG (JPEG alfa kanál neumí, a re-encode // PNG s REÁLNOU průhledností zůstává PNG (JPEG alfa kanál neumí,
// PNG→PNG metadata stejně zahodí); vše ostatní JPEG q80. // a re-encode PNG→PNG metadata stejně zahodí); vše ostatní JPEG q80.
final keepPng = mimeHint == 'image/png' && decoded.numChannels == 4; // Samotný alfa kanál nestačí — plně krytý RGBA PNG (screenshot,
// canvas render z editoru fotek) je jako JPEG několikanásobně menší.
final keepPng = mimeHint == 'image/png' && _hasTransparency(decoded);
stripMetadata(image);
final bytes = keepPng final bytes = keepPng
? Uint8List.fromList(img.encodePng(image)) ? Uint8List.fromList(img.encodePng(image))
: Uint8List.fromList(img.encodeJpg(image, quality: jpegQuality)); : Uint8List.fromList(img.encodeJpg(image, quality: jpegQuality));
@@ -89,6 +111,25 @@ class ImageProcessor {
}); });
} }
/// Má obraz aspoň jeden ne-plně-krytý pixel? Běží v izolátu spolu se
/// zbytkem zpracování, O(n) sken je proti dekódu/enkódu zanedbatelný.
static bool _hasTransparency(img.Image image) {
if (image.numChannels < 4) return false;
for (final pixel in image) {
if (pixel.a < pixel.maxChannelValue) return true;
}
return false;
}
/// Zahodí EXIF (GPS, model telefonu, čas focení) a textová metadata.
/// Knihovna je při ořezu i zmenšení kopíruje a encodeJpg/encodePng je
/// znovu zapíše — re-encode sám o sobě metadata NEodstraní.
static void stripMetadata(img.Image image) {
image
..exif = img.ExifData()
..textData = null;
}
static Uint8List _thumbnail(img.Image source) { static Uint8List _thumbnail(img.Image source) {
var thumb = max(source.width, source.height) > thumbDimension var thumb = max(source.width, source.height) > thumbDimension
? (source.width >= source.height ? (source.width >= source.height
@@ -102,6 +143,7 @@ class ImageProcessor {
img.compositeImage(flat, thumb); img.compositeImage(flat, thumb);
thumb = flat; thumb = flat;
} }
stripMetadata(thumb);
var quality = 70; var quality = 70;
var encoded = Uint8List.fromList(img.encodeJpg(thumb, quality: quality)); var encoded = Uint8List.fromList(img.encodeJpg(thumb, quality: quality));
// Náhled je jen placeholder, než dorazí ostrá fotka — kvalitu držíme // Náhled je jen placeholder, než dorazí ostrá fotka — kvalitu držíme
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
import 'dart:async';
/// Kdo právě píše: konverzace → otisky pisatelů.
///
/// Indikátor nemá „konec psaní“ — odesílatel posílá „píšu“ nejvýš jednou
/// za pár sekund a tady každý záznam sám vyprší. Zpráva, která pak dorazí,
/// ho smaže hned ([clear]).
class TypingTracker {
TypingTracker({DateTime Function()? clock}) : _now = clock ?? DateTime.now;
/// Jak dlouho po posledním „píšu“ indikátor svítí. Musí být delší než
/// interval, ve kterém odesílatel „píšu“ opakuje (4 s), jinak by blikal.
static const showFor = Duration(seconds: 6);
final DateTime Function() _now;
final _expires = <String, Map<String, DateTime>>{};
final _changes = StreamController<Map<String, Set<String>>>.broadcast();
Timer? _sweep;
Stream<Map<String, Set<String>>> get changes => _changes.stream;
/// Aktuální pisatelé po konverzacích (bez vypršelých).
Map<String, Set<String>> get current {
final now = _now();
return {
for (final e in _expires.entries)
if (e.value.values.any((t) => t.isAfter(now)))
e.key: {
for (final w in e.value.entries)
if (w.value.isAfter(now)) w.key,
},
};
}
void mark(String convId, String writer) {
(_expires[convId] ??= {})[writer] = _now().add(showFor);
_emit();
_sweep ??= Timer.periodic(const Duration(seconds: 1), (_) => prune());
}
void clear(String convId, String writer) {
final writers = _expires[convId];
if (writers?.remove(writer) == null) return;
if (writers!.isEmpty) _expires.remove(convId);
_emit();
}
/// Zahodí vypršelé záznamy; když už nikdo nepíše, zastaví časovač.
void prune() {
final now = _now();
var changed = false;
_expires.removeWhere((_, writers) {
final before = writers.length;
writers.removeWhere((_, t) => !t.isAfter(now));
changed |= writers.length != before;
return writers.isEmpty;
});
if (changed) _emit();
if (_expires.isEmpty) {
_sweep?.cancel();
_sweep = null;
}
}
void dispose() {
_sweep?.cancel();
_sweep = null;
_expires.clear();
}
void _emit() {
if (!_changes.isClosed) _changes.add(current);
}
}
+137 -26
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');
} }
try { final locked = await pinLockRemaining();
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: pin); if (locked != null) throw PinLockedException(locked);
await _openDb(dek);
} on WrongPinException {
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey); final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
if (decoy == null) rethrow; try {
// Když ani decoy nesedí, unwrap vyhodí WrongPinException dál. // Oba bloby jednou derivací (sdílená sůl) — čas odemčení neprozradí,
final dek = await DekWrap.unwrap(wrapped: decoy, pin: pin); // který PIN padl.
await _openDb(dek, decoy: true); final (index, dek) = await DekWrap.unwrapFirstMatching(
wrapped: [wrapped, ?decoy],
pin: pin,
);
await _openDb(dek, decoy: index == 1);
await _resetPinFailures();
} on WrongPinException {
await _notePinFailure();
rethrow;
} }
} }
/// Zbývající zámek po sérii špatných PINů; null = volno.
Future<Duration?> pinLockRemaining() async {
final raw = await _secureStorage.read(key: _pinLockUntilKey);
final until = raw == null ? null : int.tryParse(raw);
if (until == null) return null;
final remaining = DateTime.fromMillisecondsSinceEpoch(until)
.difference(DateTime.now());
return remaining > Duration.zero ? remaining : null;
}
Future<void> _notePinFailure() async {
final raw = await _secureStorage.read(key: _pinFailCountKey);
final failures = (raw == null ? 0 : int.tryParse(raw) ?? 0) + 1;
await _secureStorage.write(key: _pinFailCountKey, value: '$failures');
if (failures < _pinFreeAttempts) return;
// 30 s po páté chybě, pak dvojnásobně až do 15 minut: šest číslic
// (10⁶) tak trvá roky, Argon2id sám by brzdil jen ~2 s na pokus.
var lock = _pinLockBase * (1 << (failures - _pinFreeAttempts));
if (lock > _pinLockMax) lock = _pinLockMax;
await _secureStorage.write(
key: _pinLockUntilKey,
value: '${DateTime.now().add(lock).millisecondsSinceEpoch}',
);
}
Future<void> _resetPinFailures() async {
await _secureStorage.delete(key: _pinFailCountKey);
await _secureStorage.delete(key: _pinLockUntilKey);
}
/// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže. /// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže.
/// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře. /// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře.
Future<void> enablePin(String pin) async { Future<void> enablePin(String pin) async {
_requireRealSession();
final dek = _dek ?? await _getOrCreateRawDek(); final dek = _dek ?? await _getOrCreateRawDek();
final wrapped = await DekWrap.wrap(dek: dek, pin: pin); final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
await _secureStorage.write(key: _wrappedDekKey, value: wrapped); await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
@@ -124,6 +191,7 @@ class SecureStorageService {
/// Vypne PIN: DEK se vrátí raw do OS keystore. /// Vypne PIN: DEK se vrátí raw do OS keystore.
Future<void> disablePin() async { Future<void> disablePin() async {
_requireRealSession();
final dek = _dek ?? (throw StateError('Requires unlocked storage')); final dek = _dek ?? (throw StateError('Requires unlocked storage'));
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek)); await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
await _secureStorage.delete(key: _wrappedDekKey); await _secureStorage.delete(key: _wrappedDekKey);
@@ -151,6 +219,7 @@ class SecureStorageService {
required String oldPin, required String oldPin,
required String newPin, required String newPin,
}) async { }) async {
_requireRealSession();
final wrapped = await _secureStorage.read(key: _wrappedDekKey); final wrapped = await _secureStorage.read(key: _wrappedDekKey);
if (wrapped == null) throw StateError('No PIN set'); if (wrapped == null) throw StateError('No PIN set');
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin); final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin);
@@ -165,7 +234,11 @@ class SecureStorageService {
} }
} }
final rewrapped = await DekWrap.wrap(dek: dek, pin: newPin); // Sůl zůstává: nátlakový blob ji sdílí (jedna derivace pro oba PINy)
// a bez jeho PINu by se přebalit nedal. Jedna sůl pro dva PINy téhož
// uživatele nic neoslabuje — sůl brání předpočítání napříč uživateli.
final rewrapped = await DekWrap.wrap(
dek: dek, pin: newPin, salt: DekWrap.saltOf(wrapped));
await _secureStorage.write(key: _wrappedDekKey, value: rewrapped); await _secureStorage.write(key: _wrappedDekKey, value: rewrapped);
} }
@@ -176,6 +249,7 @@ class SecureStorageService {
/// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy /// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy
/// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit. /// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit.
Future<void> enableDecoyPin(String decoyPin) async { Future<void> enableDecoyPin(String decoyPin) async {
_requireRealSession();
final realWrapped = await _secureStorage.read(key: _wrappedDekKey); final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
if (realWrapped == null) { if (realWrapped == null) {
throw StateError('Set a regular PIN first'); throw StateError('Set a regular PIN first');
@@ -193,7 +267,9 @@ class SecureStorageService {
// Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB // Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB
final decoyPath = await _dbPath(decoy: true); final decoyPath = await _dbPath(decoy: true);
CryptoDb.open(path: decoyPath, dek: decoyDek).close(); CryptoDb.open(path: decoyPath, dek: decoyDek).close();
final wrapped = await DekWrap.wrap(dek: decoyDek, pin: decoyPin); // Stejná sůl jako skutečný blob → jedna Argon2id derivace pro oba PINy.
final wrapped = await DekWrap.wrap(
dek: decoyDek, pin: decoyPin, salt: DekWrap.saltOf(realWrapped));
await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped); await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped);
} }
@@ -203,11 +279,20 @@ class SecureStorageService {
/// Zruší nátlakový PIN a smaže decoy DB. /// Zruší nátlakový PIN a smaže decoy DB.
Future<void> disableDecoyPin() async { Future<void> disableDecoyPin() async {
_requireRealSession();
await _secureStorage.delete(key: _decoyWrappedDekKey); await _secureStorage.delete(key: _decoyWrappedDekKey);
await _deleteDbFiles(await _dbPath(decoy: true));
}
Future<void> _deleteDbFiles(String path) async {
for (final suffix in ['', '-wal', '-shm']) {
try { try {
final f = File(await _dbPath(decoy: true)); final f = File('$path$suffix');
if (f.existsSync()) f.deleteSync(); if (f.existsSync()) f.deleteSync();
} catch (_) {} } catch (_) {
// best-effort úklid
}
}
} }
/// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme /// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme
@@ -222,6 +307,8 @@ class SecureStorageService {
_db?.close(); _db?.close();
_db = null; _db = null;
_dek = null; _dek = null;
_decoyMode = false;
_decoyIdentity = null;
} }
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async { Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
@@ -293,6 +380,11 @@ class SecureStorageService {
/// Načti lokální identitu /// Načti lokální identitu
Future<LocalIdentity?> loadIdentity() async { Future<LocalIdentity?> loadIdentity() async {
if (_decoyMode) {
// Nátlaková relace ukazuje náhodnou identitu jen z paměti — skutečný
// klíč z keystore se do ní nikdy nedostane (nátlakový PIN musí být izolovaný).
return _decoyIdentity ??= await LocalIdentity.create('');
}
final json = await _secureStorage.read(key: _identityKey); final json = await _secureStorage.read(key: _identityKey);
if (json == null) return null; if (json == null) return null;
try { try {
@@ -320,6 +412,7 @@ class SecureStorageService {
} }
Future<List<String>?> loadSeedPhrase() async { Future<List<String>?> loadSeedPhrase() async {
if (_decoyMode) return null;
final json = await _secureStorage.read(key: _seedPhraseKey); final json = await _secureStorage.read(key: _seedPhraseKey);
if (json == null) return null; if (json == null) return null;
return (jsonDecode(json) as List).cast<String>(); return (jsonDecode(json) as List).cast<String>();
@@ -331,6 +424,7 @@ class SecureStorageService {
/// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings). /// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings).
/// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním. /// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním.
Future<void> enableBiometrics() async { Future<void> enableBiometrics() async {
_requireRealSession();
final dek = _dek ?? (throw StateError('Requires unlocked storage')); final dek = _dek ?? (throw StateError('Requires unlocked storage'));
await _secureStorage.write( await _secureStorage.write(
key: _biometricDekKey, key: _biometricDekKey,
@@ -342,6 +436,7 @@ class SecureStorageService {
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý /// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
/// PIN-only model: DEK existuje jen za Argon2id). /// PIN-only model: DEK existuje jen za Argon2id).
Future<void> disableBiometrics() async { Future<void> disableBiometrics() async {
_requireRealSession();
await _secureStorage.delete(key: _biometricDekKey); await _secureStorage.delete(key: _biometricDekKey);
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false'); await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
} }
@@ -442,11 +537,15 @@ class SecureStorageService {
); );
} }
Future<void> addMessage(String contactId, Message message) async { /// Přidá zprávu do konverzace. Vrací false, když už tam zpráva s tímhle id
/// je (opakované doručení) — volající pak přeskočí navazující kroky jako
/// doručenku nebo stahování přílohy.
Future<bool> addMessage(String contactId, Message message) async {
var conv = await loadConversation(contactId); var conv = await loadConversation(contactId);
conv ??= Conversation(contactId: contactId); conv ??= Conversation(contactId: contactId);
conv.addMessage(message); if (!conv.addMessage(message)) return false;
await saveConversation(conv); await saveConversation(conv);
return true;
} }
/// Založí prázdnou konverzaci, pokud ještě není (nová skupina musí být /// Založí prázdnou konverzaci, pokud ještě není (nová skupina musí být
@@ -471,24 +570,29 @@ class SecureStorageService {
if (!isUnlocked) return false; if (!isUnlocked) return false;
var changed = false; var changed = false;
final now = DateTime.now(); final now = DateTime.now();
for (final conv in await loadConversations()) { bool isExpired(Message m) =>
final expired = conv.messages
.where((m) =>
m.disappearAfterSeconds != null && m.disappearAfterSeconds != null &&
m.timestamp m.timestamp
.add(Duration(seconds: m.disappearAfterSeconds!)) .add(Duration(seconds: m.disappearAfterSeconds!))
.isBefore(now)) .isBefore(now);
.toList(); for (final snapshot in await loadConversations()) {
if (!snapshot.messages.any(isExpired)) continue;
// Konverzace se před zápisem načte znovu: mezi snímkem a zápisem
// běží event loop (mazání blobů, příjem zpráv) a starý snímek by
// přepsal zprávu přijatou mezitím — už ACKnutou, tedy navždy pryč.
final conv = await loadConversation(snapshot.contactId);
if (conv == null) continue;
final expired = conv.messages.where(isExpired).toList();
if (expired.isEmpty) continue; if (expired.isEmpty) continue;
changed = true; changed = true;
conv.messages.removeWhere(isExpired);
await saveConversation(conv);
for (final m in expired) { for (final m in expired) {
final blobId = m.attachment?.blobId; final blobId = m.attachment?.blobId;
if (blobId != null) { if (blobId != null) {
await AttachmentStore().delete(blobId); await AttachmentStore().delete(blobId);
} }
conv.messages.remove(m);
} }
await saveConversation(conv);
} }
return changed; return changed;
} }
@@ -508,12 +612,19 @@ class SecureStorageService {
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore /// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
/// i lokální bloby příloh. /// i lokální bloby příloh.
Future<void> deleteAll() async { Future<void> deleteAll() async {
final path = await _dbPath(); if (_decoyMode) {
// Nátlaková relace: „smazat" smí jen prázdnou decoy DB — skutečná
// data a keystore zůstávají (dřív to smazalo reálný účet).
final decoyPath = await _dbPath(decoy: true);
lock(); lock();
for (final suffix in ['', '-wal', '-shm']) { await _deleteDbFiles(decoyPath);
final f = File('$path$suffix'); return;
if (f.existsSync()) f.deleteSync();
} }
final path = await _dbPath();
final decoyPath = await _dbPath(decoy: true);
lock();
await _deleteDbFiles(path);
await _deleteDbFiles(decoyPath);
await AttachmentStore().deleteAll(); await AttachmentStore().deleteAll();
await _secureStorage.deleteAll(); await _secureStorage.deleteAll();
} }
+46
View File
@@ -19,6 +19,7 @@ class SepotColors {
// Text // Text
static const Color textPrimary = Color(0xFFF0EEE8); // mléko static const Color textPrimary = Color(0xFFF0EEE8); // mléko
static const Color textSecondary = Color(0xFF9AA0B5); // mlha static const Color textSecondary = Color(0xFF9AA0B5); // mlha
static const Color textEmphasis = Color(0xFFC4C8D6); // mlha, zvýrazněná
static const Color textMuted = Color(0xFF626880); static const Color textMuted = Color(0xFF626880);
// Akcentní barvy // Akcentní barvy
@@ -39,12 +40,57 @@ class SepotColors {
// Bubliny zpráv — odeslaná teplá (ember), přijatá chladná (povrch) // Bubliny zpráv — odeslaná teplá (ember), přijatá chladná (povrch)
static const Color bubbleSent = Color(0xFFF0795A); static const Color bubbleSent = Color(0xFFF0795A);
static const Color bubbleReceived = Color(0xFF191C28); static const Color bubbleReceived = Color(0xFF191C28);
/// Plovoucí lišty nad obsahem (hlavička chatu, pole pro psaní, navigace).
/// Průhlednost nese rozmazání pod sebou — bez něj by působila jako chyba.
static const Color glass = Color(0xC712141D);
static const Color glassBorder = Color(0x0FFFFFFF);
/// Podklad hlavičky profilu — o tón teplejší než noc, ať avatar vystoupí.
static const Color hero = Color(0xFF14131A);
}
/// Tóny avatarů: tlumený podklad + světlejší inkoust téže barvy. Člověk
/// dostane tón podle otisku klíče, takže má všude a u všech stejnou barvu.
class SepotTint {
const SepotTint(this.background, this.ink);
final Color background;
final Color ink;
static const all = [
SepotTint(Color(0xFF3A2A33), Color(0xFFF2A58F)), // ember
SepotTint(Color(0xFF22352B), Color(0xFFA6CFB2)), // sage
SepotTint(Color(0xFF1E2A3A), Color(0xFF9FB6D6)), // mist
SepotTint(Color(0xFF3A3222), Color(0xFFE6C27A)), // jantar
SepotTint(Color(0xFF2C2638), Color(0xFFC3B2E0)), // levandule
];
/// Stabilní napříč spuštěními — String.hashCode to Dart nezaručuje.
static SepotTint of(String seed) {
var h = 0x811c9dc5;
for (final c in seed.codeUnits) {
h = ((h ^ c) * 0x01000193) & 0xFFFFFFFF;
}
return all[h % all.length];
}
} }
/// Témata aplikace /// Témata aplikace
class SepotTheme { class SepotTheme {
SepotTheme._(); SepotTheme._();
/// Velký nadpis hlavních karet (Chaty, Kontakty, Nastavení).
static const largeTitle = TextStyle(
fontSize: 32,
fontWeight: FontWeight.w800,
letterSpacing: -0.6,
color: SepotColors.textPrimary,
);
/// Výška lišty s velkým nadpisem.
static const largeTitleBarHeight = 76.0;
static ThemeData get dark { static ThemeData get dark {
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
+40
View File
@@ -0,0 +1,40 @@
/// Porovnávání textu pro hledání: bez ohledu na velikost písmen a diakritiku.
/// Lidé na mobilu často píšou „prectu“ místo „přečtu“ — hledání to má najít.
library;
const _map = {
'á': 'a', 'ä': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'ě': 'e', 'ë': 'e',
'í': 'i', 'ľ': 'l', 'ĺ': 'l', 'ň': 'n', 'ó': 'o', 'ô': 'o', 'ö': 'o',
'ř': 'r', 'ŕ': 'r', 'š': 's', 'ť': 't', 'ú': 'u', 'ů': 'u', 'ü': 'u',
'ý': 'y', 'ž': 'z', 'ß': 'ss',
};
/// Malá písmena bez diakritiky. Délka se může změnit jen u „ß“, proto
/// pozice shod počítej přes [foldedIndexes], ne přes indexy výsledku.
String fold(String input) {
final out = StringBuffer();
for (final ch in input.toLowerCase().split('')) {
out.write(_map[ch] ?? ch);
}
return out.toString();
}
/// Začátky všech výskytů [query] v [text] (indexy do PŮVODNÍHO textu).
/// Znaky se skládají po jednom, takže indexy sedí i pro zvýraznění.
List<int> foldedIndexes(String text, String query) {
final q = fold(query);
if (q.isEmpty) return const [];
final chars = text.split('');
final folded = chars.map(fold).toList();
final hits = <int>[];
for (var i = 0; i < chars.length; i++) {
var j = i;
var matched = '';
while (j < chars.length && matched.length < q.length) {
matched += folded[j];
j++;
}
if (matched.startsWith(q)) hits.add(i);
}
return hits;
}
+34 -2
View File
@@ -4,8 +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/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';
@@ -39,10 +43,14 @@ 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();
_restartMessagingAfterUnlock();
if (mounted) context.go('/chats'); if (mounted) context.go('/chats');
} catch (_) { } catch (_) {
// biometrie nedostupná/selhala — PIN je vždy fallback // biometrie nedostupná/selhala — PIN je vždy fallback
@@ -211,6 +219,19 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
}); });
} }
/// Auto-lock službu zastavil a storage zamkl; po odemčení je nutné
/// messaging znovu nastartovat. messagingStartProvider drží starou
/// „hotovou" hodnotu (start se sám neprovede) → invalidace ho přepočítá
/// nad odemčeným storage a spustí start() znovu (jinak žádný příjem ani
/// odesílání až do restartu appky).
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 { Future<void> _verifyPin() async {
setState(() => _isVerifying = true); setState(() => _isVerifying = true);
@@ -218,14 +239,25 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
// „Ověření" PINu = Argon2id unwrap DEK + otevření SQLCipher DB. // „Ověření" PINu = Argon2id unwrap DEK + otevření SQLCipher DB.
// Špatný PIN = GCM tag nesedí = DB zůstává nečitelná. // Špatný PIN = GCM tag nesedí = DB zůstává nečitelná.
await ref.read(storageProvider).unlockWithPin(_pin); await ref.read(storageProvider).unlockWithPin(_pin);
_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');
+41
View File
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:sepot/features/calls/call_screen.dart';
import 'package:sepot/l10n/generated/app_localizations.dart';
import 'package:sepot/models/call_log.dart';
/// Ikona a text záznamu hovoru — sdílí chat i seznam chatů, ať se
/// formulace nerozejdou.
({IconData icon, String text, bool alert}) callLogLabel(
AppLocalizations l10n, CallLog log) {
final icon = log.video ? Icons.videocam_rounded : Icons.call_rounded;
return switch (log.outcome) {
CallOutcome.completed => (
icon: icon,
text: log.video
? l10n.callLogVideo(formatCallDuration(log.durationSeconds))
: l10n.callLogVoice(formatCallDuration(log.durationSeconds)),
alert: false,
),
CallOutcome.missed => (
icon: Icons.phone_missed_rounded,
text: l10n.callLogMissed,
alert: true,
),
CallOutcome.unanswered => (
icon: icon,
text: l10n.callLogUnanswered,
alert: false,
),
CallOutcome.rejected => (
icon: Icons.call_end_rounded,
text: l10n.callLogRejected,
alert: false,
),
CallOutcome.busy => (icon: icon, text: l10n.callLogBusy, alert: false),
CallOutcome.failed => (
icon: Icons.error_outline_rounded,
text: l10n.callLogFailed,
alert: true,
),
};
}

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