From 1873d6e768ab28914b90d5d6c45676484acfe898 Mon Sep 17 00:00:00 2001 From: stopflock Date: Wed, 28 Jan 2026 15:19:46 -0600 Subject: [PATCH] profile import from deeplinks --- README.md | 11 +- android/app/src/main/AndroidManifest.xml | 8 ++ assets/changelog.json | 6 + lib/app_state.dart | 6 + lib/main.dart | 10 ++ lib/services/deep_link_service.dart | 157 +++++++++++++++++++++++ lib/services/profile_import_service.dart | 120 +++++++++++++++++ pubspec.lock | 40 ++++++ pubspec.yaml | 3 +- 9 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 lib/services/deep_link_service.dart create mode 100644 lib/services/profile_import_service.dart diff --git a/README.md b/README.md index 3151486..b66fe6a 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,12 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with - **Queue management**: Review, edit, retry, or cancel pending uploads - **Changeset tracking**: Automatic grouping and commenting for organized contributions +### Profile Import & Sharing +- **Deep link support**: Import custom profiles via `deflockapp://profiles/add?p=` URLs +- **Website integration**: Generate profile import links from [deflock.me](https://deflock.me) +- **Pre-filled editor**: Imported profiles open in the profile editor for review and modification +- **Seamless workflow**: Edit imported profiles like any custom profile before saving + ### Offline Operations - **Smart area downloads**: Automatically calculate tile counts and storage requirements - **Device caching**: Offline areas include surveillance device data for complete functionality without network @@ -100,6 +106,8 @@ cp lib/keys.dart.example lib/keys.dart ### Needed Bugfixes - 360 FOV means no direction slider - Fix rendering of 0-360 FOV ring +- Move profile save button +- Fix iOS not taking FOV values, cannot remove FOV - Node data fetching super slow; retries not working? - Clean up tile cache; implement some max size or otherwise trim unused / old tiles to prevent infinite memory growth - Filter NSI suggestions based on what has already been typed in @@ -108,10 +116,9 @@ cp lib/keys.dart.example lib/keys.dart - Are offline areas preferred for fast loading even when online? Check working. ### Current Development -- Import profiles from app URL like deflockapp://addprofile?name=foo&tag1=value1 etc; submittable and FOV and requires direction are special cases - Add ability to downvote suspected locations which are old enough - Turn by turn navigation or at least swipe nav sheet up to see a list -- Import/Export map providers, profiles (profiles from deflock identify page?) +- Import/Export map providers ### On Pause - Offline navigation (pending vector map tiles) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1a19ce3..a127420 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -35,6 +35,14 @@ + + + + + + + + diff --git a/assets/changelog.json b/assets/changelog.json index 9b5230b..e4691ac 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,4 +1,10 @@ { + "2.4.0": { + "content": [ + "• Profile import from website links", + "• Visit deflock.me for profile links to auto-populate custom profiles" + ] + }, "2.3.1": { "content": [ "• Follow-me mode now automatically restores when add/edit/tag sheets are closed", diff --git a/lib/app_state.dart b/lib/app_state.dart index 1f613b8..0f517d4 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -18,6 +18,7 @@ import 'services/node_cache.dart'; import 'services/tile_preview_service.dart'; import 'services/changelog_service.dart'; import 'services/operator_profile_service.dart'; +import 'services/deep_link_service.dart'; import 'widgets/node_provider_with_cache.dart'; import 'services/profile_service.dart'; import 'widgets/proximity_warning_dialog.dart'; @@ -244,6 +245,11 @@ class AppState extends ChangeNotifier { _isInitialized = true; + // Check for initial deep link after a small delay to let navigation settle + Future.delayed(const Duration(milliseconds: 500), () { + DeepLinkService().checkInitialLink(); + }); + // Start periodic message checking _startMessageCheckTimer(); diff --git a/lib/main.dart b/lib/main.dart index 0adf3a0..9bd2d56 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'screens/osm_account_screen.dart'; import 'screens/upload_queue_screen.dart'; import 'services/localization_service.dart'; import 'services/version_service.dart'; +import 'services/deep_link_service.dart'; @@ -27,6 +28,10 @@ Future main() async { // Initialize localization service await LocalizationService.instance.init(); + // Initialize deep link service + await DeepLinkService().init(); + DeepLinkService().setNavigatorKey(_navigatorKey); + runApp( ChangeNotifierProvider( create: (_) => AppState(), @@ -68,6 +73,7 @@ class DeFlockApp extends StatelessWidget { ), useMaterial3: true, ), + navigatorKey: _navigatorKey, routes: { '/': (context) => const HomeScreen(), '/settings': (context) => const SettingsScreen(), @@ -82,7 +88,11 @@ class DeFlockApp extends StatelessWidget { '/settings/release-notes': (context) => const ReleaseNotesScreen(), }, initialRoute: '/', + ); } } +// Global navigator key for deep link navigation +final GlobalKey _navigatorKey = GlobalKey(); + diff --git a/lib/services/deep_link_service.dart b/lib/services/deep_link_service.dart new file mode 100644 index 0000000..ca57599 --- /dev/null +++ b/lib/services/deep_link_service.dart @@ -0,0 +1,157 @@ +import 'dart:async'; +import 'package:app_links/app_links.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; + +import '../models/node_profile.dart'; +import 'profile_import_service.dart'; +import '../screens/profile_editor.dart'; + +class DeepLinkService { + static final DeepLinkService _instance = DeepLinkService._internal(); + factory DeepLinkService() => _instance; + DeepLinkService._internal(); + + late AppLinks _appLinks; + StreamSubscription? _linkSubscription; + + /// Initialize deep link handling (sets up stream listener only) + Future init() async { + _appLinks = AppLinks(); + + // Set up stream listener for links when app is already running + _linkSubscription = _appLinks.uriLinkStream.listen( + _processLink, + onError: (err) { + debugPrint('[DeepLinkService] Link stream error: $err'); + }, + ); + } + + /// Process a deep link + void _processLink(Uri uri) { + debugPrint('[DeepLinkService] Processing deep link: $uri'); + + // Only handle deflockapp scheme + if (uri.scheme != 'deflockapp') { + debugPrint('[DeepLinkService] Ignoring non-deflockapp scheme: ${uri.scheme}'); + return; + } + + // Route based on path + switch (uri.host) { + case 'profiles': + _handleProfilesLink(uri); + break; + case 'auth': + // OAuth links are handled by flutter_web_auth_2 + debugPrint('[DeepLinkService] OAuth link handled by flutter_web_auth_2'); + break; + default: + debugPrint('[DeepLinkService] Unknown deep link host: ${uri.host}'); + } + } + + /// Check for initial link after app is fully ready + Future checkInitialLink() async { + debugPrint('[DeepLinkService] Checking for initial link...'); + + try { + final initialLink = await _appLinks.getInitialLink(); + if (initialLink != null) { + debugPrint('[DeepLinkService] Found initial link: $initialLink'); + _processLink(initialLink); + } else { + debugPrint('[DeepLinkService] No initial link found'); + } + } catch (e) { + debugPrint('[DeepLinkService] Failed to get initial link: $e'); + } + } + + /// Handle profile-related deep links + void _handleProfilesLink(Uri uri) { + final segments = uri.pathSegments; + + if (segments.isEmpty) { + debugPrint('[DeepLinkService] No path segments in profiles link'); + return; + } + + switch (segments[0]) { + case 'add': + _handleAddProfileLink(uri); + break; + default: + debugPrint('[DeepLinkService] Unknown profiles path: ${segments[0]}'); + } + } + + /// Handle profile add deep link: deflockapp://profiles/add?p= + void _handleAddProfileLink(Uri uri) { + final base64Data = uri.queryParameters['p']; + + if (base64Data == null || base64Data.isEmpty) { + _showError('Invalid profile link: missing profile data'); + return; + } + + // Parse profile from base64 + final profile = ProfileImportService.parseProfileFromBase64(base64Data); + + if (profile == null) { + _showError('Invalid profile data'); + return; + } + + // Navigate to profile editor with the imported profile + _navigateToProfileEditor(profile); + } + + /// Navigate to profile editor with pre-filled profile data + void _navigateToProfileEditor(NodeProfile profile) { + final context = _navigatorKey?.currentContext; + + if (context == null) { + debugPrint('[DeepLinkService] No navigator context available'); + return; + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProfileEditor(profile: profile), + ), + ); + } + + /// Show error message to user + void _showError(String message) { + final context = _navigatorKey?.currentContext; + + if (context == null) { + debugPrint('[DeepLinkService] Error (no context): $message'); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + ), + ); + } + + /// Global navigator key for navigation + GlobalKey? _navigatorKey; + + /// Set the global navigator key + void setNavigatorKey(GlobalKey navigatorKey) { + _navigatorKey = navigatorKey; + } + + /// Clean up resources + void dispose() { + _linkSubscription?.cancel(); + } +} \ No newline at end of file diff --git a/lib/services/profile_import_service.dart b/lib/services/profile_import_service.dart new file mode 100644 index 0000000..2ecac87 --- /dev/null +++ b/lib/services/profile_import_service.dart @@ -0,0 +1,120 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/node_profile.dart'; + +class ProfileImportService { + // Maximum size for base64 encoded profile data (approx 50KB decoded) + static const int maxBase64Length = 70000; + + /// Parse and validate a profile from a base64-encoded JSON string + /// Returns null if parsing/validation fails + static NodeProfile? parseProfileFromBase64(String base64Data) { + try { + // Basic size validation before expensive decode + if (base64Data.length > maxBase64Length) { + debugPrint('[ProfileImportService] Base64 data too large: ${base64Data.length} characters'); + return null; + } + + // Decode base64 + final jsonBytes = base64Decode(base64Data); + final jsonString = utf8.decode(jsonBytes); + + // Parse JSON + final jsonData = jsonDecode(jsonString) as Map; + + // Validate and sanitize the profile data + final sanitizedProfile = _validateAndSanitizeProfile(jsonData); + return sanitizedProfile; + + } catch (e) { + debugPrint('[ProfileImportService] Failed to parse profile from base64: $e'); + return null; + } + } + + /// Validate profile structure and sanitize all string values + static NodeProfile? _validateAndSanitizeProfile(Map data) { + try { + // Extract and sanitize required fields + final name = _sanitizeString(data['name']); + if (name == null || name.isEmpty) { + debugPrint('[ProfileImportService] Profile name is required'); + return null; + } + + // Extract and sanitize tags + final tagsData = data['tags']; + if (tagsData is! Map) { + debugPrint('[ProfileImportService] Profile tags must be a map'); + return null; + } + + final sanitizedTags = {}; + for (final entry in tagsData.entries) { + final key = _sanitizeString(entry.key); + final value = _sanitizeString(entry.value); + + if (key != null && key.isNotEmpty) { + // Allow empty values for refinement purposes + sanitizedTags[key] = value ?? ''; + } + } + + if (sanitizedTags.isEmpty) { + debugPrint('[ProfileImportService] Profile must have at least one valid tag'); + return null; + } + + // Extract optional fields with defaults + final requiresDirection = data['requiresDirection'] ?? true; + final submittable = data['submittable'] ?? true; + + // Parse FOV if provided + double? fov; + if (data['fov'] != null) { + if (data['fov'] is num) { + final fovValue = (data['fov'] as num).toDouble(); + if (fovValue > 0 && fovValue <= 360) { + fov = fovValue; + } + } + } + + return NodeProfile( + id: const Uuid().v4(), // Always generate new ID for imported profiles + name: name, + tags: sanitizedTags, + builtin: false, // Imported profiles are always custom + requiresDirection: requiresDirection is bool ? requiresDirection : true, + submittable: submittable is bool ? submittable : true, + editable: true, // Imported profiles are always editable + fov: fov, + ); + + } catch (e) { + debugPrint('[ProfileImportService] Failed to validate profile: $e'); + return null; + } + } + + /// Sanitize a string value by trimming and removing potentially harmful characters + static String? _sanitizeString(dynamic value) { + if (value == null) return null; + + final str = value.toString().trim(); + + // Remove control characters and limit length + final sanitized = str.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), ''); + + // Limit length to prevent abuse + const maxLength = 500; + if (sanitized.length > maxLength) { + return sanitized.substring(0, maxLength); + } + + return sanitized; + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index b1cd2a6..723b491 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: transitive description: @@ -347,6 +379,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" html: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8c4b28d..5dcf1b7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 2.3.1+38 # The thing after the + is the version code, incremented with each release +version: 2.4.0+39 # The thing after the + is the version code, incremented with each release environment: sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+ @@ -22,6 +22,7 @@ dependencies: flutter_local_notifications: ^17.2.2 url_launcher: ^6.3.0 flutter_linkify: ^6.0.0 + app_links: ^6.1.4 # Auth, storage, prefs oauth2_client: ^4.2.0