14 Commits

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

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

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

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

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

Also create the swipe-to-reply animation controller lazily instead of in
dispose().
2026-10-11 15:31:53 +02:00
Julius Joska 05eeccd83b test: tag suites that need the native libsignal library 2026-10-09 15:15:34 +02:00
133 changed files with 3164 additions and 184 deletions
+45
View File
@@ -6,6 +6,51 @@ verzování dle [ajtak standardu](#verzování).
## [Nevydáno] ## [Nevydáno]
## [1.2.3] – 2026-10-23
### Bezpečnost
- **24 slov jen po čerstvém ověření.** Zálohovací fráze se z odemčené appky
ukáže až po zadání PINu (nebo otisku/obličeje, je-li zapnutý). Totéž platí
pro export a obnovu zálohy, nastavení nátlakového PINu, prodloužení
automatického zámku, vypnutí ochrany obrazovky, zapnutí biometrie
a smazání účtu. Kdo vezme odemčený telefon, k frázi se nedostane.
V nátlakové relaci se fráze nezobrazí nikdy. Ověření PINu podléhá stejnému
zpomalení po chybách jako odemykání.
- **Cizí klíč se jménem kontaktu už nesplyne s kontaktem.** Zpráva od
neznámého klíče je „žádost o zprávu“ se jménem, které si odesílatel zvolil
sám, a s označením „(neznámý)“. Dokud ji nepřijmete, nejde odpovědět,
nechodí od vás potvrzení přečtení ani psaní, nezvoní hovory a notifikace
neukazuje jméno. Když se jméno plete s existujícím kontaktem (i přes
cyrilici, diakritiku nebo neviditelné znaky), ukáže se varování i v chatu
skutečného kontaktu.
- **Změna bezpečnostního kódu.** Když se pod účtem známého kontaktu objeví
jiný klíč, chat ukáže „Bezpečnostní kód kontaktu … se změnil“, ověření
kontaktu se zruší a odpověď neodejde na nový klíč. Nový klíč přijde jako
samostatná žádost o zprávu.
### Opraveno
- **Na iPhonu nechodily budíčky ani hovory na pozadí.** Appka relayi vůbec
neposlala token pro zprávy (APNs) ani pro hovory (VoIP): nastavení
notifikací na iOS skončilo chybou, ta zastavila celou registraci a appka
ji tiše spolkla. Registrace u Apple teď proběhne i bez povolených
notifikací, token pro hovory jde zvlášť a případné selhání ukáže seznam
chatů.
## [1.2.2] – 2026-10-11
### Opraveno
- **Přečtený chat se dál ukazoval jako nepřečtený.** Zpráva, která přišla do
otevřeného chatu, zůstala po návratu do seznamu tučně s odznakem; teď se
přečte hned, když je chat na obrazovce (a appka v popředí). Stažení přílohy
nebo dodatečné doručení už nevrátí přečtenou zprávu mezi nepřečtené.
- Gesto „odpověď tahem“ při zavírání chatu v ladicím buildu padalo na
vytvoření animace nad zrušeným prvkem.
- Na iPhonu hlásila dlaždice Push v Nastavení chybu (volala UnifiedPush,
který existuje jen na Androidu).
### Změněno
- **Nová ikona aplikace:** Š, které se ve spodní části rozpadá do teček.
## [1.2.1] – 2026-10-09 ## [1.2.1] – 2026-10-09
### Opraveno ### Opraveno
+10 -8
View File
@@ -2,16 +2,16 @@
**Šepot** is an end-to-end encrypted messenger with anonymous, on-device identity. There is no phone number, e-mail, or password: an account is a keypair generated on the device, your ID is the fingerprint of your public key, and the only backup is a mnemonic phrase. **Šepot** is an end-to-end encrypted messenger with anonymous, on-device identity. There is no phone number, e-mail, or password: an account is a keypair generated on the device, your ID is the fingerprint of your public key, and the only backup is a mnemonic phrase.
The design goal is **metadata minimization with a public threat model** — never "zero metadata". The relay is deliberately dumb: it cannot read message content, does not learn who sent a message, and does not log IP addresses. What it *does* see is documented honestly below. The design goal is **metadata minimization with a public threat model** — never "zero metadata". The relay is deliberately dumb: it cannot read message content, envelopes carry no sender, and it neither stores a sender–recipient link nor logs IP addresses. What it *does* see while a connection is open is documented honestly below.
> Status: 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). > Status: 1.2.x. Android is on [Google Play](https://play.google.com/store/apps/details?id=cz.sepot.sepot); iOS is on the App Store outside the EU, with EU availability pending Apple's trader verification under the Digital Services Act. See [`CHANGELOG.md`](CHANGELOG.md).
## Features ## Features
- **Signal-grade cryptography** via the official Rust `libsignal`: X3DH + post-quantum PQXDH (Kyber-1024) key agreement, Double Ratchet, and sealed sender. - **End-to-end encryption** via the open-source Rust `libsignal` library: X3DH + post-quantum PQXDH (Kyber-1024) key agreement, Double Ratchet, and sealed sender.
- **Anonymous identity** — no phone/e-mail/password. Recovery through a 24-word mnemonic that encodes the identity key itself (not a KDF seed). - **Anonymous identity** — no phone/e-mail/password. Recovery through a 24-word mnemonic that encodes the identity key itself (not a KDF seed).
- **Encrypted at rest** — all local data lives in SQLCipher, unlocked by a PIN (Argon2id-derived key) or biometrics. Optional **duress PIN** opens a separate empty database. - **Encrypted at rest** — all local data lives in SQLCipher, unlocked by a PIN (Argon2id-derived key) or biometrics. Optional **duress PIN** opens a separate empty database.
- **Group chats** — up to 128 members, pairwise fan-out so every member gets full forward secrecy; the relay never learns a group exists. - **Group chats** — up to 128 members, pairwise fan-out so every member gets full forward secrecy; groups are not a server-side object and the relay keeps no member list.
- **Attachments** — images and files are encrypted client-side (AES-256-GCM) and uploaded as opaque blobs with a TTL; image metadata (EXIF/GPS) is stripped on send. - **Attachments** — images and files are encrypted client-side (AES-256-GCM) and uploaded as opaque blobs with a TTL; image metadata (EXIF/GPS) is stripped on send.
- **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers). - **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers).
- **Push 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. - **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.
@@ -29,9 +29,11 @@ The design goal is **metadata minimization with a public threat model** — neve
SQLCipher at rest opaque attachment blobs SQLCipher at rest opaque attachment blobs
``` ```
Every message is encrypted end-to-end with libsignal and wrapped in a sealed-sender envelope, so the relay routes by recipient delivery address without learning the sender. Undelivered messages sit in a per-recipient queue with a fixed TTL and are hard-deleted on acknowledgement. Every message is encrypted end-to-end with libsignal and wrapped in a sealed-sender envelope, so the envelope itself carries no sender and the relay routes it by the recipient's delivery address. Undelivered messages sit in a per-recipient queue with a fixed TTL and are hard-deleted on acknowledgement.
**What the relay can see:** the recipient's delivery address, message size, and timing. **What it cannot see:** message content, the sender's identity, group membership, or (with logging disabled as shipped) client IP addresses. The full model — including where the trust boundary sits — is in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md) and the crypto facade `app/lib/core/crypto/`. **What the relay can see:** the recipient's delivery address, envelope size and timing, push tokens, and — while a connection is open — the client IP address. Envelopes are submitted over an authenticated WebSocket, so at runtime the relay also knows which connected device hands an envelope to which delivery address; it needs that to deliver. **What it does not see or keep:** message content, names, contacts or group membership. It stores no sender–recipient link and no IP addresses, and writes neither to its logs. Someone who took over the relay could start recording runtime metadata; they still could not read messages. See [`SECURITY.md`](SECURITY.md) and the crypto facade `app/lib/core/crypto/`.
**Where data lives:** messages, accounts and keys are stored only on servers in the EU (Hetzner, Germany). Message content never leaves the device unencrypted. Nothing leaves the EU except an anonymous wake-up push via Google (FCM) or Apple (APNs), which carries no message content or sender; the website runs behind Cloudflare.
## Repository layout ## Repository layout
@@ -71,10 +73,10 @@ UI strings live in `app/lib/l10n/app_*.arb` (`cs`, `en`, `de`, `ru`, `it`) and a
## Security ## Security
Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **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). Cryptography is implemented by the open-source `libsignal` library, not by hand-rolled primitives. Šepot is not affiliated with Signal Messenger LLC; Signal is a trademark of its owner. The project runs regular internal security audits and penetration tests (August, September and October 2026); no independent third-party audit has taken place yet. If you find a vulnerability, please report it privately to **security@sepot.cz** rather than opening a public issue — see [`SECURITY.md`](SECURITY.md) and [`/.well-known/security.txt`](https://sepot.cz/.well-known/security.txt).
## License ## License
Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](LICENSE). Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](LICENSE).
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository 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). The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository and the direct APK releases). That permission covers only Šepot's own code. The bundled `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
+13 -4
View File
@@ -28,7 +28,16 @@ reproduce almost everything.
## What the relay can and cannot see ## What the relay can and cannot see
The threat model is documented in the README: the relay sees routing tokens, The threat model is documented in the README. In short: envelopes carry no
message sizes, timing and push tokens, never content or sender identity. sender (sealed sender) and the relay never sees content. It does see routing
Reports that demonstrate the relay learning more than that are the ones we tokens, envelope sizes, timing and push tokens, and while a connection is
care about most. open the client IP and which authenticated device submits an envelope to
which delivery address. It stores no sender–recipient link and no IP
addresses. Reports that demonstrate the relay learning or keeping more than
that are the ones we care about most.
## Audits
We run regular internal security audits and penetration tests (August,
September and October 2026); fixes are listed in `CHANGELOG.md`. No
independent third-party audit has taken place yet.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

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

Before

Width:  |  Height:  |  Size: 575 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 27 KiB

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

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 B

After

Width:  |  Height:  |  Size: 560 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 817 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 26 KiB

