fix(app): read the version from the installed package

The version was a hand-written constant that had to be kept in sync with
`version:` in pubspec.yaml. It was not: the constant still said 1.1.0 while
the shipped build was 1.1.2+22, so every abuse report carried a version that
no build ever had — and reports could not be traced back to a build.

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

Also adds the timestamp regression test that covers MessagingService.wireSentAt,
which shipped in 1.1.2 without its test.
This commit is contained in:
Julius Joska
2026-09-04 10:18:37 +02:00
parent f903e21e3a
commit 79ad411d7e
9 changed files with 193 additions and 5 deletions
+44
View File
@@ -0,0 +1,44 @@
/// Verze aplikace čtená z nainstalovaného balíčku.
///
/// Proč ne konstanta: verze se dřív psala ručně do `AppConstants` a musela se
/// hlídat proti `version:` v pubspec.yaml. Nehlídala se — produkční 1.1.2+22
/// posílala v hlášeních zneužití „1.1.0" a podle hlášení tak nešlo dohledat,
/// ze kterého buildu přišlo. `package_info_plus` čte `versionName` (Android)
/// / `CFBundleShortVersionString` (iOS), které Flutter generuje přímo
/// z pubspecu — rozejít se to už nemůže a ruční editace odpadá.
library;
import 'package:package_info_plus/package_info_plus.dart';
class AppInfo {
AppInfo._();
/// Zástupný text, dokud verzi nezná (nebo když ji nešlo přečíst). Znak, ne
/// slovo — appka běží v pěti jazycích a české „neznámá" by v nich čouhalo.
static const String unknownVersion = '—';
static PackageInfo? _info;
/// Načte verzi z balíčku. Volat jednou při startu, před prvním `runApp`.
static Future<void> init() async {
_info = await PackageInfo.fromPlatform();
}
/// Verze pro uživatele: `1.1.2`.
static String get version => _info?.version ?? unknownVersion;
/// Verze pro diagnostiku: `1.1.2+22`. Build číslo je jediné, čím se hlášení
/// zneužití přiřadí konkrétnímu buildu — do UI nepatří, do hlášení ano.
/// `null`, dokud verzi nezná: hlášení pak pole vynechá, ať server dostane
/// prázdno místo vymyšlené hodnoty.
static String? get versionWithBuild {
final info = _info;
if (info == null) return null;
// iOS bez vyplněného CFBundleVersion vrací v buildNumber rovnou verzi
// (dokumentované chování balíčku) — nelepit „1.1.2+1.1.2".
if (info.buildNumber.isEmpty || info.buildNumber == info.version) {
return info.version;
}
return '${info.version}+${info.buildNumber}';
}
}
@@ -3,8 +3,6 @@ class AppConstants {
AppConstants._(); AppConstants._();
static const String appName = 'Šepot'; static const String appName = 'Šepot';
/// Musí sedět s `version:` v pubspec.yaml (bez build čísla).
static const String appVersion = '1.1.0';
static const String appAuthor = 'ajtak.it'; static const String appAuthor = 'ajtak.it';
// Relay server (REST + WS na jednom portu). Přepsatelné při buildu: // Relay server (REST + WS na jednom portu). Přepsatelné při buildu:
+6 -1
View File
@@ -21,6 +21,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:sepot/core/app_info.dart';
import 'package:sepot/core/constants/app_constants.dart'; import 'package:sepot/core/constants/app_constants.dart';
import 'package:sepot/core/errors.dart'; import 'package:sepot/core/errors.dart';
@@ -67,12 +68,16 @@ class ReportService {
throw const ReportEmptyDescriptionError(); throw const ReportEmptyDescriptionError();
} }
// S build číslem, ne jen „1.1.2" — jinak nejde poznat, ze kterého buildu
// hlášení přišlo. Neznámou verzi radši vynechat než poslat vymyšlenou.
final verze = AppInfo.versionWithBuild;
final body = <String, dynamic>{ final body = <String, dynamic>{
'kategorie': category.wire, 'kategorie': category.wire,
'popis': popis, 'popis': popis,
'zdroj': 'app', 'zdroj': 'app',
'platforma': Platform.operatingSystem, 'platforma': Platform.operatingSystem,
'verze': AppConstants.appVersion, 'verze': ?verze,
if (reportedFingerprint != null && reportedFingerprint.isNotEmpty) if (reportedFingerprint != null && reportedFingerprint.isNotEmpty)
'nahlasenyOtisk': reportedFingerprint, 'nahlasenyOtisk': reportedFingerprint,
if (replyTo != null && replyTo.trim().isNotEmpty) if (replyTo != null && replyTo.trim().isNotEmpty)
@@ -8,6 +8,7 @@ import 'package:go_router/go_router.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.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/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';
@@ -181,7 +182,7 @@ class SettingsScreen extends ConsumerWidget {
_buildTile( _buildTile(
icon: Icons.info_outline, icon: Icons.info_outline,
title: l10n.commonAppName, title: l10n.commonAppName,
subtitle: l10n.settingsVersion(AppConstants.appVersion), subtitle: l10n.settingsVersion(AppInfo.version),
onTap: () => _showAbout(context), onTap: () => _showAbout(context),
), ),
// Zdrojový kód je veřejný (AGPL-3.0). Detaily o licenci a odkaz // 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), const SizedBox(height: 8),
Text( Text(
ctx.l10n.settingsVersion(AppConstants.appVersion), ctx.l10n.settingsVersion(AppInfo.version),
style: const TextStyle(color: SepotColors.textSecondary), style: const TextStyle(color: SepotColors.textSecondary),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
+9
View File
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/date_symbol_data_local.dart';
import 'package:sepot/app.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/constants/app_constants.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart'; import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
@@ -56,6 +57,14 @@ void main(List<String> args) {
debugPrint('FlutterError: ${details.exceptionAsString()}'); 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 // Formáty dat všech podporovaných jazyků — nekritické, appka poběží i bez nich
for (final lang in const ['cs', 'en', 'de', 'ru', 'it']) { for (final lang in const ['cs', 'en', 'de', 'ru', 'it']) {
try { try {
+16
View File
@@ -932,6 +932,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: path:
dependency: transitive dependency: transitive
description: description:
+3
View File
@@ -30,6 +30,9 @@ dependencies:
http: ^1.2.2 http: ^1.2.2
socks5_proxy: ^2.1.1 socks5_proxy: ^2.1.1
path_provider: ^2.1.5 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 convert: ^3.1.2
libsignal: ^5.0.9 libsignal: ^5.0.9
bip39: ^1.0.6 bip39: ^1.0.6
+56
View File
@@ -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');
});
}
+56
View File
@@ -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);
});
}