Compare commits

...
2 Commits
21 changed files with 377 additions and 569 deletions

No files matched your search

+2 -2
View File
@@ -49,5 +49,5 @@ app.*.map.json
# Java heap dumps
*.hprof
# Lokale Zugangsdaten für die VHS-Karte
/config/vhs.local.json
# Lokale Build-Konfigurationen mit Zugangsdaten
/config/*.local.json
+4 -16
View File
@@ -7,10 +7,7 @@
"type": "dart",
"flutterMode": "debug",
"emulatorId": "Pixel_10_Pro",
"program": "lib/main.dart",
"toolArgs": [
"--dart-define-from-file=config/vhs.local.json"
]
"program": "lib/main.dart"
},
{
"name": "Stadtbote - Android Emulator (Release)",
@@ -18,10 +15,7 @@
"type": "dart",
"flutterMode": "release",
"emulatorId": "Pixel_10_Pro",
"program": "lib/main.dart",
"toolArgs": [
"--dart-define-from-file=config/vhs.local.json"
]
"program": "lib/main.dart"
},
{
"name": "Stadtbote - Pixel 7",
@@ -29,10 +23,7 @@
"type": "dart",
"flutterMode": "debug",
"deviceId": "2B101FDH2003HB",
"program": "lib/main.dart",
"toolArgs": [
"--dart-define-from-file=config/vhs.local.json"
]
"program": "lib/main.dart"
},
{
"name": "Stadtbote - Pixel 7 (Release)",
@@ -40,10 +31,7 @@
"type": "dart",
"flutterMode": "release",
"deviceId": "2B101FDH2003HB",
"program": "lib/main.dart",
"toolArgs": [
"--dart-define-from-file=config/vhs.local.json"
]
"program": "lib/main.dart"
}
]
}
-20
View File
@@ -1,21 +1 @@
dart run build_runner build
## VHS-Karte
Aufträge können im JSON eine optionale `map_url` enthalten. Die Schaltfläche
„Karte“ öffnet diese HTTPS-Adresse innerhalb der App. Ohne `map_url` wird die
bisherige Stationskarte verwendet.
Die HTTP-Basic-Zugangsdaten werden beim Build übergeben. Dazu
`config/vhs.example.json` nach `config/vhs.local.json` kopieren und die beiden
Werte lokal eintragen. Die lokale Datei ist von Git ausgeschlossen.
```bash
flutter run --dart-define-from-file=config/vhs.local.json
flutter build apk --release --dart-define-from-file=config/vhs.local.json
```
Die VS-Code-Startkonfigurationen und `build_release_apk.sh` verwenden diese
Datei ebenfalls. Zugangsdaten werden nur für den Host der ursprünglichen
Kartenadresse verwendet; die Ansicht erlaubt Navigation innerhalb desselben
HTTPS-Ursprungs (Host und Port).
+1 -5
View File
@@ -34,11 +34,7 @@ echo "Ziel-APK-Name: $APK_NAME"
echo
# Release-APK bauen
BUILD_ARGS=(--release)
if [[ -f "config/vhs.local.json" ]]; then
BUILD_ARGS+=(--dart-define-from-file=config/vhs.local.json)
fi
flutter build apk "${BUILD_ARGS[@]}"
flutter build apk --release
# Pfad zur erzeugten APK
BUILT_APK="build/app/outputs/flutter-apk/app-release.apk"
-4
View File
@@ -1,4 +0,0 @@
{
"VHS_BASIC_AUTH_USERNAME": "",
"VHS_BASIC_AUTH_PASSWORD": ""
}
+8 -2
View File
@@ -4,7 +4,7 @@ import 'package:stadtbote/content_views/task_list_page.dart';
import 'package:stadtbote/content_views/utils/content_view_widget.dart';
import 'package:stadtbote/messages/pop_content_view_message.dart';
import 'package:stadtbote/model/job_tour.dart';
import 'package:stadtbote/popups/job_map_popup.dart';
import 'package:stadtbote/popups/map_popup.dart';
import '../model/job.dart';
import '../popups/files_popup.dart';
@@ -124,7 +124,13 @@ class _TourDetailsPageState extends State<TourDetailsPage> with ProducerMixin {
const SizedBox(height: 10),
getDefaultButton(
'map'.tr,
() => showJobMapPopup(context, widget.job, currentTour: widget.tour)),
() {
if (widget.tour.pos != null) {
showMapPopup(context, widget.tour, widget.job.tours);
} else {
showAlertDialog('information'.tr, 'no_position'.tr);
}
}),
],
),
)),
+3 -4
View File
@@ -4,7 +4,7 @@ import 'package:stadtbote/content_views/tour_details_page.dart';
import 'package:stadtbote/content_views/utils/content_view_widget.dart';
import 'package:stadtbote/messages/pop_content_view_message.dart';
import 'package:stadtbote/model/job_tour.dart';
import 'package:stadtbote/popups/job_map_popup.dart';
import 'package:stadtbote/popups/map_popup.dart';
import '../model/job.dart';
import '../popups/files_popup.dart';
@@ -90,8 +90,7 @@ class _TourListPageState extends State<TourListPage> with ProducerMixin {
SizedBox(
width: 100,
child: Visibility(
visible: (widget.job.mapUrl?.trim().isNotEmpty ?? false) ||
(_tours.isNotEmpty && _tours.first.pos != null),
visible: _tours.isNotEmpty && _tours.first.pos != null,
child: ElevatedButton(
style: ButtonStyle(
shape: WidgetStateProperty.all<RoundedRectangleBorder>(
@@ -103,7 +102,7 @@ class _TourListPageState extends State<TourListPage> with ProducerMixin {
backgroundColor: WidgetStateProperty.all<Color>(Colors.lightGreen),
foregroundColor: WidgetStateProperty.all<Color>(Colors.black),
),
onPressed: () => showJobMapPopup(context, widget.job),
onPressed: () => showMapPopup(context, _tours.first, _tours),
child: Text('map'.tr)),
))
],
-246
View File
@@ -1,246 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:stadtbote/tools/vhs_map_access.dart';
import 'package:webview_flutter/webview_flutter.dart';
class VhsMapPage extends StatefulWidget {
const VhsMapPage({super.key, required this.uri});
final Uri uri;
@override
State<VhsMapPage> createState() => _VhsMapPageState();
}
class _VhsMapPageState extends State<VhsMapPage> {
static const _username = String.fromEnvironment('VHS_BASIC_AUTH_USERNAME');
static const _password = String.fromEnvironment('VHS_BASIC_AUTH_PASSWORD');
static const _loadError =
'Die VHS-Karte konnte nicht geladen werden. '
'Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.';
WebViewController? _controller;
String? _errorMessage;
int _loadId = 0;
int _progress = 0;
bool _isLoading = true;
@override
void initState() {
super.initState();
unawaited(_loadMap());
}
bool _isCurrentLoad(int loadId) => mounted && _loadId == loadId;
void _showError(int loadId, String message) {
if (!_isCurrentLoad(loadId)) return;
setState(() {
_errorMessage ??= message;
_isLoading = false;
});
}
Future<void> _stopLoading(WebViewController? controller) async {
try {
await controller?.runJavaScript('window.stop();');
} catch (_) {
// Beim Schließen kann die native WebView bereits entfernt worden sein.
}
}
Future<void> _loadMap() async {
final loadId = ++_loadId;
final previousController = _controller;
setState(() {
_controller = null;
_errorMessage = null;
_progress = 0;
_isLoading = true;
});
await _stopLoading(previousController);
if (!_isCurrentLoad(loadId)) return;
final access = VhsMapAccess(widget.uri);
if (!access.isValid) {
_showError(
loadId,
'Für diesen Auftrag ist kein gültiger Kartenlink verfügbar.',
);
return;
}
Uri documentUri = widget.uri;
WebViewController? controller;
try {
controller = WebViewController();
await controller.setJavaScriptMode(JavaScriptMode.unrestricted);
if (!_isCurrentLoad(loadId)) return;
await controller.setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (request) {
if (!_isCurrentLoad(loadId) || _errorMessage != null) {
return NavigationDecision.prevent;
}
final target = Uri.tryParse(request.url);
if (target == null || !access.allowsNavigation(target)) {
if (request.isMainFrame) {
_showError(
loadId,
'Dieser Link kann in der VHS-Karte nicht geöffnet werden.',
);
}
return NavigationDecision.prevent;
}
if (request.isMainFrame) documentUri = target;
return NavigationDecision.navigate;
},
onPageStarted: (url) {
if (!_isCurrentLoad(loadId) || _errorMessage != null) return;
final target = Uri.tryParse(url);
if (target == null || !access.allowsNavigation(target)) {
unawaited(_stopLoading(controller));
_showError(
loadId,
'Dieser Link kann in der VHS-Karte nicht geöffnet werden.',
);
return;
}
documentUri = target;
setState(() {
_progress = 0;
_isLoading = true;
});
},
onProgress: (progress) {
if (!_isCurrentLoad(loadId) || _errorMessage != null) return;
setState(() => _progress = progress);
},
onPageFinished: (_) {
if (!_isCurrentLoad(loadId) || _errorMessage != null) return;
setState(() => _isLoading = false);
},
onWebResourceError: (error) {
// Fehlende Kartenkacheln dürfen die gesamte Karte nicht verdecken.
if (error.isForMainFrame == true ||
(error.isForMainFrame == null &&
error.url != null &&
Uri.tryParse(error.url!) == documentUri)) {
_showError(loadId, _loadError);
}
},
onHttpError: (error) {
// 401 kann vor der erfolgreichen HTTP-Anmeldung gemeldet werden.
if (error.response?.statusCode == 401) return;
// WebKit meldet für Dokumentfehler teilweise keine Request-URI.
if (error.request == null || error.request!.uri == documentUri) {
_showError(loadId, _loadError);
}
},
onHttpAuthRequest: (request) {
if (!_isCurrentLoad(loadId) ||
_errorMessage != null ||
!access.allowsAuthentication(request.host)) {
request.onCancel();
return;
}
if (_username.isEmpty || _password.isEmpty) {
request.onCancel();
_showError(
loadId,
'Der Zugang zur VHS-Karte ist in dieser App-Version nicht eingerichtet.',
);
return;
}
if (!access.tryAuthentication(request.host)) {
request.onCancel();
_showError(
loadId,
'Die Anmeldung an der VHS-Karte ist fehlgeschlagen.',
);
return;
}
request.onProceed(
const WebViewCredential(user: _username, password: _password),
);
},
),
);
if (!_isCurrentLoad(loadId)) return;
setState(() => _controller = controller);
await controller.loadRequest(widget.uri);
} catch (_) {
_showError(loadId, _loadError);
}
}
@override
void dispose() {
_loadId++;
unawaited(_stopLoading(_controller));
super.dispose();
}
@override
Widget build(BuildContext context) {
final controller = _controller;
final errorMessage = _errorMessage;
return Scaffold(
appBar: AppBar(
title: const Text('VHS-Karte'),
leading: IconButton(
tooltip: 'Schließen',
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
actions: [
IconButton(
tooltip: 'Neu laden',
icon: const Icon(Icons.refresh),
onPressed: _isLoading ? null : _loadMap,
),
],
),
body: SafeArea(
child: errorMessage != null
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48),
const SizedBox(height: 16),
Text(errorMessage, textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: _loadMap,
child: const Text('Erneut versuchen'),
),
],
),
),
)
: Column(
children: [
if (_isLoading)
LinearProgressIndicator(
value: _progress == 0 ? null : _progress / 100,
semanticsLabel: 'VHS-Karte wird geladen',
),
Expanded(
child: controller == null
? const Center(child: CircularProgressIndicator())
: WebViewWidget(
key: ValueKey(_loadId),
controller: controller,
),
),
],
),
),
);
}
}
+3 -1
View File
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
import 'package:geolocator/geolocator.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_map/shared_map.dart';
import 'package:stadtbote/content_views/assigned_jobs_page.dart';
import 'package:stadtbote/messages/pop_content_view_message.dart';
import 'package:stadtbote/model/log_entry.dart';
import 'package:stadtbote/popups/accept_jobs_popup.dart';
@@ -529,7 +530,8 @@ class _MainState extends State<Main> with SingleTickerProviderStateMixin, Produc
void _startCheckForNewState0JobsJobsTimer() {
_checkForNewState0JobsJobsTimer ??= Timer.periodic(const Duration(seconds: 5), (Timer t) async {
if (await _jobMap!.length() > 0 && !_dialogOpened) {
// Auf der Auftragsliste können neue Aufträge direkt beantwortet werden.
if (await _jobMap!.length() > 0 && !_dialogOpened && _currentContent is! AssignedJobsPage) {
_dialogOpened = true;
await showAcceptJobsPopup(_jobMap!.sharedReference(), () async {
_dialogOpened = false;
-3
View File
@@ -15,7 +15,6 @@ class Job {
late String ver;
late int state;
String? crFilter;
String? mapUrl;
late int ordertime;
late int offsWarn;
late bool global;
@@ -64,7 +63,6 @@ class Job {
job.ver = json['ver'] as String;
job.state = json['state'] as int;
job.crFilter = json['cr_filter'] as String?;
job.mapUrl = json['map_url'] as String?;
job.ordertime = json['ordertime'] as int;
job.offsWarn = json['offs_warn'] as int;
job.global = json['global'] as bool;
@@ -88,7 +86,6 @@ class Job {
'ver': ver,
'state': state,
'cr_filter': crFilter,
'map_url': mapUrl,
'ordertime': ordertime,
'offs_warn': offsWarn,
'global': global,
+2 -6
View File
@@ -104,11 +104,6 @@
"id": "13:1803006975816940878",
"name": "filesJson",
"type": 9
},
{
"id": "14:4499212061291648440",
"name": "mapUrl",
"type": 9
}
],
"relations": []
@@ -431,7 +426,8 @@
2981635918450905844,
4551221086536914442,
1176594062059570557,
4272081635478028034
4272081635478028034,
4499212061291648440
],
"retiredRelationUids": [],
"version": 1
+3 -16
View File
@@ -128,11 +128,6 @@ final _entities = <obx_int.ModelEntity>[
id: const obx_int.IdUid(13, 1803006975816940878),
name: 'filesJson',
type: 9,
flags: 0),
obx_int.ModelProperty(
id: const obx_int.IdUid(14, 4499212061291648440),
name: 'mapUrl',
type: 9,
flags: 0)
],
relations: <obx_int.ModelRelation>[],
@@ -488,7 +483,8 @@ obx_int.ModelDefinition getObjectBoxModel() {
2981635918450905844,
4551221086536914442,
1176594062059570557,
4272081635478028034
4272081635478028034,
4499212061291648440
],
retiredRelationUids: const [],
modelVersion: 5,
@@ -547,8 +543,6 @@ obx_int.ModelDefinition getObjectBoxModel() {
final remOffset = fbb.writeString(object.rem);
final toursJsonOffset = fbb.writeString(object.toursJson);
final filesJsonOffset = fbb.writeString(object.filesJson);
final mapUrlOffset =
object.mapUrl == null ? null : fbb.writeString(object.mapUrl!);
fbb.startTable(15);
fbb.addInt64(0, object.id);
fbb.addOffset(1, verOffset);
@@ -563,7 +557,6 @@ obx_int.ModelDefinition getObjectBoxModel() {
fbb.addOffset(10, toursJsonOffset);
fbb.addInt64(11, object.externalId);
fbb.addOffset(12, filesJsonOffset);
fbb.addOffset(13, mapUrlOffset);
fbb.finish(fbb.endTable());
return object.id;
},
@@ -595,9 +588,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
..externalId =
const fb.Int64Reader().vTableGet(buffer, rootOffset, 26, 0)
..filesJson = const fb.StringReader(asciiOptimization: true)
.vTableGet(buffer, rootOffset, 28, '')
..mapUrl = const fb.StringReader(asciiOptimization: true)
.vTableGetNullable(buffer, rootOffset, 30);
.vTableGet(buffer, rootOffset, 28, '');
return object;
}),
@@ -975,10 +966,6 @@ class Job_ {
/// See [Job.filesJson].
static final filesJson =
obx.QueryStringProperty<Job>(_entities[1].properties[12]);
/// See [Job.mapUrl].
static final mapUrl =
obx.QueryStringProperty<Job>(_entities[1].properties[13]);
}
/// [LocationStatus] entity fields to define ObjectBox queries.
-34
View File
@@ -1,34 +0,0 @@
import 'package:flutter/material.dart';
import 'package:stadtbote/content_views/vhs_map_page.dart';
import 'package:stadtbote/model/job.dart';
import 'package:stadtbote/model/job_tour.dart';
import 'package:stadtbote/popups/map_popup.dart';
import 'package:stadtbote/tools/app_localizations.dart';
import 'package:stadtbote/tools/util.dart';
Future<void> showJobMapPopup(
BuildContext context,
Job job, {
JobTour? currentTour,
}) async {
final mapUrl = job.mapUrl?.trim();
if (mapUrl != null && mapUrl.isNotEmpty) {
final uri = Uri.tryParse(mapUrl);
if (uri == null) {
await showAlertDialog('error'.tr, 'Die Kartenadresse ist ungültig.');
return;
}
await Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => VhsMapPage(uri: uri)));
return;
}
final tours = job.tours;
final tour = currentTour ?? (tours.isNotEmpty ? tours.first : null);
if (tour?.pos == null) {
await showAlertDialog('information'.tr, 'no_position'.tr);
return;
}
await showMapPopup(context, tour!, tours);
}
+33 -8
View File
@@ -6,6 +6,7 @@ import 'dart:math';
import 'package:archive/archive.dart';
import 'package:dart_mq/dart_mq.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:http/http.dart' as http;
import 'package:mqtt5_client/mqtt5_client.dart';
@@ -98,6 +99,22 @@ class MqttCore with ProducerMixin, ConsumerMixin {
_processBootstrap();
}
/// Ermöglicht Tests des Nachrichtenflusses ohne Bootstrap und Netzwerkzugriff.
@visibleForTesting
MqttCore.withClient({
required MqttServerClient client,
required String clientId,
required String fromServerTopic,
required String toServerTopic,
}) : _mqttClient = client,
_clientId = clientId,
_fromServerTopic = fromServerTopic,
_toServerTopic = toServerTopic {
instance = this;
initMessages();
_setupMqtt();
}
ConnectionStateEnum getConnectionStatus() {
return connectionState;
}
@@ -185,15 +202,24 @@ class MqttCore with ProducerMixin, ConsumerMixin {
}
}
bool _publishMessage(String topic, Map<String, dynamic> message, bool acknowledgeRequired, {bool enqueueOnError = true}) {
bool _publishMessage(String topic, Map<String, dynamic> message, bool acknowledgeRequired) {
try {
String messageId = const Uuid().v4();
String messageText = jsonEncode(message);
final messageId = const Uuid().v4();
if (acknowledgeRequired) {
_enqueueMessage(messageId, topic, messageText);
// Vor dem ersten Versand speichern; erst die Serverbestätigung entfernt den Eintrag.
_enqueueMessage(messageId, topic, jsonEncode(message));
}
return _sendMessage(topic, message, acknowledgeRequired, messageId);
} catch (ex) {
Util.logInfo("send failed: $ex");
return false;
}
}
bool _sendMessage(String topic, Map<String, dynamic> message, bool acknowledgeRequired, String messageId) {
try {
var encapsulatedMessageText = _encapsulateMessage(messageId, acknowledgeRequired, message);
var buffer = Uint8Buffer();
@@ -761,7 +787,7 @@ class MqttCore with ProducerMixin, ConsumerMixin {
}
}
void _enqueueMessage(String uuid, String receiver, String payload) async {
void _enqueueMessage(String uuid, String receiver, String payload) {
var enqueuedMessage = EnqueuedMessage();
enqueuedMessage.messageId = uuid;
enqueuedMessage.topic = receiver;
@@ -780,9 +806,8 @@ class MqttCore with ProducerMixin, ConsumerMixin {
try {
var json = jsonDecode(enqueuedMessage.payload);
if (_publishMessage(enqueuedMessage.topic, json, false, enqueueOnError: false)) {
box.remove(enqueuedMessage.id);
}
// Wiederholungen behalten ihre ID und benötigen weiterhin eine Bestätigung.
_sendMessage(enqueuedMessage.topic, json, true, enqueuedMessage.messageId);
} catch (ex) {
Util.logInfo("resend enqueued message failed: $ex");
}
-33
View File
@@ -1,33 +0,0 @@
class VhsMapAccess {
VhsMapAccess(this.uri);
final Uri uri;
static const int maxAuthenticationAttempts = 3;
int _authenticationAttempts = 0;
static bool _isValidUri(Uri value) =>
value.scheme == 'https' &&
value.hasAuthority &&
value.host.isNotEmpty &&
value.userInfo.isEmpty &&
value.port > 0 &&
value.port <= 65535;
bool get isValid => _isValidUri(uri);
bool allowsNavigation(Uri target) =>
isValid && _isValidUri(target) && target.origin == uri.origin;
// Die WebView liefert bei Authentifizierungsanfragen nur Host und Realm.
bool allowsAuthentication(String host) =>
isValid && host.toLowerCase() == uri.host.toLowerCase();
bool tryAuthentication(String host) {
if (!allowsAuthentication(host) ||
_authenticationAttempts >= maxAuthenticationAttempts) {
return false;
}
_authenticationAttempts++;
return true;
}
}
@@ -15,7 +15,6 @@ import pdfium_flutter
import permission_manager
import shared_preferences_foundation
import url_launcher_macos
import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
@@ -28,5 +27,4 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PermissionManagerPlugin.register(with: registry.registrar(forPlugin: "PermissionManagerPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
}
-32
View File
@@ -1629,38 +1629,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webview_flutter:
dependency: "direct main"
description:
name: webview_flutter
sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111
url: "https://pub.dev"
source: hosted
version: "4.14.1"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964"
url: "https://pub.dev"
source: hosted
version: "4.14.1"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
url: "https://pub.dev"
source: hosted
version: "2.15.1"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: "0f784be1b080e5b918ef7c2c070c1f4616899fd23f1d001feb74b4978f92bdbc"
url: "https://pub.dev"
source: hosted
version: "3.26.2"
win32:
dependency: transitive
description:
+1 -2
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 357.0.0+357
version: 358.0.0+358
environment:
sdk: '>=3.9.0 <4.0.0'
@@ -56,7 +56,6 @@ dependencies:
geolocator: ^13.0.2
expandable: ^5.0.1
url_launcher: ^6.2.4
webview_flutter: ^4.13.0
audioplayers: ^6.2.0
flutter_launcher_icons: ^0.14.3
package_info_plus: ^8.0.2
-51
View File
@@ -1,51 +0,0 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:stadtbote/model/job.dart';
void main() {
group('Job map_url', () {
test('erhält die Server-URL einschließlich Query und Fragment', () {
const mapUrl = 'https://vhs.example.invalid/map?job=21891302#courier';
final payload = _jobPayload()..['map_url'] = mapUrl;
final job = Job.fromJson(payload);
final serialized =
jsonDecode(jsonEncode(job.toJson())) as Map<String, dynamic>;
final restored = Job.fromJson(serialized);
expect(job.mapUrl, mapUrl);
expect(serialized['map_url'], mapUrl);
expect(restored.mapUrl, mapUrl);
expect(restored.externalId, 21891302);
});
test('liest ältere Aufträge ohne map_url weiterhin ein', () {
final job = Job.fromJson(_jobPayload());
final restored = Job.fromJson(job.toJson());
expect(job.mapUrl, isNull);
expect(restored.mapUrl, isNull);
expect(restored.externalId, 21891302);
});
test('akzeptiert eine explizit fehlende map_url', () {
final payload = _jobPayload()..['map_url'] = null;
expect(Job.fromJson(payload).mapUrl, isNull);
});
});
}
Map<String, dynamic> _jobPayload() => {
'id': 21891302,
'ver': '0',
'state': 0,
'ordertime': 1791285073,
'offs_warn': 0,
'global': false,
'vht_id': 2,
'rem': '',
'delayOrderTime': 0,
'tours': <Map<String, dynamic>>[],
};
+317
View File
@@ -0,0 +1,317 @@
import 'dart:async';
import 'dart:convert';
import 'package:archive/archive.dart';
import 'package:dart_mq/dart_mq.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mqtt5_client/mqtt5_client.dart';
import 'package:mqtt5_client/mqtt5_server_client.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:stadtbote/main.dart';
import 'package:stadtbote/model/enqueued_message.dart';
import 'package:stadtbote/model/job.dart';
import 'package:stadtbote/model/log_entry.dart';
import 'package:stadtbote/objectbox.g.dart';
import 'package:stadtbote/tools/constants.dart';
import 'package:stadtbote/tools/message_header.dart';
import 'package:stadtbote/tools/messages/publish_payload_message.dart';
import 'package:stadtbote/tools/mqtt_core.dart';
import 'package:stadtbote/tools/util.dart';
// ignore: depend_on_referenced_packages
import 'package:typed_data/typed_buffers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late _MessageStore store;
late _MqttClient client;
setUp(() async {
SharedPreferences.setMockInitialValues({});
await Util.init();
MQClient.initialize();
for (final topic in [
Constants.messageTopicPublishPayload,
Constants.messageTopicDisconnectClient,
Constants.messageTopicConnectClient,
Constants.messageTopicRequestConnectionStatus,
Constants.messageTopicConnectionStatusChanged,
]) {
MQClient.instance.declareQueue(topic);
}
store = _MessageStore();
MyApp.objectBoxStore = store;
client = _MqttClient();
MqttCore.withClient(
client: client,
clientId: 'test-device',
fromServerTopic: 'server/a/b/c/d/e/',
toServerTopic: 'client/a/b/c/d/e/',
);
});
tearDown(() async {
await client.incoming.close();
MQClient.instance.close();
});
Future<void> publish(WidgetTester tester, {bool requiresAck = true}) async {
MQClient.instance.sendMessage(
routingKey: Constants.messageTopicPublishPayload,
message: Message(
payload: PublishPayloadMessage(
topic: 'mssg',
message: {'text': 'Auftrag erledigt'},
acknowledgeRequired: requiresAck,
),
),
);
await tester.pump();
}
Future<void> retry(WidgetTester tester) async {
client.receive('sync_request', {});
await tester.pump();
}
testWidgets('behält ID, Inhalt und Bestätigungspflicht bei Wiederholungen', (
tester,
) async {
// Auch beim ersten Sendeversuch muss die Nachricht bereits gespeichert sein.
List<EnqueuedMessage>? queuedAtFirstSend;
client.onPublish = () => queuedAtFirstSend = store.messages.getAll();
await publish(tester);
client.onPublish = null;
final original = client.chatMessages.single;
final queued = store.messages.rows.single;
expect(queuedAtFirstSend, hasLength(1));
expect(queuedAtFirstSend!.single.messageId, original.messageId);
expect(queued.messageId, original.messageId);
expect(jsonDecode(queued.payload), original.payload);
await retry(tester);
await retry(tester);
expect(client.chatMessages, hasLength(3));
for (final message in client.chatMessages) {
expect(message.toJson(), original.toJson());
expect(message.requiresAck, isTrue);
}
expect(store.messages.rows.single.id, queued.id);
expect(store.messages.putCount, 1);
});
testWidgets('sendet bestätigte Nachrichten nicht erneut', (tester) async {
await publish(tester);
final messageId = client.chatMessages.single.messageId;
await retry(tester);
expect(store.messages.rows, hasLength(1));
client.receive('acknowledge', {'messageId': messageId});
await tester.pump();
expect(store.messages.rows, isEmpty);
await retry(tester);
expect(client.chatMessages, hasLength(2));
});
testWidgets('behält unbestätigte Nachrichten nach fehlgeschlagenem Versand', (
tester,
) async {
client.failPublish = true;
await publish(tester);
final messageId = store.messages.rows.single.messageId;
await retry(tester);
expect(store.messages.rows.single.messageId, messageId);
expect(client.chatMessages, isEmpty);
client.failPublish = false;
await retry(tester);
expect(client.chatMessages.single.messageId, messageId);
expect(client.chatMessages.single.requiresAck, isTrue);
expect(store.messages.rows, hasLength(1));
});
testWidgets('verwendet die ID bereits gespeicherter Nachrichten', (
tester,
) async {
store.messages.put(
EnqueuedMessage()
..messageId = 'persisted-message-id'
..topic = 'mssg'
..payload = '{"text":"Vor dem Neustart gespeichert"}',
);
await retry(tester);
final message = client.chatMessages.single;
expect(message.messageId, 'persisted-message-id');
expect(message.requiresAck, isTrue);
expect(message.payload['text'], 'Vor dem Neustart gespeichert');
expect(store.messages.rows, hasLength(1));
expect(store.messages.putCount, 1);
});
testWidgets('wiederholt Nachrichten ohne Bestätigungspflicht nicht', (
tester,
) async {
await publish(tester, requiresAck: false);
await retry(tester);
expect(client.chatMessages, hasLength(1));
expect(client.chatMessages.single.requiresAck, isFalse);
expect(store.messages.rows, isEmpty);
});
testWidgets(
'vergibt für neue Nachrichten auch bei gleichem Inhalt eigene IDs',
(tester) async {
await publish(tester);
await publish(tester);
final ids = client.chatMessages
.map((message) => message.messageId)
.toList();
expect(ids.toSet(), hasLength(2));
await retry(tester);
expect(client.chatMessages.map((message) => message.messageId), [
...ids,
...ids,
]);
expect(store.messages.rows, hasLength(2));
},
);
testWidgets('sendet bei fehlgeschlagener Speicherung keine neue Nachricht', (
tester,
) async {
store.messages.failPut = true;
await publish(tester);
expect(client.chatMessages, isEmpty);
expect(store.messages.rows, isEmpty);
});
}
class _MqttClient extends Fake implements MqttServerClient {
final incoming =
StreamController<List<MqttReceivedMessage<MqttMessage>>>.broadcast();
final chatMessages = <MessageHeader>[];
bool failPublish = false;
void Function()? onPublish;
@override
Stream<List<MqttReceivedMessage<MqttMessage>>> get updates => incoming.stream;
@override
MqttSubscription? subscribe(String topic, MqttQos qosLevel) => null;
@override
int publishMessage(
String topic,
MqttQos qualityOfService,
Uint8Buffer data, {
bool retain = false,
List<MqttUserProperty>? userProperties,
}) {
onPublish?.call();
if (failPublish) throw StateError('Keine Verbindung');
if (topic.endsWith('/mssg')) {
chatMessages.add(
MessageHeader.fromJson(
jsonDecode(utf8.decode(GZipDecoder().decodeBytes(data))),
),
);
}
return 1;
}
void receive(String topic, Map<String, dynamic> payload) {
final data = Uint8Buffer()
..addAll(
GZipEncoder().encode(
utf8.encode(
jsonEncode({
'messageId': 'server-message-id',
'requiresAck': false,
'payload': payload,
}),
),
),
);
final message = MqttPublishMessage()
.toTopic('server/a/b/c/d/e/test-device/$topic')
.publishData(data);
incoming.add([MqttReceivedMessage(topic, message)]);
}
}
class _MessageStore extends Fake implements Store {
final messages = _MessageBox();
final logs = _MemoryBox<LogEntry>();
final jobs = _MemoryBox<Job>();
@override
Box<T> box<T>() {
if (T == EnqueuedMessage) return messages as Box<T>;
if (T == LogEntry) return logs as Box<T>;
if (T == Job) return jobs as Box<T>;
throw StateError('Unerwarteter Entity-Typ: $T');
}
}
class _MemoryBox<T> extends Fake implements Box<T> {
final rows = <T>[];
@override
List<T> getAll() => List.of(rows);
@override
int put(T object, {PutMode mode = PutMode.put}) => 0;
@override
QueryBuilder<T> query([Condition<T>? qc]) => _MemoryQueryBuilder(rows);
}
class _MessageBox extends _MemoryBox<EnqueuedMessage> {
int putCount = 0;
bool failPut = false;
@override
int put(EnqueuedMessage object, {PutMode mode = PutMode.put}) {
if (failPut) throw StateError('Speicherung fehlgeschlagen');
object.id = ++putCount;
rows.add(object);
return object.id;
}
@override
int removeMany(List<int> ids) {
final count = rows.length;
rows.removeWhere((row) => ids.contains(row.id));
return count - rows.length;
}
}
// Die nativen Datenbankzugriffe sind ersetzt. Im Bestätigungstest enthält
// die Warteschlange genau die eine zum ACK passende Nachricht.
class _MemoryQueryBuilder<T> extends Fake implements QueryBuilder<T> {
final List<T> rows;
_MemoryQueryBuilder(this.rows);
@override
Query<T> build() => _MemoryQuery(rows);
}
class _MemoryQuery<T> extends Fake implements Query<T> {
final List<T> rows;
_MemoryQuery(this.rows);
@override
List<T> find() => List.of(rows);
@override
void close() {}
}
-82
View File
@@ -1,82 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:stadtbote/tools/vhs_map_access.dart';
void main() {
group('VHS-Kartenlink', () {
test('erlaubt HTTPS inklusive Kartenparameter und Fragment', () {
final access = VhsMapAccess(
Uri.parse('https://vhs.example/map?job=42#courier'),
);
expect(access.isValid, isTrue);
});
test('weist unsichere und unvollständige Links zurück', () {
for (final url in [
'http://vhs.example/map',
'/map?job=42',
'//vhs.example/map',
'javascript:alert(1)',
'https:///map',
'https://user:password@vhs.example/map',
'https://vhs.example:0/map',
'https://vhs.example:65536/map',
]) {
expect(VhsMapAccess(Uri.parse(url)).isValid, isFalse, reason: url);
}
});
test('erlaubt Navigation nur innerhalb derselben HTTPS-Origin', () {
final access = VhsMapAccess(Uri.parse('https://vhs.example/map?job=42'));
expect(
access.allowsNavigation(Uri.parse('https://vhs.example:443/other')),
isTrue,
);
for (final url in [
'http://vhs.example/map',
'https://other.example/map',
'https://vhs.example.other.example/map',
'https://vhs.example:8443/map',
'https://user@vhs.example/map',
'file:///map',
]) {
expect(access.allowsNavigation(Uri.parse(url)), isFalse, reason: url);
}
});
});
group('VHS-Anmeldung', () {
test('sendet Zugangsdaten nur an den exakten Kartenhost', () {
final access = VhsMapAccess(Uri.parse('https://vhs.example/map'));
expect(access.allowsAuthentication('VHS.EXAMPLE'), isTrue);
for (final host in [
'other.example',
'vhs.example.other.example',
'sub.vhs.example',
'',
]) {
expect(access.allowsAuthentication(host), isFalse, reason: host);
}
expect(
VhsMapAccess(
Uri.parse('http://vhs.example'),
).allowsAuthentication('vhs.example'),
isFalse,
);
});
test('begrenzt wiederholte Authentifizierungsanfragen', () {
final access = VhsMapAccess(Uri.parse('https://vhs.example/map'));
expect(access.tryAuthentication('other.example'), isFalse);
for (
var attempt = 0;
attempt < VhsMapAccess.maxAuthenticationAttempts;
attempt++
) {
expect(access.tryAuthentication('vhs.example'), isTrue);
}
expect(access.tryAuthentication('vhs.example'), isFalse);
expect(access.tryAuthentication('vhs.example'), isFalse);
expect(VhsMapAccess(access.uri).tryAuthentication('vhs.example'), isTrue);
});
});
}