From fc31acb80f8fba40816ba45dbd5bef3e7ebcbab9 Mon Sep 17 00:00:00 2001 From: "torok.istvan" Date: Thu, 9 Jul 2026 03:43:06 +0200 Subject: [PATCH] =?UTF-8?q?Kml=20parser=20utf-8=20t=C3=A1mogat=C3=A1s,=20i?= =?UTF-8?q?ndul=C3=A1skor=20minimum=20app=20verzi=C3=B3=20ellen=C5=91rz?= =?UTF-8?q?=C3=A9se?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/kml_parser.dart | 53 +++++++++++- lib/main.dart | 11 +++ .../views/update_required_view.dart | 67 ++++++++++++++++ lib/services/layer_import_service.dart | 23 +++++- lib/services/layer_sync_service.dart | 80 ++++++++++++------- lib/services/version_gate_service.dart | 74 +++++++++++++++++ lib/widgets/map/imported_layer_overlay.dart | 21 +++++ pubspec.yaml | 1 + 8 files changed, 301 insertions(+), 29 deletions(-) create mode 100644 lib/pages/update_required/presentation/views/update_required_view.dart create mode 100644 lib/services/version_gate_service.dart diff --git a/lib/core/kml_parser.dart b/lib/core/kml_parser.dart index 2f7b54f..0d18ec1 100644 --- a/lib/core/kml_parser.dart +++ b/lib/core/kml_parser.dart @@ -5,6 +5,7 @@ // // KMZ = ZIP archív, benne doc.kml +import 'dart:convert'; import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:flutter/material.dart'; @@ -17,6 +18,55 @@ import 'package:xml/xml.dart'; import '../models/imported_layer.dart'; class KmlParser { + /// KML szöveg dekódolása a fájlban DEKLARÁLT kódolással (vagy UTF-8, + /// ha nincs deklaráció — ez az XML alapértelmezése). + /// + /// * UTF-8 deklaráció (vagy annak hiánya) → UTF-8 dekódolás; ha ez + /// mégis érvénytelen bájtsorozatra futna (rosszul címkézett fájl), + /// Latin-1-re esünk vissza — az minden bájt-értékre ad valamilyen + /// karaktert, nem dob kivételt. + /// * Explicit ISO-8859-1 / Windows-1252 deklaráció → Latin-1. + /// * Egyéb, Dart alapkönyvtára által NEM ismert kódolás (pl. + /// ISO-8859-2, Windows-1250 — ezek adnák helyesen az ő/ű betűket + /// egy esetleges nem-UTF-8 exportnál) → egyértelmű hibaüzenet, + /// ahelyett hogy csendben félrement szöveget adnánk. + static String decodeText(List bytes) { + final declared = _detectDeclaredEncoding(bytes); + + if (declared == null || + declared.contains('utf-8') || + declared.contains('utf8')) { + try { + return utf8.decode(bytes); + } catch (_) { + return latin1.decode(bytes); + } + } + + if (declared.contains('8859-1') || + declared.contains('latin1') || + declared.contains('windows-1252') || + declared.contains('cp1252')) { + return latin1.decode(bytes); + } + + if (declared == 'ascii' || declared == 'us-ascii') { + return ascii.decode(bytes, allowInvalid: true); + } + + throw FormatException( + 'Ismeretlen vagy nem támogatott KML kódolás a fájlban: ' + '"$declared". Exportáld a fájlt UTF-8 kódolással.'); + } + + static String? _detectDeclaredEncoding(List bytes) { + final headLen = bytes.length < 200 ? bytes.length : 200; + final head = ascii.decode(bytes.sublist(0, headLen), allowInvalid: true); + final match = + RegExp(r'''encoding\s*=\s*["']([^"']+)["']''').firstMatch(head); + return match?.group(1)?.toLowerCase(); + } + // Alapértelmezett stílusok — felülírhatók konstruktorban final Color defaultPolylineColor; final double defaultPolylineStroke; @@ -46,7 +96,8 @@ class KmlParser { orElse: () => throw Exception('Nem található .kml fájl a KMZ-ben'), ); - final kmlString = String.fromCharCodes(kmlFile.content as List); + //final kmlString = String.fromCharCodes(kmlFile.content as List); + final kmlString = utf8.decode(kmlFile.content as List); return _parse(kmlString, fileName, LayerImportSourceType.kmz, id: id, isVisible: isVisible); } diff --git a/lib/main.dart b/lib/main.dart index a6f8afe..2c6b159 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get/get.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_controller.dart'; +import 'package:terepi_seged/pages/update_required/presentation/views/update_required_view.dart'; import 'package:terepi_seged/routes/app_pages.dart'; import 'package:terepi_seged/services/app_database.dart'; import 'package:terepi_seged/services/app_logger.dart'; @@ -29,6 +30,7 @@ import 'package:terepi_seged/services/stakeout_sync_service.dart'; import 'package:terepi_seged/services/tilt_service.dart'; import 'package:terepi_seged/services/track_sync_service.dart'; import 'package:terepi_seged/services/ts_sync_service.dart'; +import 'package:terepi_seged/services/version_gate_service.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -62,6 +64,15 @@ Future main() async { url: dotenv.env['SUPABASE_URL']!, anonKey: dotenv.env['SUPABASE_ANON_KEY']!); + final versionGate = await checkVersionGate(); + if (versionGate.blocked) { + runApp(MaterialApp( + debugShowCheckedModeBanner: false, + home: UpdateRequiredView(result: versionGate), + )); + return; + } + Get.put(AuthService()); Get.put(DeviceIdentityService(), permanent: true); await AppDatabase.instance.database; diff --git a/lib/pages/update_required/presentation/views/update_required_view.dart b/lib/pages/update_required/presentation/views/update_required_view.dart new file mode 100644 index 0000000..424acf4 --- /dev/null +++ b/lib/pages/update_required/presentation/views/update_required_view.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:terepi_seged/services/version_gate_service.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:app_settings/app_settings.dart'; + +/// Blokkoló "frissítés szükséges" képernyő — ez az EGYETLEN dolog, amit +/// az app mutat, ha a build a minimum alatt van. A rendes appot (GetX +/// service-ek, routing, shell) ilyenkor a main.dart el sem indítja. +class UpdateRequiredView extends StatelessWidget { + final VersionGateResult result; + const UpdateRequiredView({super.key, required this.result}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.system_update_alt, + size: 72, color: Colors.orange), + const SizedBox(height: 20), + Text( + 'Frissítés szükséges', + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + result.message, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 15), + ), + const SizedBox(height: 28), + if (result.updateUrl.isNotEmpty) + FilledButton.icon( + icon: const Icon(Icons.download), + label: const Text('Frissítés'), + onPressed: () => launchUrl( + Uri.parse(result.updateUrl), + mode: LaunchMode.externalApplication, + ), + ), + const SizedBox(height: 12), + TextButton.icon( + icon: const Icon(Icons.settings_outlined, size: 18), + label: const Text('Alkalmazás beállításainak megnyitása'), + onPressed: () => AppSettings.openAppSettings( + type: AppSettingsType.settings), + ), + const SizedBox(height: 4), + Text( + '(ha belső tesztelőként a Play Áruház link még nem elérhető)', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 11, color: Colors.grey.shade600), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/services/layer_import_service.dart b/lib/services/layer_import_service.dart index 3b5d8e1..bb33cb9 100644 --- a/lib/services/layer_import_service.dart +++ b/lib/services/layer_import_service.dart @@ -5,6 +5,7 @@ // SQLite → imported_layers tábla (metadata) // Supabase → Storage (megosztás, később) +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; @@ -13,6 +14,7 @@ import 'package:get/get.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:terepi_seged/enums/layer_import_source_type.dart'; +import 'package:terepi_seged/services/layer_sync_service.dart'; import 'package:uuid/uuid.dart'; import '../models/imported_layer.dart'; @@ -201,6 +203,23 @@ class LayerImportService extends GetxService { } } + /// Réteg megosztása a projekt csapatával (feltöltés a Storage-ba + + /// metaadat a terepi_seged_layers táblába). + Future publishLayer(String id) async { + if (!Get.isRegistered()) return; + final projectUuid = ProjectService.to.activeProject.value?.uuid; + if (projectUuid == null) { + throw Exception('Nincs aktív (megosztott) projekt.'); + } + + final metas = await AppDatabase.instance.listImportedLayers(); + final meta = metas.firstWhereOrNull((m) => m.id == id); + if (meta == null) throw Exception('A réteg nem található.'); + + await LayerSyncService.to + .publishLayer(layer: meta, projectUuid: projectUuid); + } + Future removeLayer(String id) async { final metas = await AppDatabase.instance.listImportedLayers(); final meta = metas.firstWhereOrNull((m) => m.id == id); @@ -259,7 +278,9 @@ class LayerImportService extends GetxService { return _parseGeoJson(String.fromCharCodes(bytes), name, id, isVisible: isVisible); case 'kml': - return _kmlParser.parseKml(String.fromCharCodes(bytes), name, + // return _kmlParser.parseKml(String.fromCharCodes(bytes), name, + // id: id, isVisible: isVisible); + return _kmlParser.parseKml(KmlParser.decodeText(bytes), name, id: id, isVisible: isVisible); case 'kmz': return _kmlParser.parseKmz(bytes, name, id: id, isVisible: isVisible); diff --git a/lib/services/layer_sync_service.dart b/lib/services/layer_sync_service.dart index 94a2752..1394226 100644 --- a/lib/services/layer_sync_service.dart +++ b/lib/services/layer_sync_service.dart @@ -4,6 +4,7 @@ import 'package:get/get.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:terepi_seged/services/app_logger.dart'; import 'package:terepi_seged/services/layer_import_service.dart'; import 'package:uuid/uuid.dart'; @@ -29,6 +30,7 @@ class LayerSyncService extends GetxService { static const bucket = 'terepi_seged-layers'; final isSyncing = false.obs; + final lastError = Rxn(); SupabaseClient get _client => Supabase.instance.client; AppDatabase get _db => AppDatabase.instance; @@ -55,10 +57,14 @@ class LayerSyncService extends GetxService { // Csak a szinkronizált (nem lokális) projektek rétegei érdekelnek. final projects = await _db.listProjects(); for (final project in projects.where((pr) => !pr.isLocalOnly)) { - await _pullProjectLayers(project.id!, project.uuid); + try { + await _pullProjectLayers(project.id!, project.uuid); + } catch (e) { + // EGY projekt rétegeinek hibája ne akassza meg a többi + // projektet (vagy a háttér-szinkron többi lépését). + lastError.value = 'Réteg-szinkron hiba (${project.name}): $e'; + } } - // Ha közben egy csapattárs frissített/megosztott egy réteget, ez - // jelenjen meg a térképen azonnal, újraindítás nélkül. if (Get.isRegistered()) { await LayerImportService.to.reload(); } @@ -77,34 +83,54 @@ class LayerSyncService extends GetxService { for (final r in rows) { final id = r['id'] as String; - final remoteVersion = (r['version'] as int?) ?? 1; - final localVersion = await _db.getImportedLayerVersion(id); + try { + final remoteVersion = (r['version'] as int?) ?? 1; + final localVersion = await _db.getImportedLayerVersion(id); - // Már megvan a legfrissebb verzió → nincs teendő. - if (localVersion >= remoteVersion) continue; + // Már megvan a legfrissebb verzió → nincs teendő. + if (localVersion >= remoteVersion) continue; - // Letöltés a Storage-ból, lokális cache-be. - final storagePath = r['storage_path'] as String; - final bytes = await _client.storage.from(bucket).download(storagePath); + // Letöltés a Storage-ból, lokális cache-be. + final storagePath = r['storage_path'] as String; + final bytes = await _client.storage.from(bucket).download(storagePath); - final dir = await _layerDir(); - final localPath = p.join(dir.path, '$id.geojson'); - await File(localPath).writeAsBytes(bytes, flush: true); + // A helyi fájl kiterjesztése kövesse a TÉNYLEGES forrás-típust — + // eddig mindig .geojson-nak mentette, ami a KML/KMZ rétegeket + // a GeoJSON-értelmezőre terelte (nyers zip-bájtok jsonDecode-ra). + final sourceType = (r['source_type'] as String?) ?? 'geojson'; + final ext = switch (sourceType) { + 'kml' => 'kml', + 'kmz' => 'kmz', + _ => 'geojson', + }; - // Metaadat upsert a meglévő imported_layers táblába — a térkép - // rétegkezelője innentől ugyanúgy látja, mint a kézi importot. - await _db.upsertImportedLayerRaw({ - 'id': id, - 'name': r['name'], - 'source_type': r['source_type'] ?? 'geojson', - 'local_path': localPath, - 'storage_path': storagePath, - 'is_visible': 1, - 'project_id': localProjectId, - 'imported_at': r['created_at'] ?? DateTime.now().toIso8601String(), - 'synced_at': DateTime.now().toIso8601String(), - 'version': remoteVersion, - }); + final dir = await _layerDir(); + final localPath = p.join(dir.path, '$id.$ext'); + await File(localPath).writeAsBytes(bytes, flush: true); + + // Metaadat upsert a meglévő imported_layers táblába — a térkép + // rétegkezelője innentől ugyanúgy látja, mint a kézi importot. + await _db.upsertImportedLayerRaw({ + 'id': id, + 'name': r['name'], + 'source_type': sourceType, + 'local_path': localPath, + 'storage_path': storagePath, + 'is_visible': 1, + 'project_id': localProjectId, + 'imported_at': r['created_at'] ?? DateTime.now().toIso8601String(), + 'synced_at': DateTime.now().toIso8601String(), + 'version': remoteVersion, + }); + } catch (e) { + // EGY réteg hibája (jogosultság, hiányzó storage-fájl, hálózat) + // ne akassza meg a TÖBBI réteg letöltését — csak ezt hagyjuk ki, + // a következő ciklus újrapróbálja (a verziószám nem lép, tehát + // nem "felejtődik el" a hiba, csak nem blokkol tovább). + lastError.value = 'Réteg letöltési hiba ($id): $e'; + print(lastError.value); + AppLogger.e('LAYER_SYNC', lastError.value!); + } } } diff --git a/lib/services/version_gate_service.dart b/lib/services/version_gate_service.dart new file mode 100644 index 0000000..eaa4e0f --- /dev/null +++ b/lib/services/version_gate_service.dart @@ -0,0 +1,74 @@ +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// Az app fallback csomagneve, ha a konfigurációs táblában nincs +/// (vagy még nem tölthető le) update_url. +const _fallbackUpdateUrl = + 'https://play.google.com/store/apps/details?id=hu.app_dev.terepi_seged'; + +class VersionGateResult { + final bool blocked; + final String message; + final String updateUrl; + + const VersionGateResult({ + required this.blocked, + this.message = '', + this.updateUrl = '', + }); + + static const allowed = VersionGateResult(blocked: false); +} + +/// App-indításkor lefutó minimum-build ellenőrzés. +/// +/// FONTOS — az app offline-first: ha a lekérdezés BÁRMI okból nem +/// teljesül (nincs net, időtúllépés, a tábla/kulcs még nem létezik, +/// bármilyen váratlan hiba), az ellenőrzés MINDIG átenged (fail-open). +/// Kizárólag akkor blokkol, ha a szervertől egyértelmű, jól formázott +/// választ kapott, hogy a jelenlegi build a minimum alatt van. +Future checkVersionGate() async { + try { + final rows = await Supabase.instance.client + .from('terepi_seged_app_config') + .select('key, value') + .inFilter('key', const [ + 'min_build_number', + 'update_message', + 'update_url', + ]).timeout(const Duration(seconds: 4)); + + final config = { + for (final r in rows) r['key'] as String: r['value'] as String, + }; + + final minBuild = int.tryParse(config['min_build_number'] ?? ''); + if (minBuild == null) { + // Nincs (érvényesen) beállítva minimum — nincs mit ellenőrizni. + return VersionGateResult.allowed; + } + + final info = await PackageInfo.fromPlatform(); + final currentBuild = int.tryParse(info.buildNumber); + if (currentBuild == null) { + // A saját build-számunkat sem sikerült megállapítani — ez a mi + // hibánk, nem a felhasználóé; nem blokkolunk emiatt. + return VersionGateResult.allowed; + } + + if (currentBuild < minBuild) { + return VersionGateResult( + blocked: true, + message: config['update_message'] ?? + 'Az alkalmazás egy régebbi verzióját használod, ami már nem ' + 'kompatibilis a szerverrel. Kérjük, telepítsd a legfrissebb ' + 'verziót a folytatáshoz.', + updateUrl: config['update_url'] ?? _fallbackUpdateUrl, + ); + } + return VersionGateResult.allowed; + } catch (_) { + // Hálózati hiba, timeout, hiányzó tábla stb. → MINDIG átenged. + return VersionGateResult.allowed; + } +} diff --git a/lib/widgets/map/imported_layer_overlay.dart b/lib/widgets/map/imported_layer_overlay.dart index 0423bad..f700fd3 100644 --- a/lib/widgets/map/imported_layer_overlay.dart +++ b/lib/widgets/map/imported_layer_overlay.dart @@ -7,6 +7,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:get/get.dart'; import 'package:latlong2/latlong.dart'; import 'package:terepi_seged/enums/layer_import_source_type.dart'; +import 'package:terepi_seged/services/project_service.dart'; import '../../models/imported_layer.dart'; import '../../services/layer_import_service.dart'; @@ -203,6 +204,15 @@ class _LayerTile extends StatelessWidget { constraints: const BoxConstraints(minWidth: 28, minHeight: 28), color: Colors.grey.shade500, ), + if (!(ProjectService.to.activeProject.value?.isLocalOnly ?? true)) + IconButton( + icon: const Icon(Icons.cloud_upload_outlined, size: 16), + onPressed: () => _publish(context, layer), + tooltip: 'Megosztás a csapattal', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + color: Colors.blue.shade400, + ), IconButton( icon: const Icon(Icons.palette_outlined, size: 16), onPressed: () => _openStyleSheet(context, layer), @@ -250,6 +260,17 @@ class _LayerTile extends StatelessWidget { ); } + Future _publish(BuildContext context, ImportedLayer layer) async { + try { + await LayerImportService.to.publishLayer(layer.id); + Get.snackbar('Megosztva', '${layer.name} elérhető a csapattársaknak.', + snackPosition: SnackPosition.BOTTOM); + } catch (e) { + Get.snackbar('Megosztási hiba', e.toString(), + snackPosition: SnackPosition.BOTTOM); + } + } + String _stats() { final parts = []; if (layer.markers.isNotEmpty) parts.add('${layer.markers.length} pont'); diff --git a/pubspec.yaml b/pubspec.yaml index 768e39b..0dd23d3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -84,6 +84,7 @@ dependencies: wakelock_plus: ^1.2.11 sensors_plus: ^7.1.0 url_launcher: ^6.3.2 + app_settings: ^7.0.0 flutter: sdk: flutter