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.
This commit is contained in:
@@ -197,15 +197,28 @@ class SecureStorageService {
|
||||
await _secureStorage.delete(key: _wrappedDekKey);
|
||||
}
|
||||
|
||||
/// Ověří PIN, aniž by cokoli změnil (unwrap a zahození DEK).
|
||||
/// Stojí jednu Argon2id derivaci (~2 s) — to je zároveň rate limit.
|
||||
/// Ověří skutečný PIN, aniž by cokoli změnil (unwrap a zahození DEK).
|
||||
/// Tímhle se potvrzují citlivé akce v odemčené appce (fráze, záloha,
|
||||
/// smazání účtu, změna PINu).
|
||||
///
|
||||
/// Sdílí brzdu se zámkovou obrazovkou: kdo dostane do ruky odemčený
|
||||
/// telefon, nesmí tu hádat PIN rychleji než na zámku. Zamčeno =
|
||||
/// [PinLockedException].
|
||||
///
|
||||
/// V nátlakové relaci vždy false: i kdyby někdo znal skutečný PIN, k frázi
|
||||
/// skutečné identity se odtud dostat nesmí (otevře si appku normálně).
|
||||
Future<bool> verifyPin(String pin) async {
|
||||
if (_decoyMode) return false;
|
||||
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (wrapped == null) return false;
|
||||
final locked = await pinLockRemaining();
|
||||
if (locked != null) throw PinLockedException(locked);
|
||||
try {
|
||||
await DekWrap.unwrap(wrapped: wrapped, pin: pin);
|
||||
await _resetPinFailures();
|
||||
return true;
|
||||
} on WrongPinException {
|
||||
await _notePinFailure();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,14 @@ class _ChangePinScreenState extends ConsumerState<ChangePinScreen> {
|
||||
switch (_krok) {
|
||||
case _Krok.stary:
|
||||
setState(() => _pracuje = true);
|
||||
final ok = await storage.verifyPin(_pin);
|
||||
final bool ok;
|
||||
try {
|
||||
ok = await storage.verifyPin(_pin);
|
||||
} on PinLockedException catch (e) {
|
||||
if (!mounted) return;
|
||||
_reset(context.l10n.pinLockedFor(e.remaining.inSeconds));
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (!ok) {
|
||||
_reset(context.l10n.pinWrong);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import 'package:local_auth/local_auth.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/features/auth/reauth.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
class BiometricsTile extends ConsumerStatefulWidget {
|
||||
@@ -48,6 +49,14 @@ class _BiometricsTileState extends ConsumerState<BiometricsTile> {
|
||||
_snack(l10n.biometricsStorageLocked);
|
||||
return;
|
||||
}
|
||||
// Biometrie dostane kopii DEK — zapnout ji smí jen ten, kdo zná PIN.
|
||||
// Otisk sám nestačí: v telefonu může být zapsaný i cizí prst.
|
||||
if (!mounted ||
|
||||
!await confirmSensitiveAction(context, ref,
|
||||
reason: l10n.reauthReasonBiometrics,
|
||||
allowBiometrics: false)) {
|
||||
return;
|
||||
}
|
||||
final auth = LocalAuthentication();
|
||||
if (!await auth.isDeviceSupported()) {
|
||||
_snack(l10n.biometricsNotAvailable);
|
||||
|
||||
@@ -24,6 +24,7 @@ import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/storage_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/language_settings_tile.dart';
|
||||
import 'package:sepot/features/settings/push_settings_tile.dart';
|
||||
@@ -151,8 +152,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
subtitle: l10n.settingsScreenSecuritySubtitle,
|
||||
trailing: Switch(
|
||||
value: ref.watch(screenSecurityProvider).valueOrNull ?? false,
|
||||
onChanged: (v) =>
|
||||
ref.read(screenSecurityProvider.notifier).set(v),
|
||||
onChanged: (v) => _setScreenSecurity(context, ref, v),
|
||||
activeTrackColor: SepotColors.ember,
|
||||
),
|
||||
onTap: () {},
|
||||
@@ -319,9 +319,15 @@ class SettingsScreen extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
if (vybrano != null && vybrano != ted) {
|
||||
await ref.read(autoLockProvider.notifier).set(vybrano);
|
||||
if (vybrano == null || vybrano == ted || !context.mounted) return;
|
||||
// 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ěží
|
||||
@@ -518,6 +524,10 @@ class SettingsScreen extends ConsumerWidget {
|
||||
final l10n = context.l10n;
|
||||
final identity = ref.read(identityProvider).valueOrNull;
|
||||
if (identity == null) return;
|
||||
if (!await confirmSensitiveAction(context, ref,
|
||||
reason: l10n.reauthReasonBackupExport)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final bytes = await ref.read(backupServiceProvider).export(identity);
|
||||
final dir = await getTemporaryDirectory();
|
||||
@@ -547,6 +557,10 @@ class SettingsScreen extends ConsumerWidget {
|
||||
final l10n = context.l10n;
|
||||
final identity = ref.read(identityProvider).valueOrNull;
|
||||
if (identity == null) return;
|
||||
if (!await confirmSensitiveAction(context, ref,
|
||||
reason: l10n.reauthReasonBackupImport)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final res = await FilePicker.pickFiles(withData: true);
|
||||
final data = res?.files.firstOrNull?.bytes;
|
||||
@@ -570,6 +584,11 @@ class SettingsScreen extends ConsumerWidget {
|
||||
|
||||
Future<void> _setDecoyPin(BuildContext context, WidgetRef ref) async {
|
||||
final l10n = context.l10n;
|
||||
if (!await confirmSensitiveAction(context, ref,
|
||||
reason: l10n.reauthReasonDecoyPin)) {
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
final pinCtrl = TextEditingController();
|
||||
final confirmCtrl = TextEditingController();
|
||||
const numberField = TextInputType.number;
|
||||
@@ -645,6 +664,10 @@ class SettingsScreen extends ConsumerWidget {
|
||||
|
||||
Future<void> _disableDecoyPin(BuildContext context, WidgetRef ref) async {
|
||||
final l10n = context.l10n;
|
||||
if (!await confirmSensitiveAction(context, ref,
|
||||
reason: l10n.reauthReasonDecoyPin)) {
|
||||
return;
|
||||
}
|
||||
await ref.read(storageProvider).disableDecoyPin();
|
||||
ref.invalidate(hasDecoyPinProvider);
|
||||
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;
|
||||
if (!await confirmSensitiveAction(context, ref,
|
||||
reason: l10n.reauthReasonSeed)) {
|
||||
return;
|
||||
}
|
||||
final storage = ref.read(storageProvider);
|
||||
final seed = await storage.loadSeedPhrase();
|
||||
|
||||
@@ -670,11 +700,14 @@ class SettingsScreen extends ConsumerWidget {
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
showDialog(
|
||||
await ref.read(screenSecurityProvider.notifier).enforceWhile(
|
||||
() => showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.settingsSeedPhraseTitle),
|
||||
content: Column(
|
||||
// 24 čipů se na malý displej nevejde — posouvat, ne přetéct.
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
@@ -701,6 +734,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
@@ -708,9 +742,22 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -767,6 +814,10 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (!await confirmSensitiveAction(ctx, ref,
|
||||
reason: ctx.l10n.reauthReasonDeleteAccount)) {
|
||||
return;
|
||||
}
|
||||
final messaging = ref.read(messagingServiceProvider);
|
||||
// Serverová deregistrace PŘED lokálním wipe — podpis
|
||||
// požadavku potřebuje identity klíč, který wipe zahodí.
|
||||
|
||||
@@ -2339,5 +2339,137 @@
|
||||
"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
@@ -486,5 +486,31 @@
|
||||
"profilePhotoPick": "Foto auswählen",
|
||||
"profilePhotoRemove": "Foto entfernen",
|
||||
"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
@@ -486,5 +486,31 @@
|
||||
"profilePhotoPick": "Choose 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.",
|
||||
"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
@@ -486,5 +486,31 @@
|
||||
"profilePhotoPick": "Scegli foto",
|
||||
"profilePhotoRemove": "Rimuovi foto",
|
||||
"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
@@ -486,5 +486,31 @@
|
||||
"profilePhotoPick": "Выбрать фото",
|
||||
"profilePhotoRemove": "Удалить фото",
|
||||
"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:
|
||||
/// **'Fotku se nepodařilo nastavit: {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
|
||||
|
||||
@@ -1726,4 +1726,95 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
String profilePhotoFailed(String 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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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 => 'Предупреждение безопасности';
|
||||
}
|
||||
|
||||
@@ -83,6 +83,20 @@ class ScreenSecurityNotifier extends AsyncNotifier<bool> {
|
||||
state = AsyncData(value);
|
||||
}
|
||||
|
||||
/// Po dobu [body] drží ochranu zapnutou bez ohledu na nastavení a pak
|
||||
/// vrátí uložený stav. Pro 24 slov: vypnutý přepínač nesmí pustit frázi
|
||||
/// do screenshotu ani do náhledu v přepínači aplikací. Ladicí build
|
||||
/// výjimku má stejně jako MainActivity — QA potřebuje snímky z emulátoru.
|
||||
Future<T> enforceWhile<T>(Future<T> Function() body) async {
|
||||
if (kDebugMode) return body();
|
||||
await _apply(true);
|
||||
try {
|
||||
return await body();
|
||||
} finally {
|
||||
await _apply(state.valueOrNull ?? vychozi);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _apply(bool on) async {
|
||||
try {
|
||||
if (on) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Potřebuje nativní sqlcipher a Argon2id — na builderu se vynechává (--exclude-tags ffi).
|
||||
@Tags(['ffi'])
|
||||
// Argon2id (64 MiB) ~2 s na pokus, test jich dělá přes deset.
|
||||
@Timeout(Duration(minutes: 3))
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
||||
import 'package:sepot/core/storage/secure_storage.dart';
|
||||
|
||||
/// Opětovné ověření PINu v odemčené appce (fráze, záloha, smazání účtu):
|
||||
/// stejná brzda jako zámek, nátlakový PIN ani nátlaková relace nic neodemknou.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late Directory tmp;
|
||||
late SecureStorageService storage;
|
||||
|
||||
setUpAll(() async => SepotCrypto.init());
|
||||
|
||||
setUp(() async {
|
||||
FlutterSecureStorage.setMockInitialValues({});
|
||||
tmp = Directory.systemTemp.createTempSync('sepot_verify_pin');
|
||||
storage = SecureStorageService(dbPath: '${tmp.path}/app.db');
|
||||
await storage.unlockWithoutPin();
|
||||
await storage.enablePin('111111');
|
||||
await storage.enableDecoyPin('222222');
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
storage.lock();
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('skutečný PIN projde, nátlakový ne', () async {
|
||||
expect(await storage.verifyPin('111111'), isTrue);
|
||||
expect(await storage.verifyPin('222222'), isFalse);
|
||||
});
|
||||
|
||||
test('v nátlakové relaci neprojde ani skutečný PIN', () async {
|
||||
storage.lock();
|
||||
await storage.unlockWithPin('222222');
|
||||
expect(storage.isDecoyMode, isTrue);
|
||||
expect(await storage.verifyPin('111111'), isFalse);
|
||||
expect(await storage.loadSeedPhrase(), isNull);
|
||||
});
|
||||
|
||||
test('po pěti chybách zámek, hádání se nedá obejít', () async {
|
||||
for (var i = 0; i < 5; i++) {
|
||||
expect(await storage.verifyPin('99999$i'), isFalse);
|
||||
}
|
||||
expect(await storage.pinLockRemaining(), isNotNull);
|
||||
expect(() => storage.verifyPin('111111'),
|
||||
throwsA(isA<PinLockedException>()),
|
||||
reason: 'ani správný PIN během zámku');
|
||||
});
|
||||
|
||||
test('správný PIN vynuluje počítadlo chyb', () async {
|
||||
for (var i = 0; i < 4; i++) {
|
||||
await storage.verifyPin('99999$i');
|
||||
}
|
||||
expect(await storage.verifyPin('111111'), isTrue);
|
||||
expect(await storage.verifyPin('999990'), isFalse);
|
||||
expect(await storage.pinLockRemaining(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/core/storage/secure_storage.dart';
|
||||
import 'package:sepot/features/auth/reauth.dart';
|
||||
import 'package:sepot/features/settings/settings_screen.dart';
|
||||
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:sepot/widgets/pin_keypad.dart';
|
||||
|
||||
/// 24 slov z odemčené appky jen po čerstvém PINu nebo biometrii; v nátlakové
|
||||
/// relaci vůbec. Úložiště je podvržené — Argon2id a keystore testuje
|
||||
/// `test/storage/verify_pin_test.dart`.
|
||||
class _FakeStorage extends SecureStorageService {
|
||||
_FakeStorage({this.pin = true, this.bio = false, this.decoy = false});
|
||||
|
||||
final bool pin;
|
||||
final bool bio;
|
||||
final bool decoy;
|
||||
int seedReads = 0;
|
||||
|
||||
static const correctPin = '123456';
|
||||
static final words = List.generate(24, (i) => 'slovo$i');
|
||||
|
||||
@override
|
||||
bool get isDecoyMode => decoy;
|
||||
|
||||
@override
|
||||
Future<bool> hasPin() async => pin;
|
||||
|
||||
@override
|
||||
Future<bool> hasDecoyPin() async => false;
|
||||
|
||||
@override
|
||||
Future<bool> isBiometricsEnabled() async => bio;
|
||||
|
||||
@override
|
||||
Future<Duration?> pinLockRemaining() async => null;
|
||||
|
||||
@override
|
||||
Future<bool> verifyPin(String pin) async => pin == correctPin;
|
||||
|
||||
@override
|
||||
Future<List<String>?> loadSeedPhrase() async {
|
||||
seedReads++;
|
||||
return decoy ? null : words;
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeIdentityNotifier extends IdentityNotifier {
|
||||
@override
|
||||
Future<LocalIdentity?> build() async => LocalIdentity(
|
||||
displayName: 'Alice',
|
||||
identityPrivateKey: Uint8List(32),
|
||||
identityPublicKey: Uint8List(33),
|
||||
createdAt: DateTime.utc(2026, 1, 1),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
|
||||
|
||||
Future<_FakeStorage> pump(
|
||||
WidgetTester tester, {
|
||||
bool pin = true,
|
||||
bool bio = false,
|
||||
bool bioResult = false,
|
||||
bool decoy = false,
|
||||
}) async {
|
||||
final storage = _FakeStorage(pin: pin, bio: bio, decoy: decoy);
|
||||
tester.view.physicalSize = const Size(1080, 2400);
|
||||
tester.view.devicePixelRatio = 2.5;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [
|
||||
storageProvider.overrideWithValue(storage),
|
||||
identityProvider.overrideWith(_FakeIdentityNotifier.new),
|
||||
biometricPromptProvider.overrideWithValue((_) async => bioResult),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('cs'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const SettingsScreen(),
|
||||
),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
return storage;
|
||||
}
|
||||
|
||||
Future<void> openSeed(WidgetTester tester) async {
|
||||
final tile = find.text('Zálohovací fráze');
|
||||
await tester.scrollUntilVisible(tile, 200,
|
||||
scrollable: find.byType(Scrollable).first);
|
||||
await tester.tap(tile);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> typePin(WidgetTester tester, String pin) async {
|
||||
for (final d in pin.split('')) {
|
||||
await tester.tap(find.descendant(
|
||||
of: find.byType(PinKeypad), matching: find.text(d)));
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
final firstWord = find.textContaining('slovo0');
|
||||
|
||||
testWidgets('fráze se bez PINu neukáže ani nenačte', (tester) async {
|
||||
final storage = await pump(tester);
|
||||
await openSeed(tester);
|
||||
expect(find.text('Potvrď, že jsi to ty'), findsOneWidget);
|
||||
expect(firstWord, findsNothing);
|
||||
expect(storage.seedReads, 0, reason: 'z keystore až po ověření');
|
||||
|
||||
await tester.tap(find.text('Zrušit'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(firstWord, findsNothing);
|
||||
expect(storage.seedReads, 0);
|
||||
});
|
||||
|
||||
testWidgets('špatný PIN frázi neukáže', (tester) async {
|
||||
final storage = await pump(tester);
|
||||
await openSeed(tester);
|
||||
await typePin(tester, '000000');
|
||||
expect(find.text('Špatný PIN.'), findsOneWidget);
|
||||
expect(firstWord, findsNothing);
|
||||
expect(storage.seedReads, 0);
|
||||
});
|
||||
|
||||
testWidgets('správný PIN frázi ukáže', (tester) async {
|
||||
final storage = await pump(tester);
|
||||
await openSeed(tester);
|
||||
await typePin(tester, _FakeStorage.correctPin);
|
||||
expect(firstWord, findsOneWidget);
|
||||
expect(storage.seedReads, 1);
|
||||
});
|
||||
|
||||
testWidgets('každé zobrazení chce PIN znovu', (tester) async {
|
||||
await pump(tester);
|
||||
await openSeed(tester);
|
||||
await typePin(tester, _FakeStorage.correctPin);
|
||||
await tester.tap(find.text('Zavřít'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await openSeed(tester);
|
||||
expect(find.text('Potvrď, že jsi to ty'), findsOneWidget);
|
||||
expect(firstWord, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('úspěšná biometrie stačí', (tester) async {
|
||||
await pump(tester, bio: true, bioResult: true);
|
||||
await openSeed(tester);
|
||||
expect(firstWord, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('zrušená biometrie spadne na PIN', (tester) async {
|
||||
await pump(tester, bio: true, bioResult: false);
|
||||
await openSeed(tester);
|
||||
expect(find.text('Potvrď, že jsi to ty'), findsOneWidget);
|
||||
expect(firstWord, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('bez nastaveného PINu fráze nejde', (tester) async {
|
||||
final storage = await pump(tester, pin: false);
|
||||
await openSeed(tester);
|
||||
expect(find.textContaining('Nejdřív si ho nastav'), findsOneWidget);
|
||||
expect(firstWord, findsNothing);
|
||||
expect(storage.seedReads, 0);
|
||||
});
|
||||
|
||||
testWidgets('nátlaková relace frázi vůbec nenabízí', (tester) async {
|
||||
await pump(tester, decoy: true);
|
||||
expect(find.text('Zálohovací fráze', skipOffstage: false), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('smazání účtu bez PINu neproběhne', (tester) async {
|
||||
await pump(tester);
|
||||
final button = find.text('Smazat všechna data');
|
||||
await tester.scrollUntilVisible(button, 200,
|
||||
scrollable: find.byType(Scrollable).first);
|
||||
await tester.tap(button);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byType(ElevatedButton).last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Potvrď, že jsi to ty'), findsOneWidget);
|
||||
await typePin(tester, '999999');
|
||||
expect(find.text('Špatný PIN.'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user