-6
View File
@@ -1,6 +0,0 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+12
View File
@@ -331,6 +331,18 @@ class SepotCrypto {
Future<bool> hasSession(ProtocolAddress remote) => Future<bool> hasSession(ProtocolAddress remote) =>
_stores.session.containsSession(remote); _stores.session.containsSession(remote);
/// Identity klíč, pod kterým naposledy vznikla relace s [remote]
/// (zapisuje ho libsignal při X3DH i při příjmu PreKey zprávy).
/// Null = zatím žádný.
Future<Uint8List?> sessionIdentity(ProtocolAddress remote) async {
final key = await _stores.identity.getIdentity(remote);
return key == null ? null : Uint8List.fromList(key.serialize().toList());
}
/// Zahodí relaci s [remote]; další zpráva založí novou z bundle.
Future<void> dropSession(ProtocolAddress remote) =>
_stores.session.deleteSession(remote);
/// X3DH/PQXDH: zpracuje bundle protistrany a založí session. /// X3DH/PQXDH: zpracuje bundle protistrany a založí session.
Future<void> processRemoteBundle( Future<void> processRemoteBundle(
ProtocolAddress remote, ProtocolAddress remote,
+12
View File
@@ -0,0 +1,12 @@
import 'package:sepot/l10n/generated/app_localizations.dart';
import 'package:sepot/models/contact.dart';
/// Jméno kontaktu pro UI. U žádosti o zprávu s viditelnou značkou, že si
/// jméno zvolil odesílatel sám: „Máma (neznámý)“ se nesmí na pohled
/// shodovat s přijatou „Mámou“ v seznamu, hlavičce ani ve skupině.
String contactName(AppLocalizations l10n, Contact contact) {
if (contact.displayName.isEmpty) return l10n.commonUnknownContact;
return contact.accepted
? contact.displayName
: l10n.contactRequestName(contact.displayName);
}
+3 -3
View File
@@ -74,7 +74,7 @@ class PushBootstrap {
static Future<bool> registrationFailed() async => static Future<bool> registrationFailed() async =>
await _store.read(key: _registrationFailedKey) == '1'; await _store.read(key: _registrationFailedKey) == '1';
static Future<void> _setRegistrationFailed(bool failed) => failed static Future<void> setRegistrationFailed(bool failed) => failed
? _store.write(key: _registrationFailedKey, value: '1') ? _store.write(key: _registrationFailedKey, value: '1')
: _store.delete(key: _registrationFailedKey); : _store.delete(key: _registrationFailedKey);
@@ -93,7 +93,7 @@ class PushBootstrap {
await UnifiedPush.initialize( await UnifiedPush.initialize(
onNewEndpoint: _onNewEndpoint, onNewEndpoint: _onNewEndpoint,
onRegistrationFailed: (_, _) => onRegistrationFailed: (_, _) =>
unawaited(_setRegistrationFailed(true)), unawaited(setRegistrationFailed(true)),
onUnregistered: _onUnregistered, onUnregistered: _onUnregistered,
onMessage: _onMessage, onMessage: _onMessage,
); );
@@ -138,7 +138,7 @@ class PushBootstrap {
} }
static void _onNewEndpoint(PushEndpoint endpoint, String instance) { static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
unawaited(_setRegistrationFailed(false)); // registrace se povedla unawaited(setRegistrationFailed(false)); // registrace se povedla
final s = _service; final s = _service;
if (s != null) { if (s != null) {
s.handleNewEndpoint(endpoint.url); s.handleNewEndpoint(endpoint.url);
+28 -1
View File
@@ -27,6 +27,14 @@ class PushNotifier {
await _plugin.initialize( await _plugin.initialize(
InitializationSettings( InitializationSettings(
android: const AndroidInitializationSettings('@mipmap/ic_launcher'), android: const AndroidInitializationSettings('@mipmap/ic_launcher'),
// Bez iOS nastavení plugin hodí ArgumentError a s ním padala celá
// APNs registrace — iPhony relayi neposlaly token vůbec. O oprávnění
// se žádá až v [requestPermission], ne při první notifikaci.
iOS: const DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
),
linux: LinuxInitializationSettings( linux: LinuxInitializationSettings(
defaultActionName: l10n.pushActionOpen, defaultActionName: l10n.pushActionOpen,
), ),
@@ -48,17 +56,23 @@ class PushNotifier {
priority: Priority.high, priority: Priority.high,
category: AndroidNotificationCategory.message, category: AndroidNotificationCategory.message,
), ),
iOS: const DarwinNotificationDetails(),
linux: const LinuxNotificationDetails(), linux: const LinuxNotificationDetails(),
); );
} }
/// Android 13+ runtime permission — volat při zapnutí pushe v nastavení. /// Oprávnění k viditelným notifikacím (Android 13+, iOS) — volat při
/// zapnutí pushe. Tichý budíček na iOS ho nepotřebuje.
static Future<void> requestPermission() async { static Future<void> requestPermission() async {
await _ensureInit(); await _ensureInit();
await _plugin await _plugin
.resolvePlatformSpecificImplementation< .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>() AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission(); ?.requestNotificationsPermission();
await _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(alert: true, badge: true, sound: true);
} }
/// Notifikace o přijaté zprávě, když appka běží, ale není v popředí. /// Notifikace o přijaté zprávě, když appka běží, ale není v popředí.
@@ -75,6 +89,19 @@ class PushNotifier {
); );
} }
/// Zpráva od neznámého odesílatele (žádost o zprávu): bez jména, které
/// si zvolil sám.
static Future<void> showMessageRequestNotification() async {
await _ensureInit();
final l10n = l10nFor(await LocaleNotifier.readStored());
await _plugin.show(
_notificationId,
l10n.commonAppName,
l10n.pushMessageRequest,
_details(l10n),
);
}
/// Generická notifikace bez obsahu. Klepnutí otevře appku (unlock → /// Generická notifikace bez obsahu. Klepnutí otevře appku (unlock →
/// WS připojení → fronta se vyleje normální cestou). /// WS připojení → fronta se vyleje normální cestou).
static Future<void> showWakeNotification() async { static Future<void> showWakeNotification() async {
+85 -25
View File
@@ -27,6 +27,7 @@ import 'dart:typed_data';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
import 'package:sepot/core/calls/call_ringer.dart'; import 'package:sepot/core/calls/call_ringer.dart';
import 'package:sepot/core/push/apns_ios.dart'; import 'package:sepot/core/push/apns_ios.dart';
import 'package:sepot/core/push/push_bootstrap.dart'; import 'package:sepot/core/push/push_bootstrap.dart';
@@ -35,10 +36,27 @@ import 'package:sepot/core/services/messaging_service.dart';
import 'package:sepot/proto/sepot.pb.dart' as pb; import 'package:sepot/proto/sepot.pb.dart' as pb;
import 'package:unifiedpush/unifiedpush.dart'; import 'package:unifiedpush/unifiedpush.dart';
/// Nativní strana iOS pushů. Oddělená od [PushService], aby šlo pořadí
/// kroků a skládání registrace ověřit testem bez iPhonu.
class IosPushPlatform {
const IosPushPlatform();
bool get isIOS => Platform.isIOS;
Future<void> registerApns() => ApnsBridge.register();
Future<String?> apnsToken() => ApnsBridge.currentToken();
Future<String?> voipToken() => CallRinger.voipToken();
Future<void> requestPermission() => PushNotifier.requestPermission();
Future<void> writeMode(String mode) => PushBootstrap.writeMode(mode);
Future<void> setRegistrationFailed(bool failed) =>
PushBootstrap.setRegistrationFailed(failed);
}
class PushService { class PushService {
PushService(this._messaging); PushService(this._messaging, {IosPushPlatform ios = const IosPushPlatform()})
: _ios = ios;
final MessagingService _messaging; final MessagingService _messaging;
final IosPushPlatform _ios;
StreamSubscription<String>? _fcmTokenSub; StreamSubscription<String>? _fcmTokenSub;
StreamSubscription<RemoteMessage>? _fcmMessageSub; StreamSubscription<RemoteMessage>? _fcmMessageSub;
@@ -94,34 +112,66 @@ class PushService {
String? _apnsToken; String? _apnsToken;
/// APNs token pro zprávy + VoIP token pro hovory v jedné registraci /// Registrace iOS pushů pro relay: APNs token pro zprávy + VoIP token pro
/// (relay drží obojí u zařízení). /// hovory (relay drží obojí u zařízení). VoIP token jde i sám, s prázdným
Future<void> _registerApnsToken(String token) async { /// endpointem — PushKit ho vydá nezávisle na APNs a hovory nesmí čekat,
_apnsToken = token; /// až Apple vydá i token pro zprávy. Null = zatím není co poslat.
final voip = await CallRinger.voipToken(); @visibleForTesting
final reg = _registration( static pb.RegisterPush? apnsRegistration({
pb.RegisterPush_Platform.PLATFORM_APNS, String? apnsToken,
Uint8List.fromList(utf8.encode(token)), String? voipToken,
}) {
if (apnsToken == null && voipToken == null) return null;
final reg = pb.RegisterPush(
platform: pb.RegisterPush_Platform.PLATFORM_APNS,
endpoint: apnsToken == null
? Uint8List(0)
: Uint8List.fromList(utf8.encode(apnsToken)),
); );
if (voip != null) reg.voipToken = utf8.encode(voip); if (voipToken != null) reg.voipToken = utf8.encode(voipToken);
_messaging.registerPush(reg); return reg;
} }
/// PushKit vydal (nový) VoIP token — přeregistrovat, pokud už APNs běží. Future<void> _sendApnsRegistration() async {
Future<void> refreshVoipToken() async { final reg = apnsRegistration(
final token = _apnsToken; apnsToken: _apnsToken,
if (token != null) await _registerApnsToken(token); voipToken: await _ios.voipToken(),
);
if (reg != null) _messaging.registerPush(reg);
} }
Future<void> _registerApnsToken(String token) async {
_apnsToken = token;
await _sendApnsRegistration();
}
/// PushKit vydal (nový) VoIP token — poslat ho relayi, i když APNs token
/// pro zprávy ještě nedorazil.
Future<void> refreshVoipToken() => _sendApnsRegistration();
/// iOS nativní push (APNs) — zapne registraci u Apple; token přijde /// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
/// asynchronně a sám se pošle relayi. Bez Googlu. /// asynchronně a sám se pošle relayi. Bez Googlu.
///
/// Registrace u Apple jde PŘED žádostí o oprávnění: tichý budíček
/// oprávnění nepotřebuje, takže odmítnutý (nebo selhaný) dialog nesmí
/// zastavit doručování zpráv.
Future<void> enableApns() async { Future<void> enableApns() async {
await init(); await init();
await PushNotifier.requestPermission(); await _ios.registerApns();
await ApnsBridge.register(); await _ios.writeMode('apns');
final existing = await ApnsBridge.currentToken(); final existing = await _ios.apnsToken();
if (existing != null) _registerApnsToken(existing); if (existing != null) {
await PushBootstrap.writeMode('apns'); await _registerApnsToken(existing);
} else {
// VoIP token mohl přijít dřív než APNs — hovory ať zvoní hned.
await _sendApnsRegistration();
}
try {
await _ios.requestPermission();
} catch (e, s) {
// Bez oprávnění chodí budíčky dál, jen bez viditelné notifikace.
debugPrint('Oprávnění k notifikacím selhalo: $e\n$s');
}
} }
/// Zapne push: použije uloženého/výchozího distributora a zaregistruje se. /// Zapne push: použije uloženého/výchozího distributora a zaregistruje se.
@@ -138,10 +188,14 @@ class PushService {
} }
/// Seznam nainstalovaných distributorů (pro výběr v nastavení). /// Seznam nainstalovaných distributorů (pro výběr v nastavení).
Future<List<String>> distributors() => UnifiedPush.getDistributors(); /// UnifiedPush existuje jen na Androidu; jinde by volání pluginu vyhodilo
/// UnimplementedError (iOS: dlaždice Push v Nastavení).
Future<List<String>> distributors() async =>
Platform.isAndroid ? UnifiedPush.getDistributors() : const [];
/// Aktuálně uložený distributor (null = push vypnutý / nevybraný). /// Aktuálně uložený distributor (null = push vypnutý / nevybraný).
Future<String?> currentDistributor() => UnifiedPush.getDistributor(); Future<String?> currentDistributor() async =>
Platform.isAndroid ? UnifiedPush.getDistributor() : null;
/// Vybere konkrétního distributora a zaregistruje se u něj. /// Vybere konkrétního distributora a zaregistruje se u něj.
Future<void> useDistributor(String distributor) async { Future<void> useDistributor(String distributor) async {
@@ -158,7 +212,7 @@ class PushService {
} }
Future<bool> get isEnabled async => Future<bool> get isEnabled async =>
(await UnifiedPush.getDistributor()) != null; Platform.isAndroid && (await UnifiedPush.getDistributor()) != null;
// ── Režimy (off / up / fcm) ───────────────────────────────────────── // ── Režimy (off / up / fcm) ─────────────────────────────────────────
@@ -167,6 +221,7 @@ class PushService {
Future<String> currentMode() async { Future<String> currentMode() async {
final mode = await PushBootstrap.readMode(); final mode = await PushBootstrap.readMode();
if (mode != 'off') return mode; if (mode != 'off') return mode;
if (!Platform.isAndroid) return mode;
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off'; return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
} }
@@ -240,10 +295,15 @@ class PushService {
Future<void> restoreMode() async { Future<void> restoreMode() async {
// iOS: jediná smysluplná cesta je APNs (UnifiedPush je Android-only). // iOS: jediná smysluplná cesta je APNs (UnifiedPush je Android-only).
// Zapínáme ho automaticky — pro iOS messenger jsou notifikace základ. // Zapínáme ho automaticky — pro iOS messenger jsou notifikace základ.
if (Platform.isIOS) { if (_ios.isIOS) {
try { try {
await enableApns(); await enableApns();
} catch (_) { await _ios.setRegistrationFailed(false);
} catch (e, s) {
// Dřív se chyba spolkla a iPhone tiše zůstal bez budíčků. Teď ji
// uvidí stav pushů v chat listu (PushHealth.failed).
debugPrint('APNs registrace selhala: $e\n$s');
await _ios.setRegistrationFailed(true);
await init(); await init();
} }
return; return;
+64
View File
@@ -0,0 +1,64 @@
/// Rozpoznání jmen, která se pletou s existujícím kontaktem.
///
/// Jméno v Šepotu si každý volí sám, takže „Máma“ od cizího klíče je
/// nejlevnější phishing. Porovnává se „kostra“ jména: bez velikosti
/// písmen, diakritiky, mezer, interpunkce a neviditelných znaků, s cyrilicí
/// a řečtinou převedenou na latinku. „MAMA“, „Мáма“ (cyrilské М) i
/// „Máma“ s neviditelnou mezerou uprostřed tak padnou na totéž jako „Máma“.
library;
import 'package:sepot/core/utils/text_fold.dart';
import 'package:sepot/models/contact.dart';
// Znaky, které na displeji vypadají jako latinka. Jen ty, co se v malých
// písmenech opravdu pletou; ne celý Unicode confusables seznam — falešná
// shoda tu nic nerozbije, jen přidá varování navíc.
const _confusables = {
'а': 'a', 'в': 'b', 'е': 'e', 'ё': 'e', 'к': 'k', 'м': 'm', 'н': 'h',
'о': 'o', 'р': 'p', 'с': 'c', 'т': 't', 'у': 'y', 'х': 'x', 'і': 'l',
'ї': 'l', 'ј': 'j', 'ѕ': 's', 'ԁ': 'd', 'ɡ': 'g', 'α': 'a', 'β': 'b',
'ε': 'e', 'ι': 'l', 'κ': 'k', 'ν': 'v', 'ο': 'o', 'ρ': 'p', 'τ': 't',
'υ': 'u', 'χ': 'x', 'ү': 'y', '0': 'o', '1': 'l', 'i': 'l', '|': 'l',
// Latinská diakritika mimo češtinu a slovenštinu (tu skládá už fold).
'à': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'ā': 'a', 'ą': 'a', 'ç': 'c',
'ć': 'c', 'è': 'e', 'ê': 'e', 'ē': 'e', 'ę': 'e', 'ì': 'l', 'î': 'l',
'ï': 'l', 'ł': 'l', 'ñ': 'n', 'ń': 'n', 'ò': 'o', 'õ': 'o', 'ø': 'o',
'ő': 'o', 'ś': 's', 'ù': 'u', 'û': 'u', 'ű': 'u', 'ÿ': 'y', 'ź': 'z',
'ż': 'z',
};
final _invisible = RegExp(
'[\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180E'
'\u200B-\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0F\uFEFF]');
final _combining = RegExp('[\u0300-\u036F]');
final _separators = RegExp(r'[\s\p{P}\p{S}]', unicode: true);
/// Kostra jména pro porovnání. Prázdný výsledek = jméno nic nenese.
String nameSkeleton(String name) {
final folded = fold(name
.replaceAll(_invisible, '')
.replaceAll(_combining, '')
.replaceAll(_separators, ''));
final out = StringBuffer();
for (final ch in folded.split('')) {
out.write(_confusables[ch] ?? ch);
}
// „rn“ se v bezpatkovém písmu čte jako „m“.
return out.toString().replaceAll('rn', 'm');
}
/// Přijatý kontakt, se kterým se [name] plete (null = nic podobného).
/// [exceptId] = sám odesílatel, ten se se sebou neporovnává.
Contact? lookalikeContact(
String name,
Iterable<Contact> contacts, {
String? exceptId,
}) {
final skeleton = nameSkeleton(name);
if (skeleton.isEmpty) return null;
for (final c in contacts) {
if (!c.accepted || c.id == exceptId) continue;
if (nameSkeleton(c.displayName) == skeleton) return c;
}
return null;
}
+127 -25
View File
@@ -36,6 +36,7 @@ import 'package:sepot/core/errors.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart'; import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/core/network/relay_client.dart'; import 'package:sepot/core/network/relay_client.dart';
import 'package:sepot/core/network/relay_transport.dart'; import 'package:sepot/core/network/relay_transport.dart';
import 'package:sepot/core/safety/lookalike_names.dart';
import 'package:sepot/core/services/group_admin_policy.dart'; import 'package:sepot/core/services/group_admin_policy.dart';
import 'package:sepot/core/services/image_processor.dart'; import 'package:sepot/core/services/image_processor.dart';
import 'package:sepot/core/services/typing_tracker.dart'; import 'package:sepot/core/services/typing_tracker.dart';
@@ -218,7 +219,7 @@ class MessagingService {
final targets = contacts.where((c) => final targets = contacts.where((c) =>
!c.blocked && !c.blocked &&
(group == null (group == null
? c.id == convId ? c.id == convId && c.accepted
: group.memberIds.contains(c.id) && c.id != _identity?.id)); : group.memberIds.contains(c.id) && c.id != _identity?.id));
for (final c in targets) { for (final c in targets) {
final until = _typingBackoffUntil[c.id]; final until = _typingBackoffUntil[c.id];
@@ -993,6 +994,8 @@ class MessagingService {
Future<void> sendReadReceipts(Contact contact, List<String> messageIds, Future<void> sendReadReceipts(Contact contact, List<String> messageIds,
{String? gid}) async { {String? gid}) async {
if (messageIds.isEmpty) return; if (messageIds.isEmpty) return;
// Neznámý odesílatel se nedozví, že jsem jeho zprávu otevřel.
if (gid == null && !contact.accepted) return;
final enabled = final enabled =
(await storage.getSetting<bool>('read_receipts_enabled')) ?? false; (await storage.getSetting<bool>('read_receipts_enabled')) ?? false;
if (!enabled) return; if (!enabled) return;
@@ -1948,19 +1951,26 @@ class MessagingService {
verified: known.verified, verified: known.verified,
notes: known.notes, notes: known.notes,
blocked: known.blocked, blocked: known.blocked,
avatar: known.avatar,
accepted: known.accepted,
)); ));
} }
continue; continue;
} }
final name = (e['n'] as String?)?.trim(); final name = (e['n'] as String?)?.trim();
await storage.saveContact(Contact.fromPublicIdentity(PublicIdentity( // Člena skupiny jsem si nepřidal já: jméno mu dal on sám (nebo ten,
// Prázdné jméno = UI si doplní lokalizovaný fallback // kdo roster poslal). Jako u příchozí zprávy je to žádost.
displayName: (name == null || name.isEmpty) ? '' : name, await storage.saveContact(Contact.fromPublicIdentity(
identityKey: base64Decode(e['ik'] as String), PublicIdentity(
createdAt: DateTime.now(), // Prázdné jméno = UI si doplní lokalizovaný fallback
accountId: aid, displayName: (name == null || name.isEmpty) ? '' : clipName(name),
deliveryAddress: daB64 == null ? null : base64Decode(daB64), identityKey: base64Decode(e['ik'] as String),
))); createdAt: DateTime.now(),
accountId: aid,
deliveryAddress: daB64 == null ? null : base64Decode(daB64),
),
accepted: false,
));
} }
} }
@@ -2215,12 +2225,14 @@ class MessagingService {
// načte, zašifruje a uloží bez souběhu. Odeslání po síti už zámek // načte, zašifruje a uloží bez souběhu. Odeslání po síti už zámek
// nedrží — čekání na receipt by blokovalo všechny ostatní. // nedrží — čekání na receipt by blokovalo všechny ostatní.
final sealed = await _serial<Uint8List?>(() async { final sealed = await _serial<Uint8List?>(() async {
await _dropForeignSession(crypto, peerAddr, contact);
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět // První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní). // s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
if (!await crypto.hasSession(peerAddr)) { if (!await crypto.hasSession(peerAddr)) {
if (ephemeral) return null; if (ephemeral) return null;
final bundle = await relay.fetchBundle(peerDa); final bundle = await relay.fetchBundle(peerDa);
if (!_listEquals(bundle.identityKey, peer.identityKey)) { if (!_listEquals(bundle.identityKey, peer.identityKey)) {
await _noteIdentityChanged(contact);
throw const IdentityKeyMismatchError(); throw const IdentityKeyMismatchError();
} }
await crypto.processRemoteBundle(peerAddr, bundle); await crypto.processRemoteBundle(peerAddr, bundle);
@@ -2245,6 +2257,29 @@ class MessagingService {
return receipt.status; return receipt.status;
} }
/// libsignal 5.0.9 nad prázdným paměťovým storem identit důvěřuje
/// čemukoli (TOFU bez paměti): příchozí PreKey zpráva s cizím klíčem pod
/// stejnou adresou relaci tiše přepíše a další odpověď by šla jemu.
/// Klíč relace proto porovnáváme s klíčem kontaktu sami; nesedí-li,
/// relace se zahodí (nová vznikne z bundle, který se ověřuje výše)
/// a kontakt dostane varování.
Future<void> _dropForeignSession(
SepotCrypto crypto, ProtocolAddress peerAddr, Contact contact) async {
if (!await crypto.hasSession(peerAddr)) return;
final bound = await crypto.sessionIdentity(peerAddr);
if (bound == null ||
_listEquals(bound, contact.publicIdentity.identityKey)) {
return;
}
await crypto.dropSession(peerAddr);
await _noteIdentityChanged(contact);
}
@visibleForTesting
Future<void> debugDropForeignSession(
SepotCrypto crypto, ProtocolAddress peerAddr, Contact contact) =>
_dropForeignSession(crypto, peerAddr, contact);
// ── Příjem ────────────────────────────────────────────────────────── // ── Příjem ──────────────────────────────────────────────────────────
/// Dodávky se zpracovávají postupně (viz [_serial]) a evidují jako /// Dodávky se zpracovávají postupně (viz [_serial]) a evidují jako
@@ -2279,7 +2314,17 @@ class MessagingService {
// (může jít o dočasný stav — např. cert refresh). // (může jít o dočasný stav — např. cert refresh).
return; return;
} }
await _processOpened(opened, envelope.msgUuid);
}
@visibleForTesting
Future<void> debugProcessOpened(
SepotSealedMessage opened, List<int> msgUuid) =>
_processOpened(opened, msgUuid);
/// Zpracuje už dešifrovanou obálku: kontakt, typ obsahu, uložení, ACK.
Future<void> _processOpened(
SepotSealedMessage opened, List<int> msgUuid) async {
// Od tohohle bodu ratchet postoupil — obálku na relayi už NIKDY znovu // Od tohohle bodu ratchet postoupil — obálku na relayi už NIKDY znovu
// nedešifrujeme (duplicitní decrypt libsignal odmítne). Vadný OBSAH se // nedešifrujeme (duplicitní decrypt libsignal odmítne). Vadný OBSAH se
// proto ACKuje a zahazuje: redelivery nemůže nic spravit, jen by // proto ACKuje a zahazuje: redelivery nemůže nic spravit, jen by
@@ -2292,7 +2337,7 @@ class MessagingService {
if (content['v'] != 1 || !known.contains(type)) { if (content['v'] != 1 || !known.contains(type)) {
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný // Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný
// a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit. // a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
_relay?.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
@@ -2302,15 +2347,19 @@ class MessagingService {
// Blokovaný kontakt: zahoď cokoliv od něj (ACK, ať se nefrontuje), // Blokovaný kontakt: zahoď cokoliv od něj (ACK, ať se nefrontuje),
// nic neukládej ani nezobrazuj a neposílej doručenku. // nic neukládej ani nezobrazuj a neposílej doručenku.
if (contact.blocked) { if (contact.blocked) {
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
// Hovory jen 1:1; skupinový gid tu nemá co dělat. // Hovory jen 1:1; skupinový gid tu nemá co dělat.
// Nic se neukládá — záznam „Hovor · 3:12“ si zapíše obsluha hovoru. // Nic se neukládá — záznam „Hovor · 3:12“ si zapíše obsluha hovoru.
// Žádost o zprávu nezvoní: cizí klíč by jinak rozezvonil telefon
// s vlastním zvoleným jménem („Máma volá“).
if (type == 'call') { if (type == 'call') {
if (content['gid'] == null) onCallSignal?.call(contact, content); if (content['gid'] == null && contact.accepted) {
_relay!.ack(envelope.msgUuid); onCallSignal?.call(contact, content);
}
_relay?.ack(msgUuid);
return; return;
} }
@@ -2320,7 +2369,7 @@ class MessagingService {
// nepomůže). // nepomůže).
if (_groupControlTypes.contains(type)) { if (_groupControlTypes.contains(type)) {
await _handleGroupControl(senderFp, type, content); await _handleGroupControl(senderFp, type, content);
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
@@ -2330,7 +2379,7 @@ class MessagingService {
final group = final group =
gid == null ? null : await _groupForIncoming(gid, senderFp); gid == null ? null : await _groupForIncoming(gid, senderFp);
if (gid != null && group == null) { if (gid != null && group == null) {
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
if (group != null && _noteCapability(group, senderFp, content)) { if (group != null && _noteCapability(group, senderFp, content)) {
@@ -2345,7 +2394,7 @@ class MessagingService {
final fresh = DateTime.now().difference(wireSentAt(content['ts'])) < final fresh = DateTime.now().difference(wireSentAt(content['ts'])) <
TypingTracker.showFor; TypingTracker.showFor;
if (fresh && await _typingEnabled()) typing.mark(convId, senderFp); if (fresh && await _typingEnabled()) typing.mark(convId, senderFp);
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
@@ -2380,7 +2429,7 @@ class MessagingService {
); );
} }
} }
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
onUpdate?.call(); onUpdate?.call();
return; return;
} }
@@ -2389,7 +2438,7 @@ class MessagingService {
// novou bublinu. Aplikujeme a ACKneme (retry z fronty nepomůže). // novou bublinu. Aplikujeme a ACKneme (retry z fronty nepomůže).
if (type == 'rx' || type == 'edit' || type == 'del' || type == 'vote') { if (type == 'rx' || type == 'edit' || type == 'del' || type == 'vote') {
await _applyIncomingControl(convId, type!, content, opened); await _applyIncomingControl(convId, type!, content, opened);
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
onUpdate?.call(); onUpdate?.call();
return; return;
} }
@@ -2407,7 +2456,7 @@ class MessagingService {
if (raw is! String) { if (raw is! String) {
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty // Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh. // nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
// Strop délky z drátu: obálka unese 256 KiB a konverzace je jeden // Strop délky z drátu: obálka unese 256 KiB a konverzace je jeden
@@ -2441,7 +2490,7 @@ class MessagingService {
} }
} on Object { } on Object {
// Trvale vadná metadata — retry z fronty nepomůže, ACKnout // Trvale vadná metadata — retry z fronty nepomůže, ACKnout
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
return; return;
} }
message = Message( message = Message(
@@ -2466,7 +2515,7 @@ class MessagingService {
// Uloženo na disku → teprve teď smí relay obálku zahodit. // Uloženo na disku → teprve teď smí relay obálku zahodit.
// (Blob přílohy do fronty nepatří — stahuje se na pozadí a při // (Blob přílohy do fronty nepatří — stahuje se na pozadí a při
// neúspěchu má UI retry, ACK na něj nečeká.) // neúspěchu má UI retry, ACK na něj nečeká.)
_relay!.ack(envelope.msgUuid); _relay?.ack(msgUuid);
// Zprávu už známe (odesílatel ji poslal znovu po výpadku spojení). // Zprávu už známe (odesílatel ji poslal znovu po výpadku spojení).
// Druhá bublina ani další stahování téhož blobu nemá vzniknout, ale // Druhá bublina ani další stahování téhož blobu nemá vzniknout, ale
@@ -2499,11 +2548,11 @@ class MessagingService {
} on FormatException { } on FormatException {
// Malformovaný obsah (ne-UTF-8/ne-JSON plaintext, vadná base64 // Malformovaný obsah (ne-UTF-8/ne-JSON plaintext, vadná base64
// v profilu či rosteru) — trvale vadné, ACK a zahodit. // v profilu či rosteru) — trvale vadné, ACK a zahodit.
_relay?.ack(envelope.msgUuid); _relay?.ack(msgUuid);
} on TypeError { } on TypeError {
// Pole se špatným typem (txt/ttl/id/re/p jako číslo apod.) — // Pole se špatným typem (txt/ttl/id/re/p jako číslo apod.) —
// trvale vadné, ACK a zahodit. // trvale vadné, ACK a zahodit.
_relay?.ack(envelope.msgUuid); _relay?.ack(msgUuid);
} catch (_) { } catch (_) {
// Přechodné selhání zpracování (storage apod.): NEACKovat — další // Přechodné selhání zpracování (storage apod.): NEACKovat — další
// pokus po reconnectu za nic nedá, i když duplicitní decrypt // pokus po reconnectu za nic nedá, i když duplicitní decrypt
@@ -2733,6 +2782,7 @@ class MessagingService {
// Bez tohohle se zablokovaný kontakt při změně adresy odblokoval. // Bez tohohle se zablokovaný kontakt při změně adresy odblokoval.
blocked: c.blocked, blocked: c.blocked,
avatar: avatarChanged ? avatar : c.avatar, avatar: avatarChanged ? avatar : c.avatar,
accepted: c.accepted,
); );
await storage.saveContact(updated); await storage.saveContact(updated);
if (avatarChanged) { if (avatarChanged) {
@@ -2743,7 +2793,9 @@ class MessagingService {
return updated; return updated;
} }
// Neznámý odesílatel → nový (neověřený) kontakt // Neznámý klíč = nový kontakt, NIKDY splynutí s existujícím. Identita
// je klíč: jméno ani fotka, které si odesílatel zvolil, neříkají nic
// o tom, kdo to je. Proto žádost o zprávu, ne kontakt.
// Prázdné jméno = UI si doplní lokalizovaný fallback // Prázdné jméno = UI si doplní lokalizovaný fallback
final name = (profile?['n'] as String?)?.trim(); final name = (profile?['n'] as String?)?.trim();
final identity = PublicIdentity( final identity = PublicIdentity(
@@ -2753,12 +2805,62 @@ class MessagingService {
accountId: opened.senderName, accountId: opened.senderName,
deliveryAddress: profileDa == null ? null : base64Decode(profileDa), deliveryAddress: profileDa == null ? null : base64Decode(profileDa),
); );
final contact = Contact.fromPublicIdentity(identity)..avatar = avatar; final contact = Contact.fromPublicIdentity(identity, accepted: false)
..avatar = avatar;
await storage.saveContact(contact); await storage.saveContact(contact);
if (avatar != null) unawaited(_downloadAvatar(avatar)); if (avatar != null) unawaited(_downloadAvatar(avatar));
// Účet na relayi patří jednomu klíči navždy (identities.identity_key je
// unikátní). Stejný účet s jiným klíčem = relay lže nebo byl převzat:
// dosavadní kontakt ztrácí ověření a dostane varování.
final previous = contacts
.where((c) => c.publicIdentity.accountId == opened.senderName)
.firstOrNull;
if (previous != null) await _noteIdentityChanged(previous);
final lookalike =
lookalikeContact(identity.displayName, contacts, exceptId: contact.id);
if (lookalike != null && lookalike.id != previous?.id) {
await _noteLookalike(lookalike, identity.displayName);
}
return contact; return contact;
} }
/// Bezpečnostní kód kontaktu se změnil: zrušit ověření a dát to najevo
/// v jeho konverzaci. Opakovaný výskyt za sebou nepřidává další řádky.
Future<void> _noteIdentityChanged(Contact contact) async {
if (contact.verified) {
contact.verified = false;
await storage.saveContact(contact);
}
await _addNotice(contact, MessageNotice.identityChanged);
}
/// Někdo cizí se jmenuje skoro stejně jako [contact]. Připomínka do jeho
/// konverzace (jen když existuje — nezakládat chat kvůli útočníkovi).
Future<void> _noteLookalike(Contact contact, String claimedName) async {
if (await storage.loadConversation(contact.id) == null) return;
await _addNotice(contact, MessageNotice.lookalikeRequest,
content: claimedName);
}
Future<void> _addNotice(Contact contact, MessageNotice notice,
{String? content}) async {
final me = _identity;
if (me == null) return;
final last = (await storage.loadConversation(contact.id))?.lastMessage;
if (last?.notice == notice && last?.content == content) return;
await storage.addMessage(
contact.id,
Message.notice(
contactId: contact.id,
myId: me.id,
notice: notice,
content: content),
);
onUpdate?.call();
}
// ── Sdílení vlastního kontaktu ────────────────────────────────────── // ── Sdílení vlastního kontaktu ──────────────────────────────────────
/// Veřejná identita včetně routingu — tohle patří do QR/odkazu. /// Veřejná identita včetně routingu — tohle patří do QR/odkazu.
+15 -2
View File
@@ -197,15 +197,28 @@ class SecureStorageService {
await _secureStorage.delete(key: _wrappedDekKey); await _secureStorage.delete(key: _wrappedDekKey);
} }
/// Ověří PIN, aniž by cokoli změnil (unwrap a zahození DEK). /// Ověří skutečný PIN, aniž by cokoli změnil (unwrap a zahození DEK).
/// Stojí jednu Argon2id derivaci (~2 s) — to je zároveň rate limit. /// Tímhle se potvrzují citlivé akce v odemčené appce (fráze, záloha,
/// smazání účtu, změna PINu).
///
/// Sdílí brzdu se zámkovou obrazovkou: kdo dostane do ruky odemčený
/// telefon, nesmí tu hádat PIN rychleji než na zámku. Zamčeno =
/// [PinLockedException].
///
/// V nátlakové relaci vždy false: i kdyby někdo znal skutečný PIN, k frázi
/// skutečné identity se odtud dostat nesmí (otevře si appku normálně).
Future<bool> verifyPin(String pin) async { Future<bool> verifyPin(String pin) async {
if (_decoyMode) return false;
final wrapped = await _secureStorage.read(key: _wrappedDekKey); final wrapped = await _secureStorage.read(key: _wrappedDekKey);
if (wrapped == null) return false; if (wrapped == null) return false;
final locked = await pinLockRemaining();
if (locked != null) throw PinLockedException(locked);
try { try {
await DekWrap.unwrap(wrapped: wrapped, pin: pin); await DekWrap.unwrap(wrapped: wrapped, pin: pin);
await _resetPinFailures();
return true; return true;
} on WrongPinException { } on WrongPinException {
await _notePinFailure();
return false; return false;
} }
} }
+8 -1
View File
@@ -71,7 +71,14 @@ class _ChangePinScreenState extends ConsumerState<ChangePinScreen> {
switch (_krok) { switch (_krok) {
case _Krok.stary: case _Krok.stary:
setState(() => _pracuje = true); setState(() => _pracuje = true);
final ok = await storage.verifyPin(_pin); final bool ok;
try {
ok = await storage.verifyPin(_pin);
} on PinLockedException catch (e) {
if (!mounted) return;
_reset(context.l10n.pinLockedFor(e.remaining.inSeconds));
return;
}
if (!mounted) return; if (!mounted) return;
if (!ok) { if (!ok) {
_reset(context.l10n.pinWrong); _reset(context.l10n.pinWrong);
+190
View File
@@ -0,0 +1,190 @@
/// Čerstvé ověření před citlivou akcí v už odemčené appce.
///
/// Odemčený telefon v cizí ruce nesmí stačit na převzetí účtu: 24 slov,
/// záloha, smazání účtu nebo oslabení zámku chtějí PIN (Argon2id unwrap,
/// stejná brzda jako zámková obrazovka), případně skutečnou biometrii.
/// Žádné okno „před chvílí jsem ověřil“ — každá akce se ptá znovu.
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:local_auth/local_auth.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/storage/secure_storage.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/storage_provider.dart';
import 'package:sepot/widgets/pin_keypad.dart';
/// Systémová biometrická výzva. Provider kvůli testům — skutečný
/// `local_auth` ve widget testu neběží.
final biometricPromptProvider = Provider<Future<bool> Function(String reason)>((
ref,
) {
return (reason) => LocalAuthentication().authenticate(
localizedReason: reason,
// biometricOnly: zámek telefonu (PIN/vzor zařízení) by obešel
// Argon2id ochranu slabším faktorem, který zná i „půjčovatel“.
options: const AuthenticationOptions(stickyAuth: true, biometricOnly: true),
);
});
/// Vrátí true, jen když uživatel právě teď prokázal, že zná PIN, nebo
/// prošel biometrií. [reason] se ukáže ve výzvě.
///
/// [allowBiometrics] = false u akcí, které biometrii samy zakládají:
/// zapnutí biometrie se nesmí potvrdit tím, co se teprve zapíná.
Future<bool> confirmSensitiveAction(
BuildContext context,
WidgetRef ref, {
required String reason,
bool allowBiometrics = true,
}) async {
final storage = ref.read(storageProvider);
if (storage.isDecoyMode) return false;
if (!await storage.hasPin()) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.reauthPinRequired),
action: SnackBarAction(
label: context.l10n.reauthSetPin,
onPressed: () => context.push('/pin-setup'),
),
),
);
}
return false;
}
if (allowBiometrics && await storage.isBiometricsEnabled()) {
try {
if (await ref.read(biometricPromptProvider)(reason)) return true;
} catch (_) {
// Biometrie nedostupná nebo zrušená — PIN je vždy cesta.
}
}
if (!context.mounted) return false;
final ok = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: SepotColors.background,
builder: (_) => _ReauthPinSheet(reason: reason),
);
return ok ?? false;
}
class _ReauthPinSheet extends ConsumerStatefulWidget {
const _ReauthPinSheet({required this.reason});
final String reason;
@override
ConsumerState<_ReauthPinSheet> createState() => _ReauthPinSheetState();
}
class _ReauthPinSheetState extends ConsumerState<_ReauthPinSheet> {
String _pin = '';
String? _error;
bool _busy = false;
void _onKey(String key) {
if (_busy) return;
setState(() {
_error = null;
if (key == 'DEL') {
if (_pin.isNotEmpty) _pin = _pin.substring(0, _pin.length - 1);
} else if (_pin.length < 6) {
_pin += key;
}
});
if (_pin.length == 6) _verify();
}
Future<void> _verify() async {
setState(() => _busy = true);
final l10n = context.l10n;
final storage = ref.read(storageProvider);
String error;
try {
if (await storage.verifyPin(_pin)) {
if (mounted) Navigator.of(context).pop(true);
return;
}
final locked = await storage.pinLockRemaining();
error = locked == null
? l10n.pinWrong
: l10n.pinLockedFor(locked.inSeconds);
} on PinLockedException catch (e) {
error = l10n.pinLockedFor(e.remaining.inSeconds);
}
if (!mounted) return;
setState(() {
_error = error;
_pin = '';
_busy = false;
});
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Padding(
padding: const EdgeInsets.fromLTRB(32, 24, 32, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.reauthTitle,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 22,
fontWeight: FontWeight.w700,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
widget.reason,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: SepotColors.textSecondary,
),
),
const SizedBox(height: 32),
PinDots(filled: _pin.length),
if (_error != null) ...[
const SizedBox(height: 16),
Text(
_error!,
textAlign: TextAlign.center,
style: const TextStyle(color: SepotColors.error, fontSize: 14),
),
],
if (_busy) ...[
const SizedBox(height: 16),
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: SepotColors.ember,
),
),
],
const SizedBox(height: 32),
PinKeypad(onKey: _onKey, enabled: !_busy),
const SizedBox(height: 8),
TextButton(
onPressed: _busy ? null : () => Navigator.of(context).pop(false),
child: Text(l10n.commonCancel),
),
],
),
);
}
}
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:sepot/core/l10n/contact_name.dart';
import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/core/theme/app_theme.dart';
@@ -45,13 +46,15 @@ class ArchivedChatsScreen extends ConsumerWidget {
?.where((c) => c.publicIdentity.id == conv.contactId) ?.where((c) => c.publicIdentity.id == conv.contactId)
.firstOrNull; .firstOrNull;
final name = group?.name ?? final name = group?.name ??
contact?.displayName ?? (contact == null
context.l10n.commonUnknownContact; ? context.l10n.commonUnknownContact
: contactName(context.l10n, contact));
return ChatListTile( return ChatListTile(
conversation: conv, conversation: conv,
contactName: name, contactName: name,
myId: myId, myId: myId,
isGroup: group != null, isGroup: group != null,
isRequest: group == null && contact?.accepted == false,
onTap: () => context.push('/conversation/${conv.contactId}'), onTap: () => context.push('/conversation/${conv.contactId}'),
onLongPress: () => _actions(context, ref, conv), onLongPress: () => _actions(context, ref, conv),
); );
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:sepot/core/l10n/contact_name.dart';
import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/core/theme/app_theme.dart';
@@ -206,7 +207,7 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
String nameOf(Conversation c) => ConversationsNotifier.isNotes(c.contactId) String nameOf(Conversation c) => ConversationsNotifier.isNotes(c.contactId)
? l10n.notesTitle ? l10n.notesTitle
: groupOf(c.contactId)?.name ?? : groupOf(c.contactId)?.name ??
contactOf(c.contactId)?.displayName ?? _nameOrNull(l10n, contactOf(c.contactId)) ??
l10n.commonUnknownContact; l10n.commonUnknownContact;
return SafeArea( return SafeArea(
@@ -265,7 +266,8 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
? l10n.chatTyping ? l10n.chatTyping
: writers.length == 1 : writers.length == 1
? l10n.chatTypingNamed( ? l10n.chatTypingNamed(
(contactOf(writers.first)?.displayName ?? '?') (_nameOrNull(l10n, contactOf(writers.first)) ??
'?')
.split(' ') .split(' ')
.first) .first)
: l10n.chatTypingSeveral; : l10n.chatTypingSeveral;
@@ -275,8 +277,10 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
myId: myId, myId: myId,
isGroup: group != null, isGroup: group != null,
verified: contactOf(c.contactId)?.verified ?? false, verified: contactOf(c.contactId)?.verified ?? false,
isRequest: group == null &&
contactOf(c.contactId)?.accepted == false,
lastAuthor: group != null && last != null lastAuthor: group != null && last != null
? contactOf(last.senderId)?.displayName ? _nameOrNull(l10n, contactOf(last.senderId))
: null, : null,
highlight: hits[c], highlight: hits[c],
typingLabel: typingLabel, typingLabel: typingLabel,
@@ -685,3 +689,6 @@ class _EmptyState extends StatelessWidget {
); );
} }
} }
String? _nameOrNull(AppLocalizations l10n, Contact? c) =>
c == null ? null : contactName(l10n, c);
+11 -1
View File
@@ -18,6 +18,10 @@ class ChatListTile extends StatelessWidget {
final bool isGroup; final bool isGroup;
final bool verified; final bool verified;
/// Žádost o zprávu (neznámý odesílatel): náhled místo textu hlásí, o co
/// jde — první věta útočníka nemá dostat prostor v seznamu chatů.
final bool isRequest;
/// Jméno autora poslední zprávy ve skupině (null = 1:1 nebo neznámý). /// Jméno autora poslední zprávy ve skupině (null = 1:1 nebo neznámý).
final String? lastAuthor; final String? lastAuthor;
@@ -40,6 +44,7 @@ class ChatListTile extends StatelessWidget {
required this.myId, required this.myId,
this.isGroup = false, this.isGroup = false,
this.verified = false, this.verified = false,
this.isRequest = false,
this.lastAuthor, this.lastAuthor,
this.highlight, this.highlight,
this.typingLabel, this.typingLabel,
@@ -54,7 +59,9 @@ class ChatListTile extends StatelessWidget {
final unread = conversation.unreadCount(myId); final unread = conversation.unreadCount(myId);
final muted = conversation.isMuted; final muted = conversation.isMuted;
final mine = lastMsg != null && lastMsg.isMine(myId); final mine = lastMsg != null && lastMsg.isMine(myId);
final preview = previewOf(context.l10n, lastMsg); final preview = isRequest
? (icon: Icons.person_search_rounded, text: context.l10n.requestTitle)
: previewOf(context.l10n, lastMsg);
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
@@ -245,6 +252,9 @@ class ChatListTile extends StatelessWidget {
final label = callLogLabel(l10n, call); final label = callLogLabel(l10n, call);
return (icon: label.icon, text: label.text); return (icon: label.icon, text: label.text);
} }
if (msg.notice != null) {
return (icon: Icons.gpp_maybe_rounded, text: l10n.noticePreview);
}
return switch (msg.type) { return switch (msg.type) {
MessageType.text => (icon: null, text: msg.content ?? ''), MessageType.text => (icon: null, text: msg.content ?? ''),
MessageType.image => ( MessageType.image => (
@@ -95,7 +95,9 @@ class _ContactsScreenState extends ConsumerState<ContactsScreen> {
Expanded( Expanded(
child: contactsAsync.when( child: contactsAsync.when(
data: (contacts) { data: (contacts) {
// Žádosti o zprávu kontakty nejsou — žijí jen v seznamu chatů.
final filtered = contacts.where((c) { final filtered = contacts.where((c) {
if (!c.accepted) return false;
if (_searchQuery.isEmpty) return true; if (_searchQuery.isEmpty) return true;
return c.displayName.toLowerCase().contains(_searchQuery) || return c.displayName.toLowerCase().contains(_searchQuery) ||
c.sepotId.toLowerCase().contains(_searchQuery); c.sepotId.toLowerCase().contains(_searchQuery);
@@ -5,6 +5,7 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:sepot/core/l10n/contact_name.dart';
import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/core/theme/app_theme.dart';
@@ -17,9 +18,7 @@ import 'package:sepot/providers/groups_provider.dart';
import 'package:sepot/providers/storage_provider.dart'; import 'package:sepot/providers/storage_provider.dart';
String contactLabel(BuildContext context, Contact contact) => String contactLabel(BuildContext context, Contact contact) =>
contact.displayName.isEmpty contactName(context.l10n, contact);
? context.l10n.commonUnknownContact
: contact.displayName;
void _snack(BuildContext context, String text) { void _snack(BuildContext context, String text) {
if (!context.mounted) return; if (!context.mounted) return;
@@ -12,6 +12,7 @@ import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:mime/mime.dart'; import 'package:mime/mime.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:sepot/core/l10n/contact_name.dart';
import 'package:sepot/core/calls/call_controller.dart'; import 'package:sepot/core/calls/call_controller.dart';
import 'package:sepot/core/constants/app_constants.dart'; import 'package:sepot/core/constants/app_constants.dart';
import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/error_messages.dart';
@@ -21,6 +22,8 @@ import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/core/utils/text_fold.dart'; import 'package:sepot/core/utils/text_fold.dart';
import 'package:sepot/features/calls/call_log_text.dart'; import 'package:sepot/features/calls/call_log_text.dart';
import 'package:sepot/features/conversation/chat_actions.dart'; import 'package:sepot/features/conversation/chat_actions.dart';
import 'package:sepot/features/conversation/message_request_panel.dart';
import 'package:sepot/features/conversation/widgets/safety_notice_row.dart';
import 'package:sepot/features/conversation/forward_sheet.dart'; import 'package:sepot/features/conversation/forward_sheet.dart';
import 'package:sepot/features/conversation/widgets/attachment_content.dart'; import 'package:sepot/features/conversation/widgets/attachment_content.dart';
import 'package:sepot/features/conversation/widgets/create_poll_sheet.dart'; import 'package:sepot/features/conversation/widgets/create_poll_sheet.dart';
@@ -64,7 +67,8 @@ class ConversationScreen extends ConsumerStatefulWidget {
ConsumerState<ConversationScreen> createState() => _ConversationScreenState(); ConsumerState<ConversationScreen> createState() => _ConversationScreenState();
} }
class _ConversationScreenState extends ConsumerState<ConversationScreen> { class _ConversationScreenState extends ConsumerState<ConversationScreen>
with WidgetsBindingObserver {
final _messageController = TextEditingController(); final _messageController = TextEditingController();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
final _searchController = TextEditingController(); final _searchController = TextEditingController();
@@ -133,9 +137,43 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
_drafts = ref.read(draftsProvider.notifier); _drafts = ref.read(draftsProvider.notifier);
_restoreDraft(); _restoreDraft();
_messageController.addListener(_scheduleDraftSave); _messageController.addListener(_scheduleDraftSave);
_conversations = ref.read(conversationsProvider.notifier);
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) => _captureUnreadAndMark()); WidgetsBinding.instance.addPostFrameCallback((_) => _captureUnreadAndMark());
} }
/// Notifier zachycený v initState: v dispose už `ref` použít nejde.
late final ConversationsNotifier _conversations;
/// Zpráva, která přijde do otevřeného chatu, je přečtená, jen když ji
/// uživatel opravdu vidí: appka v popředí a tahle obrazovka navrchu
/// (ne pod profilem kontaktu nebo náhledem fotky).
bool get _isSeenNow =>
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed &&
(_route?.isCurrent ?? false);
// Route uložená v didChangeDependencies: lifecycle callback může přijít
// i ve chvíli, kdy je element už deaktivovaný a hledat předka nesmí.
ModalRoute<Object?>? _route;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_route = ModalRoute.of(context);
}
void _markIfSeen() {
if (!mounted || !_isSeenNow) return;
_conversations.markAsRead(widget.contactId);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Zprávy, které přišly se zhasnutým displejem nad otevřeným chatem,
// uživatel uvidí až po návratu do appky.
if (state == AppLifecycleState.resumed) _markIfSeen();
}
/// Zapamatuje si první nepřečtenou zprávu a teprve pak konverzaci označí /// Zapamatuje si první nepřečtenou zprávu a teprve pak konverzaci označí
/// za přečtenou — jinak by řádek „nové zprávy“ nikdy nebylo kam dát. /// za přečtenou — jinak by řádek „nové zprávy“ nikdy nebylo kam dát.
Future<void> _captureUnreadAndMark() async { Future<void> _captureUnreadAndMark() async {
@@ -185,6 +223,10 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
// Odchod z chatu: všechno, co bylo na obrazovce, je přečtené – i zprávy
// dorazivší těsně před odchodem, které listener nestihl označit.
_conversations.markAsRead(widget.contactId);
_draftTimer?.cancel(); _draftTimer?.cancel();
// Odchod z chatu uprostřed úpravy = koncept je ten odložený, ne text // Odchod z chatu uprostřed úpravy = koncept je ten odložený, ne text
// upravované zprávy. // upravované zprávy.
@@ -214,6 +256,17 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
.where((c) => c.publicIdentity.id == widget.contactId) .where((c) => c.publicIdentity.id == widget.contactId)
.firstOrNull; .firstOrNull;
final conversationAsync = ref.watch(conversationProvider(widget.contactId)); final conversationAsync = ref.watch(conversationProvider(widget.contactId));
// Nová příchozí zpráva v otevřeném chatu se rovnou čte; jinak by po
// návratu do seznamu svítila jako nepřečtená.
// První načtení vynechá: to obslouží _captureUnreadAndMark, které si
// nejdřív zapamatuje řádek „N nových zpráv“.
ref.listen(conversationProvider(widget.contactId), (prev, next) {
if (prev?.valueOrNull == null) return;
final conv = next.valueOrNull;
final myId = ref.read(identityProvider).valueOrNull?.id;
if (conv == null || myId == null || conv.unreadCount(myId) == 0) return;
_markIfSeen();
});
final identity = ref.watch(identityProvider).valueOrNull; final identity = ref.watch(identityProvider).valueOrNull;
// Skupinová konverzace: contactId == gid // Skupinová konverzace: contactId == gid
final group = ref.watch(groupProvider(widget.contactId)).valueOrNull; final group = ref.watch(groupProvider(widget.contactId)).valueOrNull;
@@ -222,14 +275,13 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
// v kontaktech (roster je zakládá až s první zprávou), zkrátíme na id. // v kontaktech (roster je zakládá až s první zprávou), zkrátíme na id.
String autorJmeno(String fingerprint) { String autorJmeno(String fingerprint) {
if (fingerprint == identity?.id) return context.l10n.groupsYou; if (fingerprint == identity?.id) return context.l10n.groupsYou;
final jmeno = contacts final autor = contacts
.where((c) => c.publicIdentity.id == fingerprint) .where((c) => c.publicIdentity.id == fingerprint)
.firstOrNull .firstOrNull;
?.displayName; if (autor == null) {
if (jmeno == null) {
return 'SEP-${fingerprint.substring(0, fingerprint.length.clamp(0, 10))}'; return 'SEP-${fingerprint.substring(0, fingerprint.length.clamp(0, 10))}';
} }
return jmeno.isEmpty ? context.l10n.commonUnknownContact : jmeno; return contactName(context.l10n, autor);
} }
final Widget bottom; final Widget bottom;
@@ -243,6 +295,8 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
child: Text(context.l10n.chatUnblock), child: Text(context.l10n.chatUnblock),
), ),
); );
} else if (group == null && contact != null && !contact.accepted) {
bottom = MessageRequestPanel(contact: contact);
} else { } else {
bottom = _buildComposer(); bottom = _buildComposer();
} }
@@ -422,9 +476,9 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
} }
Widget _buildContactHeader(Contact? contact, {bool typing = false}) { Widget _buildContactHeader(Contact? contact, {bool typing = false}) {
final name = (contact == null || contact.displayName.isEmpty) final name = contact == null
? context.l10n.commonUnknownContact ? context.l10n.commonUnknownContact
: contact.displayName; : contactName(context.l10n, contact);
final verified = contact?.verified ?? false; final verified = contact?.verified ?? false;
return _buildHeaderFrame(children: [ return _buildHeaderFrame(children: [
_backButton(), _backButton(),
@@ -435,19 +489,27 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
// Stav ověření místo syrového ID (UX-FLOWS §3.1) // Stav ověření místo syrového ID (UX-FLOWS §3.1)
subtitle: typing subtitle: typing
? _typingLabel(context.l10n.chatTyping) ? _typingLabel(context.l10n.chatTyping)
: Text( : contact != null && !contact.accepted
verified ? Text(
? context.l10n.verifyVerified context.l10n.requestTitle,
: context.l10n.verifyUnverified, style: const TextStyle(
style: TextStyle( fontSize: 12, color: SepotColors.warning),
fontSize: 12, )
color: : Text(
verified ? SepotColors.sage : SepotColors.textSecondary, verified
), ? context.l10n.verifyVerified
), : context.l10n.verifyUnverified,
style: TextStyle(
fontSize: 12,
color: verified
? SepotColors.sage
: SepotColors.textSecondary,
),
),
onTap: () => _openProfile('/contact/${widget.contactId}'), onTap: () => _openProfile('/contact/${widget.contactId}'),
), ),
if (contact != null && !contact.blocked) ...[ // Žádosti o zprávu se nevolá — hovor by prozradil, že jsem u telefonu.
if (contact != null && !contact.blocked && contact.accepted) ...[
IconButton( IconButton(
tooltip: context.l10n.callVoice, tooltip: context.l10n.callVoice,
icon: const Icon(Icons.call_outlined, icon: const Icon(Icons.call_outlined,
@@ -773,6 +835,27 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
? null ? null
: messages.where((m) => m.id == msg.replyToId).firstOrNull; : messages.where((m) => m.id == msg.replyToId).firstOrNull;
final notice = msg.notice;
if (notice != null) {
return KeyedSubtree(
key: _keys.putIfAbsent(msg.id, GlobalKey.new),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showDate) _buildDateDivider(msg.timestamp),
SafetyNoticeRow(
notice: notice,
contactName: autorJmeno(widget.contactId),
claimedName: msg.content,
onVerify: group == null
? () => context.push('/verify/${widget.contactId}')
: null,
),
],
),
);
}
final callLog = msg.callLog; final callLog = msg.callLog;
if (callLog != null) { if (callLog != null) {
return KeyedSubtree( return KeyedSubtree(
@@ -1695,9 +1778,7 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
String jmeno(String f) { String jmeno(String f) {
final c = contacts.where((c) => c.id == f).firstOrNull; final c = contacts.where((c) => c.id == f).firstOrNull;
if (c == null) return 'SEP-${f.substring(0, f.length.clamp(0, 10))}'; if (c == null) return 'SEP-${f.substring(0, f.length.clamp(0, 10))}';
return c.displayName.isEmpty return contactName(context.l10n, c);
? context.l10n.commonUnknownContact
: c.displayName;
} }
Widget radek(IconData icon, String label, DateTime? t, {Color? color}) { Widget radek(IconData icon, String label, DateTime? t, {Color? color}) {
@@ -2150,7 +2231,11 @@ class _SwipeToReplyState extends State<_SwipeToReply>
static const _max = 84.0; static const _max = 84.0;
static const _trigger = 58.0; static const _trigger = 58.0;
late final AnimationController _back = AnimationController( // Kontroler vzniká až při prvním tahu. Dřív ho `late final` vytvořil
// teprve dispose() u bublin, které nikdo netáhl – nad deaktivovaným
// elementem, což v debug buildu padá na hledání TickerMode.
AnimationController? _backCtl;
AnimationController get _back => _backCtl ??= AnimationController(
vsync: this, duration: const Duration(milliseconds: 180)) vsync: this, duration: const Duration(milliseconds: 180))
..addListener(() => setState(() => _dx = _from * (1 - _back.value))); ..addListener(() => setState(() => _dx = _from * (1 - _back.value)));
double _dx = 0; double _dx = 0;
@@ -2159,7 +2244,7 @@ class _SwipeToReplyState extends State<_SwipeToReply>
@override @override
void dispose() { void dispose() {
_back.dispose(); _backCtl?.dispose();
super.dispose(); super.dispose();
} }
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sepot/core/l10n/contact_name.dart';
import 'package:sepot/core/constants/app_constants.dart'; import 'package:sepot/core/constants/app_constants.dart';
import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
@@ -37,8 +38,8 @@ Future<void> forwardMessage(
for (final g in groups) for (final g in groups)
if (!g.left && g.id != fromId) _Target(g.id, g.name, isGroup: true), if (!g.left && g.id != fromId) _Target(g.id, g.name, isGroup: true),
for (final c in contacts) for (final c in contacts)
if (!c.blocked && c.id != fromId) if (!c.blocked && c.accepted && c.id != fromId)
_Target(c.id, c.displayName, verified: c.verified), _Target(c.id, contactName(context.l10n, c), verified: c.verified),
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); ]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
// Poznámky nahoře: přeposlat si něco „na později“ je nejčastější případ. // Poznámky nahoře: přeposlat si něco „na později“ je nejčastější případ.
if (!ConversationsNotifier.isNotes(fromId)) { if (!ConversationsNotifier.isNotes(fromId)) {
@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:sepot/core/l10n/contact_name.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/models/attachment.dart'; import 'package:sepot/models/attachment.dart';
@@ -103,9 +104,9 @@ class _ImageViewerScreenState extends ConsumerState<ImageViewerScreen> {
final contact = (ref.read(contactsProvider).valueOrNull ?? const []) final contact = (ref.read(contactsProvider).valueOrNull ?? const [])
.where((c) => c.publicIdentity.id == m.senderId) .where((c) => c.publicIdentity.id == m.senderId)
.firstOrNull; .firstOrNull;
return contact?.displayName.isNotEmpty == true return contact == null
? contact!.displayName ? context.l10n.commonUnknownContact
: context.l10n.commonUnknownContact; : contactName(context.l10n, contact);
} }
@override @override
@@ -0,0 +1,193 @@
/// Žádost o zprávu: místo psacího pole u odesílatele, kterého jsem si
/// nepřidal. Jméno i fotku si zvolil sám, takže dokud ho nepřijmu, nesmí
/// vypadat jako můj kontakt a nesmí ode mě dostat odpověď omylem.
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/safety/lookalike_names.dart';
import 'package:sepot/core/services/messaging_service.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/features/conversation/chat_actions.dart';
import 'package:sepot/models/contact.dart';
import 'package:sepot/providers/contacts_provider.dart';
class MessageRequestPanel extends ConsumerWidget {
const MessageRequestPanel({super.key, required this.contact});
final Contact contact;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = context.l10n;
final contacts = ref.watch(contactsProvider).valueOrNull ?? const [];
final claimed = contact.displayName.isEmpty
? l10n.commonUnknownContact
: contact.displayName;
final lookalike =
lookalikeContact(contact.displayName, contacts, exceptId: contact.id);
return SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(10, 6, 10, 10),
child: Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 10),
decoration: BoxDecoration(
color: SepotColors.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: lookalike != null ? SepotColors.error : SepotColors.warning,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
lookalike != null
? Icons.gpp_maybe_rounded
: Icons.person_search_rounded,
size: 20,
color: lookalike != null
? SepotColors.error
: SepotColors.warning,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.requestTitle,
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w700),
),
),
],
),
const SizedBox(height: 8),
Text(
l10n.requestBody(claimed),
style: const TextStyle(
fontSize: 13, color: SepotColors.textSecondary),
),
if (lookalike != null) ...[
const SizedBox(height: 8),
Text(
l10n.requestLookalikeWarning(lookalike.displayName),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: SepotColors.error),
),
],
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () =>
setContactBlocked(context, ref, contact, true),
child: Text(l10n.chatMenuBlockContact,
style: const TextStyle(color: SepotColors.error)),
),
const SizedBox(width: 4),
FilledButton(
onPressed: () => acceptMessageRequest(context, ref, contact),
child: Text(l10n.requestAccept),
),
],
),
],
),
),
);
}
}
/// Přijetí = uživatel kontakt pojmenuje sám. Jméno, které se plete
/// s jiným přijatým kontaktem, neprojde: dvě „Mámy“ v seznamu by
/// podvrh jen schovaly.
Future<void> acceptMessageRequest(
BuildContext context, WidgetRef ref, Contact contact) async {
final contacts = ref.read(contactsProvider).valueOrNull ?? const [];
final controller = TextEditingController(
text: lookalikeContact(contact.displayName, contacts,
exceptId: contact.id) ==
null
? contact.displayName
: '',
);
final name = await showDialog<String>(
context: context,
builder: (ctx) => _AcceptDialog(
controller: controller,
isTaken: (n) =>
lookalikeContact(n, contacts, exceptId: contact.id) != null,
),
);
controller.dispose();
if (name == null) return;
await ref.read(contactsProvider.notifier).accept(contact.id, name: name);
}
class _AcceptDialog extends StatefulWidget {
const _AcceptDialog({required this.controller, required this.isTaken});
final TextEditingController controller;
final bool Function(String name) isTaken;
@override
State<_AcceptDialog> createState() => _AcceptDialogState();
}
class _AcceptDialogState extends State<_AcceptDialog> {
String? _error;
void _submit() {
final l10n = context.l10n;
final name = widget.controller.text.trim();
if (name.isEmpty) {
setState(() => _error = l10n.requestNameEmpty);
return;
}
if (widget.isTaken(name)) {
setState(() => _error = l10n.requestNameTaken);
return;
}
Navigator.of(context).pop(name);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return AlertDialog(
title: Text(l10n.requestAcceptTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.requestAcceptBody, style: const TextStyle(fontSize: 13)),
const SizedBox(height: 12),
TextField(
controller: widget.controller,
autofocus: true,
maxLength: MessagingService.maxDisplayNameLength,
decoration: InputDecoration(
labelText: l10n.requestAcceptNameLabel,
errorText: _error,
),
onSubmitted: (_) => _submit(),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.commonCancel),
),
FilledButton(onPressed: _submit, child: Text(l10n.requestAccept)),
],
);
}
}
@@ -0,0 +1,78 @@
/// Bezpečnostní upozornění uprostřed konverzace. Vypadá jinak než bublina
/// (nikdo ho neposlal), aby nešlo zaměnit se zprávou protistrany.
library;
import 'package:flutter/material.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/models/message.dart';
class SafetyNoticeRow extends StatelessWidget {
const SafetyNoticeRow({
super.key,
required this.notice,
required this.contactName,
this.claimedName,
this.onVerify,
});
final MessageNotice notice;
final String contactName;
/// Jméno, které si zvolil neznámý odesílatel ([MessageNotice.lookalikeRequest]).
final String? claimedName;
/// Odkaz na porovnání bezpečnostního kódu (null = bez tlačítka).
final VoidCallback? onVerify;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final text = switch (notice) {
MessageNotice.identityChanged => l10n.noticeIdentityChanged(contactName),
MessageNotice.lookalikeRequest =>
l10n.noticeLookalikeRequest(claimedName ?? '', contactName),
};
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Container(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 6),
decoration: BoxDecoration(
color: SepotColors.surfaceVariant,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: SepotColors.warning),
),
child: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.gpp_maybe_rounded,
size: 18, color: SepotColors.warning),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 13, color: SepotColors.textPrimary),
),
),
],
),
if (onVerify != null &&
notice == MessageNotice.identityChanged)
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: onVerify,
child: Text(l10n.noticeVerifyAction),
),
)
else
const SizedBox(height: 6),
],
),
),
);
}
}
@@ -81,7 +81,9 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
: null; : null;
final contactsAsync = ref.watch(contactsProvider); final contactsAsync = ref.watch(contactsProvider);
final contacts = (contactsAsync.valueOrNull ?? []) final contacts = (contactsAsync.valueOrNull ?? [])
.where((c) => !c.blocked) // Do skupiny jen lidi, které jsem přijal — žádost o zprávu by
// jinak dostala roster všech členů pod jménem, které si vymyslela.
.where((c) => !c.blocked && c.accepted)
.where((c) => existing == null || !existing.memberIds.contains(c.id)) .where((c) => existing == null || !existing.memberIds.contains(c.id))
.toList(); .toList();
// Já jsem v memberIds existující skupiny; nová skupina mě přidá sama. // Já jsem v memberIds existující skupiny; nová skupina mě přidá sama.
@@ -12,6 +12,7 @@ import 'package:local_auth/local_auth.dart';
import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/features/auth/reauth.dart';
import 'package:sepot/providers/storage_provider.dart'; import 'package:sepot/providers/storage_provider.dart';
class BiometricsTile extends ConsumerStatefulWidget { class BiometricsTile extends ConsumerStatefulWidget {
@@ -48,6 +49,14 @@ class _BiometricsTileState extends ConsumerState<BiometricsTile> {
_snack(l10n.biometricsStorageLocked); _snack(l10n.biometricsStorageLocked);
return; return;
} }
// Biometrie dostane kopii DEK — zapnout ji smí jen ten, kdo zná PIN.
// Otisk sám nestačí: v telefonu může být zapsaný i cizí prst.
if (!mounted ||
!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonBiometrics,
allowBiometrics: false)) {
return;
}
final auth = LocalAuthentication(); final auth = LocalAuthentication();
if (!await auth.isDeviceSupported()) { if (!await auth.isDeviceSupported()) {
_snack(l10n.biometricsNotAvailable); _snack(l10n.biometricsNotAvailable);
+89 -38
View File
@@ -24,6 +24,7 @@ import 'package:sepot/providers/identity_provider.dart';
import 'package:sepot/providers/messaging_provider.dart'; import 'package:sepot/providers/messaging_provider.dart';
import 'package:sepot/providers/storage_provider.dart'; import 'package:sepot/providers/storage_provider.dart';
import 'package:sepot/providers/settings_provider.dart'; import 'package:sepot/providers/settings_provider.dart';
import 'package:sepot/features/auth/reauth.dart';
import 'package:sepot/features/settings/biometrics_tile.dart'; import 'package:sepot/features/settings/biometrics_tile.dart';
import 'package:sepot/features/settings/language_settings_tile.dart'; import 'package:sepot/features/settings/language_settings_tile.dart';
import 'package:sepot/features/settings/push_settings_tile.dart'; import 'package:sepot/features/settings/push_settings_tile.dart';
@@ -151,8 +152,7 @@ class SettingsScreen extends ConsumerWidget {
subtitle: l10n.settingsScreenSecuritySubtitle, subtitle: l10n.settingsScreenSecuritySubtitle,
trailing: Switch( trailing: Switch(
value: ref.watch(screenSecurityProvider).valueOrNull ?? false, value: ref.watch(screenSecurityProvider).valueOrNull ?? false,
onChanged: (v) => onChanged: (v) => _setScreenSecurity(context, ref, v),
ref.read(screenSecurityProvider.notifier).set(v),
activeTrackColor: SepotColors.ember, activeTrackColor: SepotColors.ember,
), ),
onTap: () {}, onTap: () {},
@@ -319,9 +319,15 @@ class SettingsScreen extends ConsumerWidget {
], ],
), ),
); );
if (vybrano != null && vybrano != ted) { if (vybrano == null || vybrano == ted || !context.mounted) return;
await ref.read(autoLockProvider.notifier).set(vybrano); // Delší interval nebo „nikdy“ = slabší zámek. Kratší projde bez ptaní.
final weakens = vybrano == 0 || (ted != 0 && vybrano > ted);
if (weakens &&
!await confirmSensitiveAction(context, ref,
reason: context.l10n.reauthReasonAutoLock)) {
return;
} }
await ref.read(autoLockProvider.notifier).set(vybrano);
} }
/// Moje profilová fotka: vybrat z galerie (oříznutí a zmenšení běží /// Moje profilová fotka: vybrat z galerie (oříznutí a zmenšení běží
@@ -518,6 +524,10 @@ class SettingsScreen extends ConsumerWidget {
final l10n = context.l10n; final l10n = context.l10n;
final identity = ref.read(identityProvider).valueOrNull; final identity = ref.read(identityProvider).valueOrNull;
if (identity == null) return; if (identity == null) return;
if (!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonBackupExport)) {
return;
}
try { try {
final bytes = await ref.read(backupServiceProvider).export(identity); final bytes = await ref.read(backupServiceProvider).export(identity);
final dir = await getTemporaryDirectory(); final dir = await getTemporaryDirectory();
@@ -547,6 +557,10 @@ class SettingsScreen extends ConsumerWidget {
final l10n = context.l10n; final l10n = context.l10n;
final identity = ref.read(identityProvider).valueOrNull; final identity = ref.read(identityProvider).valueOrNull;
if (identity == null) return; if (identity == null) return;
if (!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonBackupImport)) {
return;
}
try { try {
final res = await FilePicker.pickFiles(withData: true); final res = await FilePicker.pickFiles(withData: true);
final data = res?.files.firstOrNull?.bytes; final data = res?.files.firstOrNull?.bytes;
@@ -570,6 +584,11 @@ class SettingsScreen extends ConsumerWidget {
Future<void> _setDecoyPin(BuildContext context, WidgetRef ref) async { Future<void> _setDecoyPin(BuildContext context, WidgetRef ref) async {
final l10n = context.l10n; final l10n = context.l10n;
if (!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonDecoyPin)) {
return;
}
if (!context.mounted) return;
final pinCtrl = TextEditingController(); final pinCtrl = TextEditingController();
final confirmCtrl = TextEditingController(); final confirmCtrl = TextEditingController();
const numberField = TextInputType.number; const numberField = TextInputType.number;
@@ -645,6 +664,10 @@ class SettingsScreen extends ConsumerWidget {
Future<void> _disableDecoyPin(BuildContext context, WidgetRef ref) async { Future<void> _disableDecoyPin(BuildContext context, WidgetRef ref) async {
final l10n = context.l10n; final l10n = context.l10n;
if (!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonDecoyPin)) {
return;
}
await ref.read(storageProvider).disableDecoyPin(); await ref.read(storageProvider).disableDecoyPin();
ref.invalidate(hasDecoyPinProvider); ref.invalidate(hasDecoyPinProvider);
if (context.mounted) { if (context.mounted) {
@@ -654,8 +677,15 @@ class SettingsScreen extends ConsumerWidget {
} }
} }
void _showSeedPhrase(BuildContext context, WidgetRef ref) async { /// Fráze = celý účet navždy. Načte se z keystore až po čerstvém ověření
/// a kopírovat ji nejde záměrně: schránku čtou klávesnice a synchronizace
/// do cloudu, takže by 24 slov opustilo telefon mimo kontrolu uživatele.
Future<void> _showSeedPhrase(BuildContext context, WidgetRef ref) async {
final l10n = context.l10n; final l10n = context.l10n;
if (!await confirmSensitiveAction(context, ref,
reason: l10n.reauthReasonSeed)) {
return;
}
final storage = ref.read(storageProvider); final storage = ref.read(storageProvider);
final seed = await storage.loadSeedPhrase(); final seed = await storage.loadSeedPhrase();
@@ -670,47 +700,64 @@ class SettingsScreen extends ConsumerWidget {
if (!context.mounted) return; if (!context.mounted) return;
showDialog( await ref.read(screenSecurityProvider.notifier).enforceWhile(
context: context, () => showDialog<void>(
builder: (ctx) => AlertDialog( context: context,
title: Text(ctx.l10n.settingsSeedPhraseTitle), builder: (ctx) => AlertDialog(
content: Column( title: Text(ctx.l10n.settingsSeedPhraseTitle),
mainAxisSize: MainAxisSize.min, // 24 čipů se na malý displej nevejde — posouvat, ne přetéct.
children: [ content: SingleChildScrollView(
Text( child: Column(
ctx.l10n.settingsSeedPhraseWarning, mainAxisSize: MainAxisSize.min,
style: const TextStyle( children: [
fontSize: 13, Text(
color: SepotColors.warning, ctx.l10n.settingsSeedPhraseWarning,
), style: const TextStyle(
), fontSize: 13,
const SizedBox(height: 16), color: SepotColors.warning,
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(seed.length, (i) {
return Chip(
label: Text(
ctx.l10n.settingsSeedWordChip(i + 1, seed[i]),
style: SepotTheme.cryptoText,
), ),
backgroundColor: SepotColors.surfaceVariant, ),
side: const BorderSide(color: SepotColors.border), const SizedBox(height: 16),
); Wrap(
}), spacing: 8,
runSpacing: 8,
children: List.generate(seed.length, (i) {
return Chip(
label: Text(
ctx.l10n.settingsSeedWordChip(i + 1, seed[i]),
style: SepotTheme.cryptoText,
),
backgroundColor: SepotColors.surfaceVariant,
side: const BorderSide(color: SepotColors.border),
);
}),
),
],
),
),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(ctx.l10n.commonClose),
), ),
], ],
), ),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(ctx.l10n.commonClose),
),
],
), ),
); );
} }
/// Vypnutí ochrany obrazovky pouští obsah (i frázi) do snímků a náhledu
/// v přepínači aplikací — stejné oslabení jako delší auto-zámek.
Future<void> _setScreenSecurity(
BuildContext context, WidgetRef ref, bool enabled) async {
if (!enabled &&
!await confirmSensitiveAction(context, ref,
reason: context.l10n.reauthReasonScreenSecurity)) {
return;
}
await ref.read(screenSecurityProvider.notifier).set(enabled);
}
void _showAbout(BuildContext context) { void _showAbout(BuildContext context) {
showDialog( showDialog(
context: context, context: context,
@@ -767,6 +814,10 @@ class SettingsScreen extends ConsumerWidget {
), ),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
if (!await confirmSensitiveAction(ctx, ref,
reason: ctx.l10n.reauthReasonDeleteAccount)) {
return;
}
final messaging = ref.read(messagingServiceProvider); final messaging = ref.read(messagingServiceProvider);
// Serverová deregistrace PŘED lokálním wipe — podpis // Serverová deregistrace PŘED lokálním wipe — podpis
// požadavku potřebuje identity klíč, který wipe zahodí. // požadavku potřebuje identity klíč, který wipe zahodí.
+132
View File
@@ -2339,5 +2339,137 @@
"type": "String" "type": "String"
} }
} }
},
"reauthTitle": "Potvrď, že jsi to ty",
"@reauthTitle": {
"description": "Nadpis výzvy k PINu před citlivou akcí v odemčené appce"
},
"reauthPinRequired": "Tahle akce potřebuje PIN. Nejdřív si ho nastav.",
"@reauthPinRequired": {
"description": "Snackbar: citlivá akce bez nastaveného PINu"
},
"reauthSetPin": "Nastavit PIN",
"@reauthSetPin": {
"description": "Akce snackbaru: nastavit PIN"
},
"reauthReasonSeed": "Zobrazení 24 slov pro obnovu účtu",
"@reauthReasonSeed": {
"description": "Důvod ve výzvě k PINu/biometrii: zobrazení obnovovací fráze"
},
"reauthReasonBackupExport": "Export zálohy",
"@reauthReasonBackupExport": {
"description": "Důvod: export zálohy"
},
"reauthReasonBackupImport": "Obnova ze zálohy",
"@reauthReasonBackupImport": {
"description": "Důvod: obnova ze zálohy"
},
"reauthReasonDecoyPin": "Změna nátlakového PINu",
"@reauthReasonDecoyPin": {
"description": "Důvod: nastavení nebo zrušení nátlakového PINu"
},
"reauthReasonAutoLock": "Prodloužení automatického zámku",
"@reauthReasonAutoLock": {
"description": "Důvod: prodloužení nebo vypnutí automatického zámku"
},
"reauthReasonScreenSecurity": "Vypnutí ochrany obrazovky",
"@reauthReasonScreenSecurity": {
"description": "Důvod: vypnutí ochrany obrazovky"
},
"reauthReasonDeleteAccount": "Smazání účtu a všech dat",
"@reauthReasonDeleteAccount": {
"description": "Důvod: smazání účtu"
},
"reauthReasonBiometrics": "Zapnutí biometrie",
"@reauthReasonBiometrics": {
"description": "Důvod: zapnutí biometrického odemčení"
},
"contactRequestName": "{name} (neznámý)",
"@contactRequestName": {
"description": "Jméno neznámého odesílatele (žádost o zprávu) všude v UI; {name} si zvolil sám",
"placeholders": {
"name": {
"type": "String"
}
}
},
"pushMessageRequest": "Žádost o zprávu od neznámého odesílatele",
"@pushMessageRequest": {
"description": "Text notifikace pro zprávu od neznámého odesílatele — bez jména, které si zvolil"
},
"requestTitle": "Žádost o zprávu",
"@requestTitle": {
"description": "Nadpis panelu / štítek žádosti o zprávu"
},
"requestBody": "„{name}“ není ve tvých kontaktech. Jméno i fotku si zvolil sám, takže nemusí jít o toho, za koho se vydává. Dokud ho nepřijmeš, neuvidí, že jsi zprávu četl, a nemůže ti volat.",
"@requestBody": {
"description": "Panel žádosti o zprávu; {name} = jméno, které si odesílatel zvolil",
"placeholders": {
"name": {
"type": "String"
}
}
},
"requestLookalikeWarning": "Pozor: stejně se jmenuje tvůj kontakt {name}. Tohle je jiný klíč, tedy jiný telefon nebo jiný člověk. Než odpovíš, ověř si to jinou cestou (zavolej, potkejte se).",
"@requestLookalikeWarning": {
"description": "Varování v panelu žádosti: jméno se plete s existujícím kontaktem {name}",
"placeholders": {
"name": {
"type": "String"
}
}
},
"requestAccept": "Přijmout",
"@requestAccept": {
"description": "Tlačítko: přijmout žádost o zprávu"
},
"requestAcceptTitle": "Přijmout jako kontakt",
"@requestAcceptTitle": {
"description": "Nadpis dialogu přijetí žádosti"
},
"requestAcceptBody": "Pojmenuj kontakt sám. Jméno, které si odesílatel zvolil, se nepřebírá automaticky.",
"@requestAcceptBody": {
"description": "Text dialogu přijetí: kontakt pojmenuje uživatel sám"
},
"requestAcceptNameLabel": "Jméno kontaktu",
"@requestAcceptNameLabel": {
"description": "Popisek pole se jménem kontaktu"
},
"requestNameEmpty": "Zadej jméno.",
"@requestNameEmpty": {
"description": "Chyba: prázdné jméno"
},
"requestNameTaken": "Tak (nebo skoro tak) se už jmenuje jiný kontakt. Zvol jméno, které je nezamění.",
"@requestNameTaken": {
"description": "Chyba: jméno se plete s jiným kontaktem"
},
"noticeIdentityChanged": "Bezpečnostní kód kontaktu {name} se změnil. Pod jeho adresou se ozval jiný klíč: nový telefon, přeinstalace, nebo podvrh. Ověření je zrušené. Než pošleš něco citlivého, porovnejte kód znovu.",
"@noticeIdentityChanged": {
"description": "Upozornění v konverzaci: kontaktu {name} se změnil identity klíč",
"placeholders": {
"name": {
"type": "String"
}
}
},
"noticeLookalikeRequest": "Neznámý odesílatel si dal jméno „{claimed}“, které se plete s kontaktem {name}. Tento kontakt to není, zpráva přišla z jiného klíče. Najdeš ji v chatech jako žádost o zprávu.",
"@noticeLookalikeRequest": {
"description": "Upozornění v konverzaci existujícího kontaktu {name}: neznámý si zvolil podobné jméno {claimed}",
"placeholders": {
"claimed": {
"type": "String"
},
"name": {
"type": "String"
}
}
},
"noticeVerifyAction": "Ověřit kód",
"@noticeVerifyAction": {
"description": "Tlačítko u upozornění na změnu kódu"
},
"noticePreview": "Bezpečnostní upozornění",
"@noticePreview": {
"description": "Náhled v seznamu chatů, když je poslední položkou bezpečnostní upozornění"
} }
} }
+27 -1
View File
@@ -486,5 +486,31 @@
"profilePhotoPick": "Foto auswählen", "profilePhotoPick": "Foto auswählen",
"profilePhotoRemove": "Foto entfernen", "profilePhotoRemove": "Foto entfernen",
"profilePhotoHint": "Nur deine Chatpartner sehen es — Ende-zu-Ende-verschlüsselt wie Nachrichten. Standort und Fotodaten werden nicht gesendet.", "profilePhotoHint": "Nur deine Chatpartner sehen es — Ende-zu-Ende-verschlüsselt wie Nachrichten. Standort und Fotodaten werden nicht gesendet.",
"profilePhotoFailed": "Foto konnte nicht gesetzt werden: {error}" "profilePhotoFailed": "Foto konnte nicht gesetzt werden: {error}",
"reauthTitle": "Bestätige, dass du es bist",
"reauthPinRequired": "Diese Aktion erfordert eine PIN. Lege zuerst eine fest.",
"reauthSetPin": "PIN festlegen",
"reauthReasonSeed": "24 Wiederherstellungswörter anzeigen",
"reauthReasonBackupExport": "Backup exportieren",
"reauthReasonBackupImport": "Aus Backup wiederherstellen",
"reauthReasonDecoyPin": "Notfall-PIN ändern",
"reauthReasonAutoLock": "Automatische Sperre verlängern",
"reauthReasonScreenSecurity": "Bildschirmschutz ausschalten",
"reauthReasonDeleteAccount": "Konto und alle Daten löschen",
"reauthReasonBiometrics": "Biometrie einschalten",
"contactRequestName": "{name} (unbekannt)",
"pushMessageRequest": "Nachrichtenanfrage von unbekanntem Absender",
"requestTitle": "Nachrichtenanfrage",
"requestBody": "„{name}“ ist nicht in deinen Kontakten. Name und Foto hat die Person selbst gewählt, sie ist also vielleicht nicht, wer sie vorgibt zu sein. Bis du annimmst, sieht sie nicht, dass du die Nachricht gelesen hast, und kann dich nicht anrufen.",
"requestLookalikeWarning": "Achtung: Dein Kontakt {name} heißt genauso. Das ist ein anderer Schlüssel, also ein anderes Telefon oder eine andere Person. Prüfe das auf anderem Weg, bevor du antwortest (anrufen, persönlich treffen).",
"requestAccept": "Annehmen",
"requestAcceptTitle": "Als Kontakt annehmen",
"requestAcceptBody": "Benenne den Kontakt selbst. Der vom Absender gewählte Name wird nicht automatisch übernommen.",
"requestAcceptNameLabel": "Kontaktname",
"requestNameEmpty": "Gib einen Namen ein.",
"requestNameTaken": "Ein anderer Kontakt heißt schon so (oder fast so). Wähle einen Namen, der nicht verwechselt werden kann.",
"noticeIdentityChanged": "Der Sicherheitscode von {name} hat sich geändert. Unter dieser Adresse meldet sich ein anderer Schlüssel: neues Telefon, Neuinstallation oder Fälschung. Die Verifizierung wurde aufgehoben. Vergleicht den Code erneut, bevor du etwas Sensibles sendest.",
"noticeLookalikeRequest": "Ein unbekannter Absender nennt sich „{claimed}“, was nach deinem Kontakt {name} aussieht. Es ist nicht dieser Kontakt, die Nachricht kam von einem anderen Schlüssel. Du findest sie in den Chats als Nachrichtenanfrage.",
"noticeVerifyAction": "Code prüfen",
"noticePreview": "Sicherheitshinweis"
} }
+27 -1
View File
@@ -486,5 +486,31 @@
"profilePhotoPick": "Choose photo", "profilePhotoPick": "Choose photo",
"profilePhotoRemove": "Remove photo", "profilePhotoRemove": "Remove photo",
"profilePhotoHint": "Only people you chat with will see it — end-to-end encrypted like your messages. No location or photo metadata is sent.", "profilePhotoHint": "Only people you chat with will see it — end-to-end encrypted like your messages. No location or photo metadata is sent.",
"profilePhotoFailed": "Could not set photo: {error}" "profilePhotoFailed": "Could not set photo: {error}",
"reauthTitle": "Confirm it's you",
"reauthPinRequired": "This action needs a PIN. Set one first.",
"reauthSetPin": "Set PIN",
"reauthReasonSeed": "Show the 24 recovery words",
"reauthReasonBackupExport": "Export backup",
"reauthReasonBackupImport": "Restore from backup",
"reauthReasonDecoyPin": "Change the duress PIN",
"reauthReasonAutoLock": "Extend the auto-lock",
"reauthReasonScreenSecurity": "Turn off screen security",
"reauthReasonDeleteAccount": "Delete the account and all data",
"reauthReasonBiometrics": "Turn on biometrics",
"contactRequestName": "{name} (unknown)",
"pushMessageRequest": "Message request from an unknown sender",
"requestTitle": "Message request",
"requestBody": "“{name}” is not in your contacts. They chose the name and photo themselves, so they may not be who they claim to be. Until you accept, they can't see that you read the message and can't call you.",
"requestLookalikeWarning": "Careful: your contact {name} has the same name. This is a different key, so a different phone or a different person. Check another way before you reply (call, meet in person).",
"requestAccept": "Accept",
"requestAcceptTitle": "Accept as contact",
"requestAcceptBody": "Name the contact yourself. The name the sender chose is not taken over automatically.",
"requestAcceptNameLabel": "Contact name",
"requestNameEmpty": "Enter a name.",
"requestNameTaken": "Another contact already has this (or a very similar) name. Choose one that can't be confused.",
"noticeIdentityChanged": "The safety code of {name} has changed. A different key showed up at their address: a new phone, a reinstall, or an impostor. Verification has been removed. Compare the code again before you send anything sensitive.",
"noticeLookalikeRequest": "An unknown sender named themselves “{claimed}”, which looks like your contact {name}. It is not this contact, the message came from a different key. You'll find it in your chats as a message request.",
"noticeVerifyAction": "Verify code",
"noticePreview": "Security notice"
} }
+27 -1
View File
@@ -486,5 +486,31 @@
"profilePhotoPick": "Scegli foto", "profilePhotoPick": "Scegli foto",
"profilePhotoRemove": "Rimuovi foto", "profilePhotoRemove": "Rimuovi foto",
"profilePhotoHint": "La vedranno solo le persone con cui chatti — cifrata come i messaggi. Posizione e metadati non vengono inviati.", "profilePhotoHint": "La vedranno solo le persone con cui chatti — cifrata come i messaggi. Posizione e metadati non vengono inviati.",
"profilePhotoFailed": "Impossibile impostare la foto: {error}" "profilePhotoFailed": "Impossibile impostare la foto: {error}",
"reauthTitle": "Conferma che sei tu",
"reauthPinRequired": "Questa azione richiede un PIN. Impostalo prima.",
"reauthSetPin": "Imposta PIN",
"reauthReasonSeed": "Mostra le 24 parole di recupero",
"reauthReasonBackupExport": "Esporta backup",
"reauthReasonBackupImport": "Ripristina da backup",
"reauthReasonDecoyPin": "Modifica il PIN di emergenza",
"reauthReasonAutoLock": "Prolunga il blocco automatico",
"reauthReasonScreenSecurity": "Disattiva la protezione schermo",
"reauthReasonDeleteAccount": "Elimina l'account e tutti i dati",
"reauthReasonBiometrics": "Attiva la biometria",
"contactRequestName": "{name} (sconosciuto)",
"pushMessageRequest": "Richiesta di messaggio da un mittente sconosciuto",
"requestTitle": "Richiesta di messaggio",
"requestBody": "«{name}» non è tra i tuoi contatti. Nome e foto li ha scelti da sé, quindi potrebbe non essere chi dice di essere. Finché non accetti, non vede che hai letto il messaggio e non può chiamarti.",
"requestLookalikeWarning": "Attenzione: il tuo contatto {name} ha lo stesso nome. Questa è una chiave diversa, quindi un altro telefono o un'altra persona. Verifica in un altro modo prima di rispondere (chiama, incontratevi).",
"requestAccept": "Accetta",
"requestAcceptTitle": "Accetta come contatto",
"requestAcceptBody": "Dai tu un nome al contatto. Il nome scelto dal mittente non viene ripreso automaticamente.",
"requestAcceptNameLabel": "Nome del contatto",
"requestNameEmpty": "Inserisci un nome.",
"requestNameTaken": "Un altro contatto ha già questo nome (o quasi). Scegline uno che non si possa confondere.",
"noticeIdentityChanged": "Il codice di sicurezza di {name} è cambiato. Al suo indirizzo si è presentata una chiave diversa: nuovo telefono, reinstallazione o un impostore. La verifica è stata annullata. Confrontate di nuovo il codice prima di inviare qualcosa di sensibile.",
"noticeLookalikeRequest": "Un mittente sconosciuto si è chiamato «{claimed}», un nome che si confonde con il tuo contatto {name}. Non è questo contatto, il messaggio è arrivato da una chiave diversa. Lo trovi nelle chat come richiesta di messaggio.",
"noticeVerifyAction": "Verifica codice",
"noticePreview": "Avviso di sicurezza"
} }
+27 -1
View File
@@ -486,5 +486,31 @@
"profilePhotoPick": "Выбрать фото", "profilePhotoPick": "Выбрать фото",
"profilePhotoRemove": "Удалить фото", "profilePhotoRemove": "Удалить фото",
"profilePhotoHint": "Фото увидят только ваши собеседники — зашифровано, как и сообщения. Геоданные и метаданные не отправляются.", "profilePhotoHint": "Фото увидят только ваши собеседники — зашифровано, как и сообщения. Геоданные и метаданные не отправляются.",
"profilePhotoFailed": "Не удалось установить фото: {error}" "profilePhotoFailed": "Не удалось установить фото: {error}",
"reauthTitle": "Подтвердите, что это вы",
"reauthPinRequired": "Для этого действия нужен PIN-код. Сначала задайте его.",
"reauthSetPin": "Задать PIN",
"reauthReasonSeed": "Показать 24 слова для восстановления",
"reauthReasonBackupExport": "Экспорт резервной копии",
"reauthReasonBackupImport": "Восстановление из резервной копии",
"reauthReasonDecoyPin": "Изменение PIN-кода под принуждением",
"reauthReasonAutoLock": "Увеличение времени автоблокировки",
"reauthReasonScreenSecurity": "Отключение защиты экрана",
"reauthReasonDeleteAccount": "Удаление аккаунта и всех данных",
"reauthReasonBiometrics": "Включение биометрии",
"contactRequestName": "{name} (неизвестный)",
"pushMessageRequest": "Запрос на переписку от неизвестного отправителя",
"requestTitle": "Запрос на переписку",
"requestBody": "«{name}» нет в ваших контактах. Имя и фото отправитель выбрал сам, поэтому это может быть не тот, за кого он себя выдаёт. Пока вы не примете запрос, он не увидит, что вы прочитали сообщение, и не сможет вам позвонить.",
"requestLookalikeWarning": "Осторожно: так же зовут ваш контакт {name}. Это другой ключ, то есть другой телефон или другой человек. Прежде чем отвечать, проверьте это другим способом (позвоните, встретьтесь).",
"requestAccept": "Принять",
"requestAcceptTitle": "Принять как контакт",
"requestAcceptBody": "Назовите контакт сами. Имя, выбранное отправителем, автоматически не используется.",
"requestAcceptNameLabel": "Имя контакта",
"requestNameEmpty": "Введите имя.",
"requestNameTaken": "Так (или почти так) уже называется другой контакт. Выберите имя, которое нельзя перепутать.",
"noticeIdentityChanged": "Код безопасности контакта {name} изменился. По его адресу появился другой ключ: новый телефон, переустановка или подделка. Подтверждение отменено. Прежде чем отправлять что-то важное, сверьте код заново.",
"noticeLookalikeRequest": "Неизвестный отправитель назвался «{claimed}», что похоже на ваш контакт {name}. Это не он: сообщение пришло с другого ключа. Оно в чатах как запрос на переписку.",
"noticeVerifyAction": "Проверить код",
"noticePreview": "Предупреждение безопасности"
} }
@@ -3025,6 +3025,162 @@ abstract class AppLocalizations {
/// In cs, this message translates to: /// In cs, this message translates to:
/// **'Fotku se nepodařilo nastavit: {error}'** /// **'Fotku se nepodařilo nastavit: {error}'**
String profilePhotoFailed(String error); String profilePhotoFailed(String error);
/// Nadpis výzvy k PINu před citlivou akcí v odemčené appce
///
/// In cs, this message translates to:
/// **'Potvrď, že jsi to ty'**
String get reauthTitle;
/// Snackbar: citlivá akce bez nastaveného PINu
///
/// In cs, this message translates to:
/// **'Tahle akce potřebuje PIN. Nejdřív si ho nastav.'**
String get reauthPinRequired;
/// Akce snackbaru: nastavit PIN
///
/// In cs, this message translates to:
/// **'Nastavit PIN'**
String get reauthSetPin;
/// Důvod ve výzvě k PINu/biometrii: zobrazení obnovovací fráze
///
/// In cs, this message translates to:
/// **'Zobrazení 24 slov pro obnovu účtu'**
String get reauthReasonSeed;
/// Důvod: export zálohy
///
/// In cs, this message translates to:
/// **'Export zálohy'**
String get reauthReasonBackupExport;
/// Důvod: obnova ze zálohy
///
/// In cs, this message translates to:
/// **'Obnova ze zálohy'**
String get reauthReasonBackupImport;
/// Důvod: nastavení nebo zrušení nátlakového PINu
///
/// In cs, this message translates to:
/// **'Změna nátlakového PINu'**
String get reauthReasonDecoyPin;
/// Důvod: prodloužení nebo vypnutí automatického zámku
///
/// In cs, this message translates to:
/// **'Prodloužení automatického zámku'**
String get reauthReasonAutoLock;
/// Důvod: vypnutí ochrany obrazovky
///
/// In cs, this message translates to:
/// **'Vypnutí ochrany obrazovky'**
String get reauthReasonScreenSecurity;
/// Důvod: smazání účtu
///
/// In cs, this message translates to:
/// **'Smazání účtu a všech dat'**
String get reauthReasonDeleteAccount;
/// Důvod: zapnutí biometrického odemčení
///
/// In cs, this message translates to:
/// **'Zapnutí biometrie'**
String get reauthReasonBiometrics;
/// Jméno neznámého odesílatele (žádost o zprávu) všude v UI; {name} si zvolil sám
///
/// In cs, this message translates to:
/// **'{name} (neznámý)'**
String contactRequestName(String name);
/// Text notifikace pro zprávu od neznámého odesílatele — bez jména, které si zvolil
///
/// In cs, this message translates to:
/// **'Žádost o zprávu od neznámého odesílatele'**
String get pushMessageRequest;
/// Nadpis panelu / štítek žádosti o zprávu
///
/// In cs, this message translates to:
/// **'Žádost o zprávu'**
String get requestTitle;
/// Panel žádosti o zprávu; {name} = jméno, které si odesílatel zvolil
///
/// In cs, this message translates to:
/// **'„{name}“ není ve tvých kontaktech. Jméno i fotku si zvolil sám, takže nemusí jít o toho, za koho se vydává. Dokud ho nepřijmeš, neuvidí, že jsi zprávu četl, a nemůže ti volat.'**
String requestBody(String name);
/// Varování v panelu žádosti: jméno se plete s existujícím kontaktem {name}
///
/// In cs, this message translates to:
/// **'Pozor: stejně se jmenuje tvůj kontakt {name}. Tohle je jiný klíč, tedy jiný telefon nebo jiný člověk. Než odpovíš, ověř si to jinou cestou (zavolej, potkejte se).'**
String requestLookalikeWarning(String name);
/// Tlačítko: přijmout žádost o zprávu
///
/// In cs, this message translates to:
/// **'Přijmout'**
String get requestAccept;
/// Nadpis dialogu přijetí žádosti
///
/// In cs, this message translates to:
/// **'Přijmout jako kontakt'**
String get requestAcceptTitle;
/// Text dialogu přijetí: kontakt pojmenuje uživatel sám
///
/// In cs, this message translates to:
/// **'Pojmenuj kontakt sám. Jméno, které si odesílatel zvolil, se nepřebírá automaticky.'**
String get requestAcceptBody;
/// Popisek pole se jménem kontaktu
///
/// In cs, this message translates to:
/// **'Jméno kontaktu'**
String get requestAcceptNameLabel;
/// Chyba: prázdné jméno
///
/// In cs, this message translates to:
/// **'Zadej jméno.'**
String get requestNameEmpty;
/// Chyba: jméno se plete s jiným kontaktem
///
/// In cs, this message translates to:
/// **'Tak (nebo skoro tak) se už jmenuje jiný kontakt. Zvol jméno, které je nezamění.'**
String get requestNameTaken;
/// Upozornění v konverzaci: kontaktu {name} se změnil identity klíč
///
/// In cs, this message translates to:
/// **'Bezpečnostní kód kontaktu {name} se změnil. Pod jeho adresou se ozval jiný klíč: nový telefon, přeinstalace, nebo podvrh. Ověření je zrušené. Než pošleš něco citlivého, porovnejte kód znovu.'**
String noticeIdentityChanged(String name);
/// Upozornění v konverzaci existujícího kontaktu {name}: neznámý si zvolil podobné jméno {claimed}
///
/// In cs, this message translates to:
/// **'Neznámý odesílatel si dal jméno „{claimed}“, které se plete s kontaktem {name}. Tento kontakt to není, zpráva přišla z jiného klíče. Najdeš ji v chatech jako žádost o zprávu.'**
String noticeLookalikeRequest(String claimed, String name);
/// Tlačítko u upozornění na změnu kódu
///
/// In cs, this message translates to:
/// **'Ověřit kód'**
String get noticeVerifyAction;
/// Náhled v seznamu chatů, když je poslední položkou bezpečnostní upozornění
///
/// In cs, this message translates to:
/// **'Bezpečnostní upozornění'**
String get noticePreview;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
@@ -1726,4 +1726,95 @@ class AppLocalizationsCs extends AppLocalizations {
String profilePhotoFailed(String error) { String profilePhotoFailed(String error) {
return 'Fotku se nepodařilo nastavit: $error'; return 'Fotku se nepodařilo nastavit: $error';
} }
@override
String get reauthTitle => 'Potvrď, že jsi to ty';
@override
String get reauthPinRequired =>
'Tahle akce potřebuje PIN. Nejdřív si ho nastav.';
@override
String get reauthSetPin => 'Nastavit PIN';
@override
String get reauthReasonSeed => 'Zobrazení 24 slov pro obnovu účtu';
@override
String get reauthReasonBackupExport => 'Export zálohy';
@override
String get reauthReasonBackupImport => 'Obnova ze zálohy';
@override
String get reauthReasonDecoyPin => 'Změna nátlakového PINu';
@override
String get reauthReasonAutoLock => 'Prodloužení automatického zámku';
@override
String get reauthReasonScreenSecurity => 'Vypnutí ochrany obrazovky';
@override
String get reauthReasonDeleteAccount => 'Smazání účtu a všech dat';
@override
String get reauthReasonBiometrics => 'Zapnutí biometrie';
@override
String contactRequestName(String name) {
return '$name (neznámý)';
}
@override
String get pushMessageRequest => 'Žádost o zprávu od neznámého odesílatele';
@override
String get requestTitle => 'Žádost o zprávu';
@override
String requestBody(String name) {
return '„$name“ není ve tvých kontaktech. Jméno i fotku si zvolil sám, takže nemusí jít o toho, za koho se vydává. Dokud ho nepřijmeš, neuvidí, že jsi zprávu četl, a nemůže ti volat.';
}
@override
String requestLookalikeWarning(String name) {
return 'Pozor: stejně se jmenuje tvůj kontakt $name. Tohle je jiný klíč, tedy jiný telefon nebo jiný člověk. Než odpovíš, ověř si to jinou cestou (zavolej, potkejte se).';
}
@override
String get requestAccept => 'Přijmout';
@override
String get requestAcceptTitle => 'Přijmout jako kontakt';
@override
String get requestAcceptBody =>
'Pojmenuj kontakt sám. Jméno, které si odesílatel zvolil, se nepřebírá automaticky.';
@override
String get requestAcceptNameLabel => 'Jméno kontaktu';
@override
String get requestNameEmpty => 'Zadej jméno.';
@override
String get requestNameTaken =>
'Tak (nebo skoro tak) se už jmenuje jiný kontakt. Zvol jméno, které je nezamění.';
@override
String noticeIdentityChanged(String name) {
return 'Bezpečnostní kód kontaktu $name se změnil. Pod jeho adresou se ozval jiný klíč: nový telefon, přeinstalace, nebo podvrh. Ověření je zrušené. Než pošleš něco citlivého, porovnejte kód znovu.';
}
@override
String noticeLookalikeRequest(String claimed, String name) {
return 'Neznámý odesílatel si dal jméno „$claimed“, které se plete s kontaktem $name. Tento kontakt to není, zpráva přišla z jiného klíče. Najdeš ji v chatech jako žádost o zprávu.';
}
@override
String get noticeVerifyAction => 'Ověřit kód';
@override
String get noticePreview => 'Bezpečnostní upozornění';
} }
@@ -1748,4 +1748,96 @@ class AppLocalizationsDe extends AppLocalizations {
String profilePhotoFailed(String error) { String profilePhotoFailed(String error) {
return 'Foto konnte nicht gesetzt werden: $error'; return 'Foto konnte nicht gesetzt werden: $error';
} }
@override
String get reauthTitle => 'Bestätige, dass du es bist';
@override
String get reauthPinRequired =>
'Diese Aktion erfordert eine PIN. Lege zuerst eine fest.';
@override
String get reauthSetPin => 'PIN festlegen';
@override
String get reauthReasonSeed => '24 Wiederherstellungswörter anzeigen';
@override
String get reauthReasonBackupExport => 'Backup exportieren';
@override
String get reauthReasonBackupImport => 'Aus Backup wiederherstellen';
@override
String get reauthReasonDecoyPin => 'Notfall-PIN ändern';
@override
String get reauthReasonAutoLock => 'Automatische Sperre verlängern';
@override
String get reauthReasonScreenSecurity => 'Bildschirmschutz ausschalten';
@override
String get reauthReasonDeleteAccount => 'Konto und alle Daten löschen';
@override
String get reauthReasonBiometrics => 'Biometrie einschalten';
@override
String contactRequestName(String name) {
return '$name (unbekannt)';
}
@override
String get pushMessageRequest =>
'Nachrichtenanfrage von unbekanntem Absender';
@override
String get requestTitle => 'Nachrichtenanfrage';
@override
String requestBody(String name) {
return '„$name“ ist nicht in deinen Kontakten. Name und Foto hat die Person selbst gewählt, sie ist also vielleicht nicht, wer sie vorgibt zu sein. Bis du annimmst, sieht sie nicht, dass du die Nachricht gelesen hast, und kann dich nicht anrufen.';
}
@override
String requestLookalikeWarning(String name) {
return 'Achtung: Dein Kontakt $name heißt genauso. Das ist ein anderer Schlüssel, also ein anderes Telefon oder eine andere Person. Prüfe das auf anderem Weg, bevor du antwortest (anrufen, persönlich treffen).';
}
@override
String get requestAccept => 'Annehmen';
@override
String get requestAcceptTitle => 'Als Kontakt annehmen';
@override
String get requestAcceptBody =>
'Benenne den Kontakt selbst. Der vom Absender gewählte Name wird nicht automatisch übernommen.';
@override
String get requestAcceptNameLabel => 'Kontaktname';
@override
String get requestNameEmpty => 'Gib einen Namen ein.';
@override
String get requestNameTaken =>
'Ein anderer Kontakt heißt schon so (oder fast so). Wähle einen Namen, der nicht verwechselt werden kann.';
@override
String noticeIdentityChanged(String name) {
return 'Der Sicherheitscode von $name hat sich geändert. Unter dieser Adresse meldet sich ein anderer Schlüssel: neues Telefon, Neuinstallation oder Fälschung. Die Verifizierung wurde aufgehoben. Vergleicht den Code erneut, bevor du etwas Sensibles sendest.';
}
@override
String noticeLookalikeRequest(String claimed, String name) {
return 'Ein unbekannter Absender nennt sich „$claimed“, was nach deinem Kontakt $name aussieht. Es ist nicht dieser Kontakt, die Nachricht kam von einem anderen Schlüssel. Du findest sie in den Chats als Nachrichtenanfrage.';
}
@override
String get noticeVerifyAction => 'Code prüfen';
@override
String get noticePreview => 'Sicherheitshinweis';
} }
@@ -1720,4 +1720,94 @@ class AppLocalizationsEn extends AppLocalizations {
String profilePhotoFailed(String error) { String profilePhotoFailed(String error) {
return 'Could not set photo: $error'; return 'Could not set photo: $error';
} }
@override
String get reauthTitle => 'Confirm it\'s you';
@override
String get reauthPinRequired => 'This action needs a PIN. Set one first.';
@override
String get reauthSetPin => 'Set PIN';
@override
String get reauthReasonSeed => 'Show the 24 recovery words';
@override
String get reauthReasonBackupExport => 'Export backup';
@override
String get reauthReasonBackupImport => 'Restore from backup';
@override
String get reauthReasonDecoyPin => 'Change the duress PIN';
@override
String get reauthReasonAutoLock => 'Extend the auto-lock';
@override
String get reauthReasonScreenSecurity => 'Turn off screen security';
@override
String get reauthReasonDeleteAccount => 'Delete the account and all data';
@override
String get reauthReasonBiometrics => 'Turn on biometrics';
@override
String contactRequestName(String name) {
return '$name (unknown)';
}
@override
String get pushMessageRequest => 'Message request from an unknown sender';
@override
String get requestTitle => 'Message request';
@override
String requestBody(String name) {
return '“$name” is not in your contacts. They chose the name and photo themselves, so they may not be who they claim to be. Until you accept, they can\'t see that you read the message and can\'t call you.';
}
@override
String requestLookalikeWarning(String name) {
return 'Careful: your contact $name has the same name. This is a different key, so a different phone or a different person. Check another way before you reply (call, meet in person).';
}
@override
String get requestAccept => 'Accept';
@override
String get requestAcceptTitle => 'Accept as contact';
@override
String get requestAcceptBody =>
'Name the contact yourself. The name the sender chose is not taken over automatically.';
@override
String get requestAcceptNameLabel => 'Contact name';
@override
String get requestNameEmpty => 'Enter a name.';
@override
String get requestNameTaken =>
'Another contact already has this (or a very similar) name. Choose one that can\'t be confused.';
@override
String noticeIdentityChanged(String name) {
return 'The safety code of $name has changed. A different key showed up at their address: a new phone, a reinstall, or an impostor. Verification has been removed. Compare the code again before you send anything sensitive.';
}
@override
String noticeLookalikeRequest(String claimed, String name) {
return 'An unknown sender named themselves “$claimed”, which looks like your contact $name. It is not this contact, the message came from a different key. You\'ll find it in your chats as a message request.';
}
@override
String get noticeVerifyAction => 'Verify code';
@override
String get noticePreview => 'Security notice';
} }
@@ -1735,4 +1735,96 @@ class AppLocalizationsIt extends AppLocalizations {
String profilePhotoFailed(String error) { String profilePhotoFailed(String error) {
return 'Impossibile impostare la foto: $error'; return 'Impossibile impostare la foto: $error';
} }
@override
String get reauthTitle => 'Conferma che sei tu';
@override
String get reauthPinRequired =>
'Questa azione richiede un PIN. Impostalo prima.';
@override
String get reauthSetPin => 'Imposta PIN';
@override
String get reauthReasonSeed => 'Mostra le 24 parole di recupero';
@override
String get reauthReasonBackupExport => 'Esporta backup';
@override
String get reauthReasonBackupImport => 'Ripristina da backup';
@override
String get reauthReasonDecoyPin => 'Modifica il PIN di emergenza';
@override
String get reauthReasonAutoLock => 'Prolunga il blocco automatico';
@override
String get reauthReasonScreenSecurity => 'Disattiva la protezione schermo';
@override
String get reauthReasonDeleteAccount => 'Elimina l\'account e tutti i dati';
@override
String get reauthReasonBiometrics => 'Attiva la biometria';
@override
String contactRequestName(String name) {
return '$name (sconosciuto)';
}
@override
String get pushMessageRequest =>
'Richiesta di messaggio da un mittente sconosciuto';
@override
String get requestTitle => 'Richiesta di messaggio';
@override
String requestBody(String name) {
return '«$name» non è tra i tuoi contatti. Nome e foto li ha scelti da sé, quindi potrebbe non essere chi dice di essere. Finché non accetti, non vede che hai letto il messaggio e non può chiamarti.';
}
@override
String requestLookalikeWarning(String name) {
return 'Attenzione: il tuo contatto $name ha lo stesso nome. Questa è una chiave diversa, quindi un altro telefono o un\'altra persona. Verifica in un altro modo prima di rispondere (chiama, incontratevi).';
}
@override
String get requestAccept => 'Accetta';
@override
String get requestAcceptTitle => 'Accetta come contatto';
@override
String get requestAcceptBody =>
'Dai tu un nome al contatto. Il nome scelto dal mittente non viene ripreso automaticamente.';
@override
String get requestAcceptNameLabel => 'Nome del contatto';
@override
String get requestNameEmpty => 'Inserisci un nome.';
@override
String get requestNameTaken =>
'Un altro contatto ha già questo nome (o quasi). Scegline uno che non si possa confondere.';
@override
String noticeIdentityChanged(String name) {
return 'Il codice di sicurezza di $name è cambiato. Al suo indirizzo si è presentata una chiave diversa: nuovo telefono, reinstallazione o un impostore. La verifica è stata annullata. Confrontate di nuovo il codice prima di inviare qualcosa di sensibile.';
}
@override
String noticeLookalikeRequest(String claimed, String name) {
return 'Un mittente sconosciuto si è chiamato «$claimed», un nome che si confonde con il tuo contatto $name. Non è questo contatto, il messaggio è arrivato da una chiave diversa. Lo trovi nelle chat come richiesta di messaggio.';
}
@override
String get noticeVerifyAction => 'Verifica codice';
@override
String get noticePreview => 'Avviso di sicurezza';
} }
@@ -1761,4 +1761,96 @@ class AppLocalizationsRu extends AppLocalizations {
String profilePhotoFailed(String error) { String profilePhotoFailed(String error) {
return 'Не удалось установить фото: $error'; return 'Не удалось установить фото: $error';
} }
@override
String get reauthTitle => 'Подтвердите, что это вы';
@override
String get reauthPinRequired =>
'Для этого действия нужен PIN-код. Сначала задайте его.';
@override
String get reauthSetPin => 'Задать PIN';
@override
String get reauthReasonSeed => 'Показать 24 слова для восстановления';
@override
String get reauthReasonBackupExport => 'Экспорт резервной копии';
@override
String get reauthReasonBackupImport => 'Восстановление из резервной копии';
@override
String get reauthReasonDecoyPin => 'Изменение PIN-кода под принуждением';
@override
String get reauthReasonAutoLock => 'Увеличение времени автоблокировки';
@override
String get reauthReasonScreenSecurity => 'Отключение защиты экрана';
@override
String get reauthReasonDeleteAccount => 'Удаление аккаунта и всех данных';
@override
String get reauthReasonBiometrics => 'Включение биометрии';
@override
String contactRequestName(String name) {
return '$name (неизвестный)';
}
@override
String get pushMessageRequest =>
'Запрос на переписку от неизвестного отправителя';
@override
String get requestTitle => 'Запрос на переписку';
@override
String requestBody(String name) {
return '«$name» нет в ваших контактах. Имя и фото отправитель выбрал сам, поэтому это может быть не тот, за кого он себя выдаёт. Пока вы не примете запрос, он не увидит, что вы прочитали сообщение, и не сможет вам позвонить.';
}
@override
String requestLookalikeWarning(String name) {
return 'Осторожно: так же зовут ваш контакт $name. Это другой ключ, то есть другой телефон или другой человек. Прежде чем отвечать, проверьте это другим способом (позвоните, встретьтесь).';
}
@override
String get requestAccept => 'Принять';
@override
String get requestAcceptTitle => 'Принять как контакт';
@override
String get requestAcceptBody =>
'Назовите контакт сами. Имя, выбранное отправителем, автоматически не используется.';
@override
String get requestAcceptNameLabel => 'Имя контакта';
@override
String get requestNameEmpty => 'Введите имя.';
@override
String get requestNameTaken =>
'Так (или почти так) уже называется другой контакт. Выберите имя, которое нельзя перепутать.';
@override
String noticeIdentityChanged(String name) {
return 'Код безопасности контакта $name изменился. По его адресу появился другой ключ: новый телефон, переустановка или подделка. Подтверждение отменено. Прежде чем отправлять что-то важное, сверьте код заново.';
}
@override
String noticeLookalikeRequest(String claimed, String name) {
return 'Неизвестный отправитель назвался «$claimed», что похоже на ваш контакт $name. Это не он: сообщение пришло с другого ключа. Оно в чатах как запрос на переписку.';
}
@override
String get noticeVerifyAction => 'Проверить код';
@override
String get noticePreview => 'Предупреждение безопасности';
} }

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