diff --git a/app/lib/core/app_info.dart b/app/lib/core/app_info.dart new file mode 100644 index 0000000..12177cd --- /dev/null +++ b/app/lib/core/app_info.dart @@ -0,0 +1,44 @@ +/// Verze aplikace čtená z nainstalovaného balíčku. +/// +/// Proč ne konstanta: verze se dřív psala ručně do `AppConstants` a musela se +/// hlídat proti `version:` v pubspec.yaml. Nehlídala se — produkční 1.1.2+22 +/// posílala v hlášeních zneužití „1.1.0" a podle hlášení tak nešlo dohledat, +/// ze kterého buildu přišlo. `package_info_plus` čte `versionName` (Android) +/// / `CFBundleShortVersionString` (iOS), které Flutter generuje přímo +/// z pubspecu — rozejít se to už nemůže a ruční editace odpadá. +library; + +import 'package:package_info_plus/package_info_plus.dart'; + +class AppInfo { + AppInfo._(); + + /// Zástupný text, dokud verzi nezná (nebo když ji nešlo přečíst). Znak, ne + /// slovo — appka běží v pěti jazycích a české „neznámá" by v nich čouhalo. + static const String unknownVersion = '—'; + + static PackageInfo? _info; + + /// Načte verzi z balíčku. Volat jednou při startu, před prvním `runApp`. + static Future init() async { + _info = await PackageInfo.fromPlatform(); + } + + /// Verze pro uživatele: `1.1.2`. + static String get version => _info?.version ?? unknownVersion; + + /// Verze pro diagnostiku: `1.1.2+22`. Build číslo je jediné, čím se hlášení + /// zneužití přiřadí konkrétnímu buildu — do UI nepatří, do hlášení ano. + /// `null`, dokud verzi nezná: hlášení pak pole vynechá, ať server dostane + /// prázdno místo vymyšlené hodnoty. + static String? get versionWithBuild { + final info = _info; + if (info == null) return null; + // iOS bez vyplněného CFBundleVersion vrací v buildNumber rovnou verzi + // (dokumentované chování balíčku) — nelepit „1.1.2+1.1.2". + if (info.buildNumber.isEmpty || info.buildNumber == info.version) { + return info.version; + } + return '${info.version}+${info.buildNumber}'; + } +} diff --git a/app/lib/core/constants/app_constants.dart b/app/lib/core/constants/app_constants.dart index 445872d..6ebb9a6 100644 --- a/app/lib/core/constants/app_constants.dart +++ b/app/lib/core/constants/app_constants.dart @@ -3,8 +3,6 @@ class AppConstants { AppConstants._(); static const String appName = 'Šepot'; - /// Musí sedět s `version:` v pubspec.yaml (bez build čísla). - static const String appVersion = '1.1.0'; static const String appAuthor = 'ajtak.it'; // Relay server (REST + WS na jednom portu). Přepsatelné při buildu: diff --git a/app/lib/core/safety/report_service.dart b/app/lib/core/safety/report_service.dart index f11c69a..8196dbb 100644 --- a/app/lib/core/safety/report_service.dart +++ b/app/lib/core/safety/report_service.dart @@ -21,6 +21,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; +import 'package:sepot/core/app_info.dart'; import 'package:sepot/core/constants/app_constants.dart'; import 'package:sepot/core/errors.dart'; @@ -67,12 +68,16 @@ class ReportService { throw const ReportEmptyDescriptionError(); } + // S build číslem, ne jen „1.1.2" — jinak nejde poznat, ze kterého buildu + // hlášení přišlo. Neznámou verzi radši vynechat než poslat vymyšlenou. + final verze = AppInfo.versionWithBuild; + final body = { 'kategorie': category.wire, 'popis': popis, 'zdroj': 'app', 'platforma': Platform.operatingSystem, - 'verze': AppConstants.appVersion, + 'verze': ?verze, if (reportedFingerprint != null && reportedFingerprint.isNotEmpty) 'nahlasenyOtisk': reportedFingerprint, if (replyTo != null && replyTo.trim().isNotEmpty) diff --git a/app/lib/features/settings/settings_screen.dart b/app/lib/features/settings/settings_screen.dart index 0c9483a..6b7f538 100644 --- a/app/lib/features/settings/settings_screen.dart +++ b/app/lib/features/settings/settings_screen.dart @@ -8,6 +8,7 @@ import 'package:go_router/go_router.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:sepot/core/app_info.dart'; import 'package:sepot/core/constants/app_constants.dart'; import 'package:sepot/core/l10n/error_messages.dart'; import 'package:sepot/core/l10n/l10n.dart'; @@ -181,7 +182,7 @@ class SettingsScreen extends ConsumerWidget { _buildTile( icon: Icons.info_outline, title: l10n.commonAppName, - subtitle: l10n.settingsVersion(AppConstants.appVersion), + subtitle: l10n.settingsVersion(AppInfo.version), onTap: () => _showAbout(context), ), // Zdrojový kód je veřejný (AGPL-3.0). Detaily o licenci a odkaz @@ -614,7 +615,7 @@ class SettingsScreen extends ConsumerWidget { ), const SizedBox(height: 8), Text( - ctx.l10n.settingsVersion(AppConstants.appVersion), + ctx.l10n.settingsVersion(AppInfo.version), style: const TextStyle(color: SepotColors.textSecondary), ), const SizedBox(height: 16), diff --git a/app/lib/main.dart b/app/lib/main.dart index b12b56e..3431c89 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:sepot/app.dart'; +import 'package:sepot/core/app_info.dart'; import 'package:sepot/core/constants/app_constants.dart'; import 'package:sepot/core/crypto/sepot_crypto.dart'; import 'package:sepot/core/l10n/l10n.dart'; @@ -56,6 +57,14 @@ void main(List args) { debugPrint('FlutterError: ${details.exceptionAsString()}'); }; + // Verze appky z balíčku — nekritické, bez ní se jen v nastavení a + // v hlášení zneužití místo čísla ukáže AppInfo.unknownVersion. + try { + await AppInfo.init(); + } catch (e, s) { + debugPrint('AppInfo.init selhalo (nekritické): $e\n$s'); + } + // Formáty dat všech podporovaných jazyků — nekritické, appka poběží i bez nich for (final lang in const ['cs', 'en', 'de', 'ru', 'it']) { try { diff --git a/app/pubspec.lock b/app/pubspec.lock index 3b9097e..88aeb1c 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -932,6 +932,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 218c8a6..49bdda4 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -30,6 +30,9 @@ dependencies: http: ^1.2.2 socks5_proxy: ^2.1.1 path_provider: ^2.1.5 + # Verzi appky nikdy nepsat ručně — čte se z nainstalovaného balíčku + # (viz lib/core/app_info.dart), aby se nemohla rozejít s `version:` výš. + package_info_plus: ^8.3.1 convert: ^3.1.2 libsignal: ^5.0.9 bip39: ^1.0.6 diff --git a/app/test/app_info_test.dart b/app/test/app_info_test.dart new file mode 100644 index 0000000..f02a060 --- /dev/null +++ b/app/test/app_info_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:sepot/core/app_info.dart'; + +/// Verzi appky nesmí nikdo psát ručně. Regrese: konstanta zůstala po releasu +/// na „1.1.0", takže všechna hlášení zneužití z produkční 1.1.2+22 byla +/// označená špatnou verzí a nešlo podle nich dohledat build. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + PackageInfo mock({required String version, required String buildNumber}) => + PackageInfo( + appName: 'Šepot', + packageName: 'cz.sepot.app', + version: version, + buildNumber: buildNumber, + buildSignature: '', + ); + + void nastavBalicek(PackageInfo info) => PackageInfo.setMockInitialValues( + appName: info.appName, + packageName: info.packageName, + version: info.version, + buildNumber: info.buildNumber, + buildSignature: info.buildSignature, + ); + + // Pořadí testů záleží: PackageInfo si první načtení nacachuje napořád, + // takže stav „ještě se nenačetlo" jde ověřit jen před prvním init(). + test('před inicializací nepředstírá verzi', () { + expect(AppInfo.version, AppInfo.unknownVersion); + expect(AppInfo.versionWithBuild, isNull); + }); + + test('hlášení nese verzi včetně build čísla', () async { + nastavBalicek(mock(version: '1.1.2', buildNumber: '22')); + await AppInfo.init(); + + expect(AppInfo.version, '1.1.2'); + expect(AppInfo.versionWithBuild, '1.1.2+22'); + }); + + test('iOS bez CFBundleVersion nezdvojí verzi', () async { + nastavBalicek(mock(version: '1.1.2', buildNumber: '1.1.2')); + await AppInfo.init(); + + expect(AppInfo.versionWithBuild, '1.1.2'); + }); + + test('prázdné build číslo → jen verze', () async { + nastavBalicek(mock(version: '2.0.0', buildNumber: '')); + await AppInfo.init(); + + expect(AppInfo.versionWithBuild, '2.0.0'); + }); +} diff --git a/app/test/message_timestamp_test.dart b/app/test/message_timestamp_test.dart new file mode 100644 index 0000000..994cef4 --- /dev/null +++ b/app/test/message_timestamp_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sepot/core/services/messaging_service.dart'; + +/// Čas zprávy musí pocházet od odesílatele, ne z okamžiku doručení. +/// Regrese: bez `ts` na drátě dostal každý příjemce vlastní `DateTime.now()`, +/// takže po vyzvednutí offline fronty uskočila celá konverzace na „teď" +/// a každý člen skupiny viděl u téže zprávy jiný čas. +void main() { + test('čas odesílatele z drátu se použije beze změny', () { + final odeslano = DateTime(2026, 8, 10, 14, 30); + final t = MessagingService.wireSentAt(odeslano.millisecondsSinceEpoch); + expect(t, odeslano); + }); + + test('starý klient bez ts → fallback na čas doručení', () { + final pred = DateTime.now(); + final t = MessagingService.wireSentAt(null); + expect(t.isBefore(pred.subtract(const Duration(seconds: 5))), isFalse); + }); + + test('netypové ts (string z drátu) → fallback, nespadne', () { + final pred = DateTime.now(); + final t = MessagingService.wireSentAt('2026-08-10T14:30:00Z'); + expect(t.isBefore(pred.subtract(const Duration(seconds: 5))), isFalse); + }); + + test('rozejité hodiny v toleranci se přijmou', () { + // Přes drát jde epocha v ms, takže mikrosekundy z DateTime.now() padnou + // pod stůl — porovnáváme na milisekundy, ne na identitu objektu. + final skoroTed = DateTime.now().add(const Duration(minutes: 3)); + final t = MessagingService.wireSentAt(skoroTed.millisecondsSinceEpoch); + expect(t.millisecondsSinceEpoch, skoroTed.millisecondsSinceEpoch); + }); + + test('čas z budoucnosti nad strop → čas doručení', () { + final budoucnost = DateTime.now().add(const Duration(days: 365)); + final t = MessagingService.wireSentAt(budoucnost.millisecondsSinceEpoch); + expect(t.isBefore(budoucnost), isTrue); + }); + + test('absurdně starý čas → čas doručení', () { + final t = MessagingService.wireSentAt( + DateTime(1999).millisecondsSinceEpoch, + ); + expect(t.year, greaterThan(2020)); + }); + + test('pořadí zpráv drží čas odeslání, ne pořadí doručení', () { + // Fronta se vylije naráz, ale starší zpráva musí zůstat starší. + final prvni = DateTime(2026, 8, 10, 9, 0); + final druha = DateTime(2026, 8, 10, 9, 5); + final a = MessagingService.wireSentAt(prvni.millisecondsSinceEpoch); + final b = MessagingService.wireSentAt(druha.millisecondsSinceEpoch); + expect(a.isBefore(b), isTrue); + }); +}