mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
profile import from deeplinks
This commit is contained in:
11
README.md
11
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=<base64>` 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)
|
||||
|
||||
@@ -35,6 +35,14 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<!-- Profile import deep links -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="deflockapp" android:host="profiles"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- flutter_web_auth_2 callback activity (V2 embedding) -->
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<void> 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<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
|
||||
157
lib/services/deep_link_service.dart
Normal file
157
lib/services/deep_link_service.dart
Normal file
@@ -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<Uri>? _linkSubscription;
|
||||
|
||||
/// Initialize deep link handling (sets up stream listener only)
|
||||
Future<void> 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<void> 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=<base64>
|
||||
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<NavigatorState>? _navigatorKey;
|
||||
|
||||
/// Set the global navigator key
|
||||
void setNavigatorKey(GlobalKey<NavigatorState> navigatorKey) {
|
||||
_navigatorKey = navigatorKey;
|
||||
}
|
||||
|
||||
/// Clean up resources
|
||||
void dispose() {
|
||||
_linkSubscription?.cancel();
|
||||
}
|
||||
}
|
||||
120
lib/services/profile_import_service.dart
Normal file
120
lib/services/profile_import_service.dart
Normal file
@@ -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<String, dynamic>;
|
||||
|
||||
// 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<String, dynamic> 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<String, dynamic>) {
|
||||
debugPrint('[ProfileImportService] Profile tags must be a map');
|
||||
return null;
|
||||
}
|
||||
|
||||
final sanitizedTags = <String, String>{};
|
||||
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;
|
||||
}
|
||||
}
|
||||
40
pubspec.lock
40
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user