From 37408eb57daf20e37a83b2154f82c85e079af2cd Mon Sep 17 00:00:00 2001 From: maelemiel Date: Tue, 28 Oct 2025 15:40:41 +0100 Subject: [PATCH 01/31] feat(AndroidManifest): add HTTP deep link for OAuth2 callbacks in emulator --- mobile/android/app/src/main/AndroidManifest.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 8e52789c..13284576 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -46,6 +46,18 @@ + + + + + + + + + + + + From 75403a666e5cbde60f3f6122e3419a5821473876 Mon Sep 17 00:00:00 2001 From: maelemiel Date: Tue, 28 Oct 2025 15:40:46 +0100 Subject: [PATCH 02/31] feat(compatibility_rules): add endpoint to retrieve action-reaction compatibility rules --- backend/automations/urls.py | 5 +++++ backend/automations/views.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/backend/automations/urls.py b/backend/automations/urls.py index e0798908..6e7a83cd 100755 --- a/backend/automations/urls.py +++ b/backend/automations/urls.py @@ -73,4 +73,9 @@ views.DebugExecutionsView.as_view({"get": "list"}), name="debug-executions", ), + path( + "api/compatibility-rules/", + views.CompatibilityRulesView.as_view({"get": "list"}), + name="compatibility-rules", + ), ] diff --git a/backend/automations/views.py b/backend/automations/views.py index cfd26988..1d26c79d 100755 --- a/backend/automations/views.py +++ b/backend/automations/views.py @@ -613,3 +613,28 @@ def list(self, request, area_id=None): {"error": "Area not found or access denied"}, status=status.HTTP_404_NOT_FOUND, ) + + +# Compatibility Rules View +# ============================================================================ + + +@extend_schema( + tags=["Compatibility"], + summary="Get action-reaction compatibility rules", + description="Returns the compatibility rules mapping actions to compatible reactions. Public endpoint.", +) +class CompatibilityRulesView(viewsets.ViewSet): + """Returns compatibility rules for action-reaction combinations.""" + + permission_classes = [permissions.AllowAny] + + def list(self, request): + """ + Get all compatibility rules. + + GET /api/compatibility-rules/ + """ + from .validators import COMPATIBILITY_RULES + + return Response(COMPATIBILITY_RULES, status=status.HTTP_200_OK) From 4921a53e4d0822f00075c8e8e99028bb572e6e02 Mon Sep 17 00:00:00 2001 From: maelemiel Date: Tue, 28 Oct 2025 18:30:26 +0100 Subject: [PATCH 03/31] feat(about_service): add requires_oauth field to indicate OAuth authentication necessity --- backend/automations/serializers.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/backend/automations/serializers.py b/backend/automations/serializers.py index 943cdce1..0af5d568 100755 --- a/backend/automations/serializers.py +++ b/backend/automations/serializers.py @@ -392,10 +392,35 @@ class AboutServiceSerializer(serializers.ModelSerializer): actions = AboutActionSerializer(many=True, read_only=True) reactions = AboutReactionSerializer(many=True, read_only=True) + requires_oauth = serializers.SerializerMethodField() class Meta: model = Service - fields = ["name", "actions", "reactions"] + fields = ["name", "requires_oauth", "actions", "reactions"] + + def get_requires_oauth(self, obj): + """Check if this service requires OAuth authentication. + + Services that require OAuth: + - Direct OAuth providers (google, github, slack, spotify, twitch) + - Services that map to OAuth tokens (gmail->google, google_calendar->google) + """ + from django.conf import settings + + oauth_names = settings.OAUTH2_PROVIDERS.keys() + + # Direct OAuth providers + if obj.name in oauth_names: + return True + + # Services that map to OAuth tokens + service_oauth_map = { + 'gmail': 'google', + 'google_calendar': 'google', + } + + mapped_oauth = service_oauth_map.get(obj.name) + return mapped_oauth in oauth_names if mapped_oauth else False # Serializers pour Execution (journaling) From 9852e010fd968b3e35e3dc8226ddb0e04be4ff42 Mon Sep 17 00:00:00 2001 From: maelemiel Date: Tue, 28 Oct 2025 18:44:41 +0100 Subject: [PATCH 04/31] feat: Add OAuth requirement to Service model and related components - Updated Service model to include `requiresOAuth` field. - Modified Service serialization and deserialization to handle `requiresOAuth`. - Adjusted ServiceConnectionsPage to sort and filter services based on OAuth requirement. - Enhanced CreateAppletPage to reset action and reaction configurations. - Introduced CompatibilityProvider for managing action-reaction compatibility rules. - Updated ConnectedServicesProvider to map service names to their OAuth tokens. - Refactored OAuthService to improve error handling and connection logic. - Implemented ServiceTokenMapper for resolving service names to OAuth tokens. - Enhanced ActionConfigCard and ServiceActionSelectorCard to filter reactions based on compatibility. - Updated tests to cover new `requiresOAuth` functionality in Service and related components. --- .../lib/config/service_provider_config.dart | 19 +- mobile/lib/main.dart | 38 +- mobile/lib/models/service.dart | 4 + mobile/lib/pages/create_applet_page.dart | 7 +- .../lib/pages/service_connections_page.dart | 19 +- .../lib/providers/compatibility_provider.dart | 108 ++++ .../connected_services_provider.dart | 5 +- mobile/lib/services/oauth_service.dart | 96 ++-- .../lib/services/service_catalog_service.dart | 1 + mobile/lib/utils/oauth_deep_link_handler.dart | 189 ++++--- mobile/lib/utils/service_token_mapper.dart | 35 ++ .../create_applet/action_config_card.dart | 3 + .../service_action_selector_card.dart | 502 ++++++++++-------- mobile/test/service_card_test.dart | 33 +- .../test/service_catalog_provider_test.dart | 1 + mobile/test/service_models_test.dart | 20 +- 16 files changed, 700 insertions(+), 380 deletions(-) create mode 100644 mobile/lib/providers/compatibility_provider.dart create mode 100644 mobile/lib/utils/service_token_mapper.dart diff --git a/mobile/lib/config/service_provider_config.dart b/mobile/lib/config/service_provider_config.dart index edd6d800..d5c52116 100644 --- a/mobile/lib/config/service_provider_config.dart +++ b/mobile/lib/config/service_provider_config.dart @@ -1,3 +1,5 @@ +import '../utils/service_token_mapper.dart'; + /// Service Provider Configuration /// /// Centralized configuration for all OAuth service providers (Google, GitHub, etc.) @@ -6,25 +8,22 @@ class ServiceProviderConfig { 'github', 'gmail', 'google', + 'google_calendar', 'slack', + 'spotify', 'twitch', ]; static bool requiresOAuth(String serviceName) { - // Map gmail to google for OAuth purposes + // Map gmail/google_calendar to google for OAuth purposes final mappedName = mapServiceName(serviceName); return oauthServices.contains(mappedName.toLowerCase()); } - /// Map service names for consistency (gmail -> google) + /// Map service names for consistency (gmail/google_calendar -> google) /// This is used to normalize service names across the application static String mapServiceName(String serviceName) { - switch (serviceName.toLowerCase()) { - case 'gmail': - return 'google'; - default: - return serviceName; - } + return ServiceTokenMapper.resolveTokenService(serviceName); } /// Get user-friendly provider name @@ -37,6 +36,8 @@ class ServiceProviderConfig { return 'GitHub'; case 'slack': return 'Slack'; + case 'spotify': + return 'Spotify'; case 'twitch': return 'Twitch'; default: @@ -54,6 +55,8 @@ class ServiceProviderConfig { return 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/256px-Google_2015_logo.svg.png'; case 'slack': return 'https://cdn-icons-png.flaticon.com/512/2111/2111615.png'; + case 'spotify': + return 'https://cdn-icons-png.flaticon.com/512/2111/2111624.png'; case 'twitch': return 'https://cdn-icons-png.flaticon.com/512/5968/5968819.png'; case 'timer': diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index efc8be5e..bb3965ad 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'dart:async'; import 'package:app_links/app_links.dart'; import 'providers/providers.dart'; +import 'providers/compatibility_provider.dart'; import 'pages/splash_page.dart'; import 'pages/login_page.dart'; import 'pages/reset_password_page.dart'; @@ -49,6 +50,9 @@ class _MyAppState extends State { bool _initialLinkHandled = false; late OAuthDeepLinkHandler _oauthHandler; + Uri? _lastHandledUri; + DateTime? _lastHandledTime; + @override void initState() { super.initState(); @@ -56,7 +60,6 @@ class _MyAppState extends State { _oauthHandler = OAuthDeepLinkHandler(); _oauthHandler.onOAuthComplete = _handleOAuthComplete; _initDeepLinkListener(); - _initOAuthHandler(); } @override @@ -66,10 +69,6 @@ class _MyAppState extends State { super.dispose(); } - Future _initOAuthHandler() async { - await _oauthHandler.initialize(); - } - void _handleOAuthComplete(String provider, bool success, String? message) { WidgetsBinding.instance.addPostFrameCallback((_) { final context = navigatorKey.currentContext; @@ -116,6 +115,22 @@ class _MyAppState extends State { void _handleDeepLink(Uri uri) { debugPrint('Handling deep link: $uri'); + final now = DateTime.now(); + if (_lastHandledUri?.toString() == uri.toString() && + _lastHandledTime != null && + now.difference(_lastHandledTime!).inSeconds < 2) { + debugPrint('Duplicate deep link ignored (same URI within 2 seconds)'); + return; + } + + _lastHandledUri = uri; + _lastHandledTime = now; + + if (_oauthHandler.isOAuthCallback(uri)) { + _oauthHandler.handleDeepLink(uri); + return; + } + if (uri.scheme == AppConfig.urlScheme && uri.host == AppConfig.resetPasswordDeepLink) { final token = uri.queryParameters['token']; @@ -146,6 +161,7 @@ class _MyAppState extends State { ChangeNotifierProvider(create: (_) => ServiceCatalogProvider()), ChangeNotifierProvider(create: (_) => ConnectedServicesProvider()), ChangeNotifierProvider(create: (_) => AutomationStatsProvider()), + ChangeNotifierProvider(create: (_) => CompatibilityProvider()), ], child: Builder( builder: (context) { @@ -167,6 +183,18 @@ class _MyAppState extends State { } }; + // Load compatibility rules when app starts + WidgetsBinding.instance.addPostFrameCallback((_) { + final compatibilityProvider = Provider.of( + context, + listen: false, + ); + if (!compatibilityProvider.isLoaded && + !compatibilityProvider.isLoading) { + compatibilityProvider.loadRules(); + } + }); + return MaterialApp( navigatorKey: navigatorKey, title: '${AppConfig.appName} Mobile', diff --git a/mobile/lib/models/service.dart b/mobile/lib/models/service.dart index a45d7f34..d86e73d8 100644 --- a/mobile/lib/models/service.dart +++ b/mobile/lib/models/service.dart @@ -4,12 +4,14 @@ import '../config/service_provider_config.dart'; class Service { final int id; final String name; + final bool requiresOAuth; final List actions; final List reactions; Service({ required this.id, required this.name, + required this.requiresOAuth, required this.actions, required this.reactions, }); @@ -18,6 +20,7 @@ class Service { return Service( id: json['id'] as int? ?? 0, name: (json['name'] as String?)?.trim() ?? '', + requiresOAuth: (json['requires_oauth'] as bool?) ?? false, actions: (json['actions'] as List?) ?.map( @@ -41,6 +44,7 @@ class Service { return { 'id': id, 'name': name, + 'requires_oauth': requiresOAuth, 'actions': actions.map((action) => action.toJson()).toList(), 'reactions': reactions.map((reaction) => reaction.toJson()).toList(), }; diff --git a/mobile/lib/pages/create_applet_page.dart b/mobile/lib/pages/create_applet_page.dart index 707a3598..517295b8 100644 --- a/mobile/lib/pages/create_applet_page.dart +++ b/mobile/lib/pages/create_applet_page.dart @@ -153,6 +153,7 @@ class _CreateAppletPageState extends State { _selectedActionId = context .read() .getActionId(value!); + _actionConfig = {}; }); }, ), @@ -165,6 +166,7 @@ class _CreateAppletPageState extends State { ActionConfigCard( selectedService: _selectedActionService, selectedReaction: _selectedActionReaction, + selectedTriggerAction: _selectedTriggerAction, onServiceChanged: (value) { setState(() { _selectedActionService = value; @@ -179,6 +181,7 @@ class _CreateAppletPageState extends State { _selectedReactionId = context .read() .getReactionId(value!); + _reactionConfig = {}; }); }, ), @@ -339,7 +342,7 @@ class _CreateAppletPageState extends State { 'Automation "${applet.name}" created successfully!', ); - // Reset form + // Reset form completely _nameController.clear(); setState(() { _selectedTriggerService = null; @@ -348,6 +351,8 @@ class _CreateAppletPageState extends State { _selectedActionReaction = null; _selectedActionId = null; _selectedReactionId = null; + _actionConfig = {}; + _reactionConfig = {}; }); _formKey.currentState?.reset(); diff --git a/mobile/lib/pages/service_connections_page.dart b/mobile/lib/pages/service_connections_page.dart index 03ae885f..0ea1c20e 100644 --- a/mobile/lib/pages/service_connections_page.dart +++ b/mobile/lib/pages/service_connections_page.dart @@ -245,11 +245,8 @@ class _ServiceConnectionsPageState extends State { final sortedServices = [...allServices] ..sort((a, b) { - final aRequiresOAuth = ServiceProviderConfig.requiresOAuth(a.name); - final bRequiresOAuth = ServiceProviderConfig.requiresOAuth(b.name); - - if (aRequiresOAuth && !bRequiresOAuth) return -1; - if (!aRequiresOAuth && bRequiresOAuth) return 1; + if (a.requiresOAuth && !b.requiresOAuth) return -1; + if (!a.requiresOAuth && b.requiresOAuth) return 1; return a.displayName.compareTo(b.displayName); }); @@ -268,10 +265,7 @@ class _ServiceConnectionsPageState extends State { ), const SizedBox(height: 12), ...sortedServices - .where( - (service) => - ServiceProviderConfig.requiresOAuth(service.name), - ) + .where((service) => service.requiresOAuth) .map((service) => _buildServiceCard(service)), const SizedBox(height: 24), @@ -286,10 +280,7 @@ class _ServiceConnectionsPageState extends State { ), const SizedBox(height: 12), ...sortedServices - .where( - (service) => - !ServiceProviderConfig.requiresOAuth(service.name), - ) + .where((service) => !service.requiresOAuth) .map((service) => _buildServiceCard(service)), ], ), @@ -299,7 +290,7 @@ class _ServiceConnectionsPageState extends State { } Widget _buildServiceCard(Service service) { - final requiresOAuth = ServiceProviderConfig.requiresOAuth(service.name); + final requiresOAuth = service.requiresOAuth; final isConnected = _isServiceConnected(service.name); return Card( diff --git a/mobile/lib/providers/compatibility_provider.dart b/mobile/lib/providers/compatibility_provider.dart new file mode 100644 index 00000000..dcf22eb8 --- /dev/null +++ b/mobile/lib/providers/compatibility_provider.dart @@ -0,0 +1,108 @@ +import 'package:flutter/foundation.dart'; +import 'dart:convert'; +import '../services/http_client_service.dart'; + +/// Provider for managing action-reaction compatibility rules +/// Loads rules dynamically from the backend on first use and caches them +class CompatibilityProvider extends ChangeNotifier { + Map> _rules = {}; + bool _isLoaded = false; + bool _isLoading = false; + String? _error; + + final HttpClientService _httpClient = HttpClientService(); + + /// Get all compatibility rules + Map> get rules => _rules; + + /// Check if rules are loaded + bool get isLoaded => _isLoaded; + + /// Check if rules are currently loading + bool get isLoading => _isLoading; + + /// Get any error message + String? get error => _error; + + /// Initialize and load rules from backend (can be called multiple times safely) + Future loadRules() async { + if (_isLoaded) { + debugPrint('[CompatibilityProvider] Rules already loaded, skipping'); + return; + } + + if (_isLoading) { + debugPrint('[CompatibilityProvider] Rules already loading, skipping'); + return; + } + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + debugPrint('[CompatibilityProvider] Fetching rules from backend...'); + final response = await _httpClient.get('/api/compatibility-rules/'); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + + _rules = {}; + data.forEach((key, value) { + if (value is List) { + _rules[key] = List.from(value); + } + }); + + _isLoaded = true; + debugPrint( + '[CompatibilityProvider] Loaded ${_rules.length} compatibility rules from backend', + ); + } else { + throw Exception( + 'Failed to load compatibility rules: ${response.statusCode}', + ); + } + } catch (e) { + _error = 'Failed to load compatibility rules: $e'; + debugPrint('[CompatibilityProvider] Error: $_error'); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// Get compatible reactions for an action + List getCompatibleReactions( + String actionName, + List allReactions, + ) { + final compatible = _rules[actionName] ?? []; + + // If "*" is in the rules, return all reactions + if (compatible.contains("*")) { + return allReactions; + } + + // Otherwise filter to only compatible ones + return allReactions + .where((reaction) => compatible.contains(reaction)) + .toList(); + } + + /// Check if a reaction is compatible with an action + bool isCompatible(String actionName, String reactionName) { + final compatible = _rules[actionName] ?? []; + if (compatible.contains("*")) return true; + return compatible.contains(reactionName); + } + + /// Refresh rules from backend + Future refreshRules() async { + _isLoaded = false; + _rules = {}; + _error = null; + await loadRules(); + debugPrint('[CompatibilityProvider] Rules refreshed'); + } +} diff --git a/mobile/lib/providers/connected_services_provider.dart b/mobile/lib/providers/connected_services_provider.dart index 1c074915..26f8c95b 100644 --- a/mobile/lib/providers/connected_services_provider.dart +++ b/mobile/lib/providers/connected_services_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../services/oauth_service.dart'; import '../models/service_token.dart'; +import '../utils/service_token_mapper.dart'; /// Provider for managing connected OAuth services class ConnectedServicesProvider extends ChangeNotifier { @@ -17,9 +18,11 @@ class ConnectedServicesProvider extends ChangeNotifier { String? get error => _error; /// Check if a specific service is connected + /// Maps service names (e.g., gmail -> google) to find the actual token bool isServiceConnected(String serviceName) { + final tokenName = ServiceTokenMapper.resolveTokenService(serviceName); return _connectedServices.any( - (s) => s.serviceName.toLowerCase() == serviceName.toLowerCase(), + (s) => s.serviceName.toLowerCase() == tokenName.toLowerCase(), ); } diff --git a/mobile/lib/services/oauth_service.dart b/mobile/lib/services/oauth_service.dart index 11b922cd..201b384a 100644 --- a/mobile/lib/services/oauth_service.dart +++ b/mobile/lib/services/oauth_service.dart @@ -1,11 +1,13 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../config/api_config.dart'; import '../models/service_token.dart'; import '../models/connection_history.dart'; +import '../utils/service_token_mapper.dart'; import 'token_service.dart'; -/// Custom exception for OAuth errors class OAuthException implements Exception { final String message; final int? statusCode; @@ -16,21 +18,13 @@ class OAuthException implements Exception { String toString() => message; } -/// Service responsible for OAuth2 operations class OAuthService { final TokenService _tokenService = TokenService(); - // Singleton pattern static final OAuthService _instance = OAuthService._internal(); factory OAuthService() => _instance; OAuthService._internal(); - // ============================================ - // OAUTH2 INITIATION - // ============================================ - - /// Initiate OAuth2 flow for a provider - /// Returns the authorization URL to open in browser Future initiateOAuth(String provider) async { try { final token = await _tokenService.getAuthToken(); @@ -59,11 +53,6 @@ class OAuthService { } } - // ============================================ - // OAUTH2 CALLBACK HANDLING - // ============================================ - - /// Handle OAuth2 callback with code and state Future> handleOAuthCallback({ required String provider, required String code, @@ -75,33 +64,59 @@ class OAuthService { throw OAuthException('User not authenticated'); } - final response = await http.get( - Uri.parse( - ApiConfig.oauthCallbackUrl(provider, code: code, state: state), - ), - headers: { - 'Authorization': 'Bearer $token', - 'Content-Type': 'application/json', - }, + final callbackUrl = ApiConfig.oauthCallbackUrl( + provider, + code: code, + state: state, ); + debugPrint('[OAUTH] Making callback request to: $callbackUrl'); + + try { + final response = await http + .get( + Uri.parse(callbackUrl), + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }, + ) + .timeout(const Duration(seconds: 15)); + + debugPrint('[OAUTH] Got response status: ${response.statusCode}'); + + if (response.statusCode == 302 || response.statusCode == 301) { + debugPrint('[OAUTH] Got redirect (302/301) - treating as success'); + return { + 'message': 'Successfully connected to $provider', + 'provider': provider, + }; + } - if (response.statusCode == 200) { - return json.decode(response.body); - } + if (response.statusCode == 200) { + return json.decode(response.body); + } - final errorMessage = _parseErrorResponse(response); - throw OAuthException(errorMessage, statusCode: response.statusCode); + final errorMessage = _parseErrorResponse(response); + throw OAuthException(errorMessage, statusCode: response.statusCode); + } on SocketException catch (e) { + if (e.message.contains('Connection refused') || + e.message.contains('localhost')) { + debugPrint( + '[OAUTH] Got connection refused (expected from redirect attempt) - treating as success', + ); + return { + 'message': 'Successfully connected to $provider', + 'provider': provider, + }; + } + rethrow; + } } catch (e) { if (e is OAuthException) rethrow; throw OAuthException('Failed to complete OAuth: ${e.toString()}'); } } - // ============================================ - // SERVICE MANAGEMENT - // ============================================ - - /// Get list of connected services Future getConnectedServices() async { try { final token = await _tokenService.getAuthToken(); @@ -130,7 +145,6 @@ class OAuthService { } } - /// Get connection history for troubleshooting Future getConnectionHistory({int limit = 20}) async { try { final token = await _tokenService.getAuthToken(); @@ -161,7 +175,6 @@ class OAuthService { } } - /// Disconnect a service Future> disconnectService(String provider) async { try { final token = await _tokenService.getAuthToken(); @@ -189,24 +202,24 @@ class OAuthService { } } - /// Check if a specific service is connected Future isServiceConnected(String provider) async { try { final services = await getConnectedServices(); + final tokenName = ServiceTokenMapper.resolveTokenService(provider); return services.connectedServices.any( - (s) => s.serviceName.toLowerCase() == provider.toLowerCase(), + (s) => s.serviceName.toLowerCase() == tokenName.toLowerCase(), ); } catch (e) { return false; } } - /// Get token info for a specific service Future getServiceToken(String provider) async { try { final services = await getConnectedServices(); + final tokenName = ServiceTokenMapper.resolveTokenService(provider); final matchingServices = services.connectedServices.where( - (s) => s.serviceName.toLowerCase() == provider.toLowerCase(), + (s) => s.serviceName.toLowerCase() == tokenName.toLowerCase(), ); return matchingServices.isNotEmpty ? matchingServices.first : null; } catch (e) { @@ -214,17 +227,11 @@ class OAuthService { } } - // ============================================ - // HELPER METHODS - // ============================================ - - /// Parse error response from API String _parseErrorResponse(http.Response response) { try { final data = json.decode(response.body); if (data is Map) { - // Try to get error message from various possible fields if (data.containsKey('message')) { return data['message'] as String; } @@ -239,7 +246,6 @@ class OAuthService { return data['detail'] as String; } - // If it's a map of field errors, combine them final errors = []; data.forEach((key, value) { if (value is List) { diff --git a/mobile/lib/services/service_catalog_service.dart b/mobile/lib/services/service_catalog_service.dart index b13dbeb2..6e2bc817 100644 --- a/mobile/lib/services/service_catalog_service.dart +++ b/mobile/lib/services/service_catalog_service.dart @@ -102,6 +102,7 @@ class ServiceCatalogService { return Service( id: serviceId, name: serviceName, + requiresOAuth: (serviceJson['requires_oauth'] as bool?) ?? false, actions: actionsByService[serviceId] ?? [], reactions: reactionsByService[serviceId] ?? [], ); diff --git a/mobile/lib/utils/oauth_deep_link_handler.dart b/mobile/lib/utils/oauth_deep_link_handler.dart index 30be8151..0c70b213 100644 --- a/mobile/lib/utils/oauth_deep_link_handler.dart +++ b/mobile/lib/utils/oauth_deep_link_handler.dart @@ -1,105 +1,146 @@ import 'dart:async'; -import 'package:app_links/app_links.dart'; import 'package:flutter/foundation.dart'; import '../services/oauth_service.dart'; import '../config/app_config.dart'; -/// Handler for OAuth2 deep link callbacks class OAuthDeepLinkHandler { final OAuthService _oauthService = OAuthService(); - final _appLinks = AppLinks(); + final Set _processedStates = {}; - StreamSubscription? _linkSubscription; - - // Callback when OAuth is completed Function(String provider, bool success, String? message)? onOAuthComplete; - // Singleton pattern static final OAuthDeepLinkHandler _instance = OAuthDeepLinkHandler._internal(); factory OAuthDeepLinkHandler() => _instance; OAuthDeepLinkHandler._internal(); - /// Initialize deep link listening - Future initialize() async { - // Handle initial link if app was opened from a link - try { - final initialUri = await _appLinks.getInitialLink(); - if (initialUri != null) { - await _handleDeepLink(initialUri); - } - } catch (e) { - debugPrint('Error handling initial link: $e'); + bool isOAuthCallback(Uri uri) { + if (uri.scheme == AppConfig.urlScheme && uri.host == AppConfig.authPrefix) { + final pathSegments = uri.pathSegments; + return pathSegments.length >= 3 && + pathSegments[0] == AppConfig.oauthPath && + pathSegments[2] == AppConfig.callbackPath; + } else if (uri.scheme == 'http' || uri.scheme == 'https') { + final path = uri.path; + final pathSegments = path.split('/').where((s) => s.isNotEmpty).toList(); + return pathSegments.length >= 4 && + pathSegments[0] == 'auth' && + pathSegments[1] == 'oauth' && + pathSegments[3] == 'callback'; } + return false; + } - // Handle links while app is running - _linkSubscription = _appLinks.uriLinkStream.listen( - (uri) => _handleDeepLink(uri), - onError: (err) { - debugPrint('Deep link error: $err'); - }, - ); + Future handleDeepLink(Uri uri) async { + if (!isOAuthCallback(uri)) { + return; + } + await _handleDeepLink(uri); } - /// Handle OAuth deep link callback - Future _handleDeepLink(Uri uri) async { - debugPrint('Handling deep link: $uri'); + Map? _parseOAuthCallback(Uri uri) { + String? provider; + final code = uri.queryParameters['code']; + final state = uri.queryParameters['state']; + final error = uri.queryParameters['error']; + final errorDescription = uri.queryParameters['error_description']; if (uri.scheme == AppConfig.urlScheme && uri.host == AppConfig.authPrefix) { final pathSegments = uri.pathSegments; - if (pathSegments.length >= 3 && pathSegments[0] == AppConfig.oauthPath && pathSegments[2] == AppConfig.callbackPath) { - final provider = pathSegments[1]; - final code = uri.queryParameters['code']; - final state = uri.queryParameters['state']; - final error = uri.queryParameters['error']; - - if (error != null) { - // OAuth error - final errorDescription = - uri.queryParameters['error_description'] ?? error; - debugPrint('OAuth error: $errorDescription'); - onOAuthComplete?.call(provider, false, errorDescription); - return; - } - - if (code == null || state == null) { - debugPrint('Missing code or state in OAuth callback'); - onOAuthComplete?.call( - provider, - false, - 'Invalid OAuth callback parameters', - ); - return; - } - - // Complete OAuth flow - try { - final result = await _oauthService.handleOAuthCallback( - provider: provider, - code: code, - state: state, - ); - - final message = - result['message'] as String? ?? - 'Successfully connected to $provider'; - - debugPrint('OAuth success: $message'); - onOAuthComplete?.call(provider, true, message); - } catch (e) { - debugPrint('OAuth callback error: $e'); - onOAuthComplete?.call(provider, false, e.toString()); - } + provider = pathSegments[1]; + } + } else if (uri.scheme == 'http' || uri.scheme == 'https') { + final path = uri.path; + final pathSegments = path.split('/').where((s) => s.isNotEmpty).toList(); + + if (pathSegments.length >= 4 && + pathSegments[0] == 'auth' && + pathSegments[1] == 'oauth' && + pathSegments[3] == 'callback') { + provider = pathSegments[2]; } } + + if (provider == null) { + debugPrint('Could not parse OAuth provider from URI: $uri'); + return null; + } + + return { + 'provider': provider, + 'code': code, + 'state': state, + 'error': error, + 'errorDescription': errorDescription, + }; } - /// Dispose the handler - void dispose() { - _linkSubscription?.cancel(); - _linkSubscription = null; + Future _handleDeepLink(Uri uri) async { + debugPrint('[OAUTH-HANDLER] Handling deep link: $uri'); + + final oauthData = _parseOAuthCallback(uri); + if (oauthData == null) { + debugPrint('[OAUTH-HANDLER] Failed to parse OAuth data from URI'); + return; + } + + final provider = oauthData['provider'] as String; + final code = oauthData['code'] as String?; + final state = oauthData['state'] as String?; + final error = oauthData['error'] as String?; + final errorDescription = oauthData['errorDescription'] as String?; + + debugPrint( + '[OAUTH-HANDLER] Parsed: provider=$provider, code=${code?.substring(0, 10)}..., state=$state', + ); + + if (state != null && _processedStates.contains(state)) { + debugPrint('[OAUTH-HANDLER] OAuth state already processed: $state'); + return; + } + + if (state != null) { + _processedStates.add(state); + } + + if (error != null) { + final description = errorDescription ?? error; + debugPrint('OAuth error: $description'); + onOAuthComplete?.call(provider, false, description); + return; + } + + if (code == null || state == null) { + debugPrint('Missing code or state in OAuth callback'); + onOAuthComplete?.call( + provider, + false, + 'Invalid OAuth callback parameters', + ); + return; + } + + try { + debugPrint('[OAUTH-HANDLER] Calling backend to complete OAuth flow...'); + final result = await _oauthService.handleOAuthCallback( + provider: provider, + code: code, + state: state, + ); + + final message = + result['message'] as String? ?? 'Successfully connected to $provider'; + + debugPrint('[OAUTH-HANDLER] OAuth success: $message'); + onOAuthComplete?.call(provider, true, message); + } catch (e) { + debugPrint('[OAUTH-HANDLER] OAuth callback error: $e'); + onOAuthComplete?.call(provider, false, e.toString()); + } } + + void dispose() {} } diff --git a/mobile/lib/utils/service_token_mapper.dart b/mobile/lib/utils/service_token_mapper.dart new file mode 100644 index 00000000..cc0c315e --- /dev/null +++ b/mobile/lib/utils/service_token_mapper.dart @@ -0,0 +1,35 @@ +/// Maps service names to their OAuth provider token +/// +/// Problem: about.json lists "gmail" and "google_calendar" as separate services, +/// but OAuth only creates a "google" token. +/// +/// This mapper handles the translation so automation execution finds the right token. +class ServiceTokenMapper { + static const Map _tokenMap = { + 'gmail': 'google', + 'google_calendar': 'google', + }; + + /// Get the actual token service name to query in the database + /// + /// Example: + /// - resolveTokenService('gmail') → 'google' + /// - resolveTokenService('google') → 'google' + /// - resolveTokenService('github') → 'github' + static String resolveTokenService(String serviceName) { + return _tokenMap[serviceName] ?? serviceName; + } + + /// Check if a service uses a mapped token + static bool isMappedService(String serviceName) { + return _tokenMap.containsKey(serviceName); + } + + /// Get all services that map to a specific token + static List getServicesForToken(String tokenName) { + return _tokenMap.entries + .where((e) => e.value == tokenName) + .map((e) => e.key) + .toList(); + } +} diff --git a/mobile/lib/widgets/create_applet/action_config_card.dart b/mobile/lib/widgets/create_applet/action_config_card.dart index 4dd9c572..eb4d0d02 100644 --- a/mobile/lib/widgets/create_applet/action_config_card.dart +++ b/mobile/lib/widgets/create_applet/action_config_card.dart @@ -4,6 +4,7 @@ import 'service_action_selector_card.dart'; class ActionConfigCard extends StatelessWidget { final String? selectedService; final String? selectedReaction; + final String? selectedTriggerAction; // For reaction filtering final ValueChanged onServiceChanged; final ValueChanged onReactionChanged; @@ -13,6 +14,7 @@ class ActionConfigCard extends StatelessWidget { required this.selectedReaction, required this.onServiceChanged, required this.onReactionChanged, + this.selectedTriggerAction, }); @override @@ -23,6 +25,7 @@ class ActionConfigCard extends StatelessWidget { selectorType: SelectorType.action, selectedService: selectedService, selectedAction: selectedReaction, + selectedTriggerAction: selectedTriggerAction, onServiceChanged: onServiceChanged, onActionChanged: onReactionChanged, ); diff --git a/mobile/lib/widgets/create_applet/service_action_selector_card.dart b/mobile/lib/widgets/create_applet/service_action_selector_card.dart index adb95a77..bc767a5d 100644 --- a/mobile/lib/widgets/create_applet/service_action_selector_card.dart +++ b/mobile/lib/widgets/create_applet/service_action_selector_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/service_catalog_provider.dart'; import '../../providers/connected_services_provider.dart'; +import '../../providers/compatibility_provider.dart'; import '../../utils/service_icons.dart'; import '../../config/service_provider_config.dart'; @@ -14,6 +15,8 @@ class ServiceActionSelectorCard extends StatefulWidget { final SelectorType selectorType; final String? selectedService; final String? selectedAction; + final String? + selectedTriggerAction; // For filtering reactions by compatibility final ValueChanged onServiceChanged; final ValueChanged onActionChanged; final bool filterByConnectedServices; @@ -28,6 +31,7 @@ class ServiceActionSelectorCard extends StatefulWidget { required this.onServiceChanged, required this.onActionChanged, this.filterByConnectedServices = true, + this.selectedTriggerAction, // Optional: for reaction filtering }); @override @@ -38,265 +42,311 @@ class ServiceActionSelectorCard extends StatefulWidget { class _ServiceActionSelectorCardState extends State { bool _showOnlyConnected = true; + /// Filter reactions based on selected trigger action compatibility + List>? _getFilteredReactions( + ServiceCatalogProvider serviceProvider, + CompatibilityProvider compatibilityProvider, + ) { + final allReactions = serviceProvider.getReactionsForService( + widget.selectedService!, + ); + + if (allReactions == null || allReactions.isEmpty) { + return null; + } + + // If we have a selected trigger action, filter by compatibility + if (widget.selectedTriggerAction != null && + widget.selectedTriggerAction!.isNotEmpty) { + final compatibleReactionNames = compatibilityProvider + .getCompatibleReactions( + widget.selectedTriggerAction!, + allReactions.map((r) => r.name).toList(), + ); + + final filteredReactions = allReactions + .where((reaction) => compatibleReactionNames.contains(reaction.name)) + .toList(); + + return filteredReactions.map((reaction) { + return DropdownMenuItem( + value: reaction.name, + child: Text(reaction.displayName), + ); + }).toList(); + } + + // No filtering if no trigger action selected + return allReactions.map((reaction) { + return DropdownMenuItem( + value: reaction.name, + child: Text(reaction.displayName), + ); + }).toList(); + } + @override Widget build(BuildContext context) { - return Consumer2( - builder: (context, serviceProvider, connectedProvider, child) { - // Filter services based on selector type - var filteredServices = serviceProvider.services - .where( - (service) => widget.selectorType == SelectorType.trigger - ? service.actions.isNotEmpty - : service.reactions.isNotEmpty, - ) - .toList(); + return Consumer3< + ServiceCatalogProvider, + ConnectedServicesProvider, + CompatibilityProvider + >( + builder: + ( + context, + serviceProvider, + connectedProvider, + compatibilityProvider, + child, + ) { + // Filter services based on selector type + var filteredServices = serviceProvider.services + .where( + (service) => widget.selectorType == SelectorType.trigger + ? service.actions.isNotEmpty + : service.reactions.isNotEmpty, + ) + .toList(); - // Further filter by connected services if enabled - if (widget.filterByConnectedServices && _showOnlyConnected) { - filteredServices = filteredServices.where((service) { - // Services that don't require OAuth are always available - if (!ServiceProviderConfig.requiresOAuth(service.name)) { - return true; + // Further filter by connected services if enabled + if (widget.filterByConnectedServices && _showOnlyConnected) { + filteredServices = filteredServices.where((service) { + // Services that don't require OAuth are always available + if (!ServiceProviderConfig.requiresOAuth(service.name)) { + return true; + } + // OAuth services must be connected + return connectedProvider.isServiceConnected(service.name); + }).toList(); } - // OAuth services must be connected - return connectedProvider.isServiceConnected(service.name); - }).toList(); - } - // Get labels and icons based on selector type - final serviceLabel = widget.selectorType == SelectorType.trigger - ? 'Trigger Service' - : 'Action Service'; - final serviceHint = widget.selectorType == SelectorType.trigger - ? 'Choose the triggering service' - : 'Choose the executing service'; - final serviceHelperText = widget.selectorType == SelectorType.trigger - ? 'Select the service that will trigger your automation' - : 'Select the service that will execute the action'; - final serviceIcon = widget.selectorType == SelectorType.trigger - ? Icons.sensors - : Icons.call_to_action; + // Get labels and icons based on selector type + final serviceLabel = widget.selectorType == SelectorType.trigger + ? 'Trigger Service' + : 'Action Service'; + final serviceHint = widget.selectorType == SelectorType.trigger + ? 'Choose the triggering service' + : 'Choose the executing service'; + final serviceHelperText = + widget.selectorType == SelectorType.trigger + ? 'Select the service that will trigger your automation' + : 'Select the service that will execute the action'; + final serviceIcon = widget.selectorType == SelectorType.trigger + ? Icons.sensors + : Icons.call_to_action; - final actionLabel = widget.selectorType == SelectorType.trigger - ? 'Action Trigger' - : 'Reaction'; - final actionHint = widget.selectorType == SelectorType.trigger - ? 'Choose the triggering action' - : 'Choose the action to perform'; - final actionHelperText = widget.selectorType == SelectorType.trigger - ? 'Select the action that will trigger your automation' - : 'Select the action that your automation should perform'; - final actionIcon = widget.selectorType == SelectorType.trigger - ? Icons.play_circle_outline - : Icons.flash_on; + final actionLabel = widget.selectorType == SelectorType.trigger + ? 'Action Trigger' + : 'Reaction'; + final actionHint = widget.selectorType == SelectorType.trigger + ? 'Choose the triggering action' + : 'Choose the action to perform'; + final actionHelperText = widget.selectorType == SelectorType.trigger + ? 'Select the action that will trigger your automation' + : 'Select the action that your automation should perform'; + final actionIcon = widget.selectorType == SelectorType.trigger + ? Icons.play_circle_outline + : Icons.flash_on; - final serviceValidatorMessage = - widget.selectorType == SelectorType.trigger - ? 'Please select a trigger service' - : 'Please select an action service'; - final actionValidatorMessage = - widget.selectorType == SelectorType.trigger - ? 'Please select a trigger action' - : 'Please select a reaction'; + final serviceValidatorMessage = + widget.selectorType == SelectorType.trigger + ? 'Please select a trigger service' + : 'Please select an action service'; + final actionValidatorMessage = + widget.selectorType == SelectorType.trigger + ? 'Please select a trigger action' + : 'Please select a reaction'; - return Card( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - widget.titleIcon, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - widget.title, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w600), - ), - ), - // Toggle for filtering by connected services - if (widget.filterByConnectedServices) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Connected only', - style: Theme.of(context).textTheme.bodySmall, - ), - Switch( - value: _showOnlyConnected, - onChanged: (value) { - setState(() { - _showOnlyConnected = value; - }); - }, - activeThumbColor: Theme.of( - context, - ).colorScheme.primary, - ), - ], - ), - ], - ), - - // Info banner when filter is on but no services match - if (widget.filterByConnectedServices && - _showOnlyConnected && - filteredServices.isEmpty) - Container( - margin: const EdgeInsets.only(top: 12, bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.errorContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( + Row( children: [ Icon( - Icons.warning_amber_rounded, - color: Theme.of(context).colorScheme.error, - size: 20, + widget.titleIcon, + color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: 8), Expanded( child: Text( - 'No connected services available. Please connect a service or turn off the filter.', - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onErrorContainer, - ), + widget.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), ), ), + // Toggle for filtering by connected services + if (widget.filterByConnectedServices) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Connected only', + style: Theme.of(context).textTheme.bodySmall, + ), + Switch( + value: _showOnlyConnected, + onChanged: (value) { + setState(() { + _showOnlyConnected = value; + }); + }, + activeThumbColor: Theme.of( + context, + ).colorScheme.primary, + ), + ], + ), ], ), - ), - const SizedBox(height: 16), - - // Service Selection - Semantics( - label: '${widget.title} service selection', - hint: serviceHelperText, - child: DropdownButtonFormField( - isExpanded: true, - initialValue: widget.selectedService, - decoration: InputDecoration( - labelText: serviceLabel, - hintText: serviceHint, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - prefixIcon: Icon(serviceIcon), - helperText: serviceHelperText, - ), - items: filteredServices.map((service) { - final isConnected = connectedProvider.isServiceConnected( - service.name, - ); - final requiresOAuth = ServiceProviderConfig.requiresOAuth( - service.name, - ); - - return DropdownMenuItem( - value: service.name, + // Info banner when filter is on but no services match + if (widget.filterByConnectedServices && + _showOnlyConnected && + filteredServices.isEmpty) + Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), child: Row( children: [ Icon( - ServiceIcons.getServiceIcon(service.name), - size: 18, - color: Theme.of(context).colorScheme.primary, + Icons.warning_amber_rounded, + color: Theme.of(context).colorScheme.error, + size: 20, ), const SizedBox(width: 8), - Expanded(child: Text(service.displayName)), - if (requiresOAuth && isConnected) - Icon( - Icons.check_circle, - size: 16, - color: Colors.green, + Expanded( + child: Text( + 'No connected services available. Please connect a service or turn off the filter.', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onErrorContainer, + ), ), + ), ], ), - ); - }).toList(), - onChanged: widget.onServiceChanged, - validator: (value) { - if (value == null || value.isEmpty) { - return serviceValidatorMessage; - } - return null; - }, - ), - ), + ), - const SizedBox(height: 16), + const SizedBox(height: 16), - // Action/Reaction Selection - Only show when service is selected - if (widget.selectedService != null) - Semantics( - label: '${widget.title} action selection', - hint: actionHelperText, - child: DropdownButtonFormField( - isExpanded: true, - initialValue: widget.selectedAction, - decoration: InputDecoration( - labelText: actionLabel, - hintText: actionHint, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + // Service Selection + Semantics( + label: '${widget.title} service selection', + hint: serviceHelperText, + child: DropdownButtonFormField( + isExpanded: true, + initialValue: widget.selectedService, + decoration: InputDecoration( + labelText: serviceLabel, + hintText: serviceHint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + prefixIcon: Icon(serviceIcon), + helperText: serviceHelperText, ), - prefixIcon: Icon(actionIcon), - helperText: actionHelperText, + items: filteredServices.map((service) { + final isConnected = connectedProvider + .isServiceConnected(service.name); + final requiresOAuth = + ServiceProviderConfig.requiresOAuth(service.name); + + return DropdownMenuItem( + value: service.name, + child: Row( + children: [ + Icon( + ServiceIcons.getServiceIcon(service.name), + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded(child: Text(service.displayName)), + if (requiresOAuth && isConnected) + Icon( + Icons.check_circle, + size: 16, + color: Colors.green, + ), + ], + ), + ); + }).toList(), + onChanged: widget.onServiceChanged, + validator: (value) { + if (value == null || value.isEmpty) { + return serviceValidatorMessage; + } + return null; + }, ), - items: - (widget.selectorType == SelectorType.trigger - ? serviceProvider - .getActionsForService( - widget.selectedService!, - ) - ?.map((action) { - return DropdownMenuItem( - value: action.name, - child: Text(action.displayName), - ); - }) - .toList() - : serviceProvider - .getReactionsForService( - widget.selectedService!, - ) - ?.map((reaction) { - return DropdownMenuItem( - value: reaction.name, - child: Text(reaction.displayName), - ); - }) - .toList()) ?? - [], - onChanged: widget.onActionChanged, - validator: (value) { - if (value == null || value.isEmpty) { - return actionValidatorMessage; - } - return null; - }, ), - ), - ], - ), - ), - ); - }, + + const SizedBox(height: 16), + + // Action/Reaction Selection - Only show when service is selected + if (widget.selectedService != null) + Semantics( + label: '${widget.title} action selection', + hint: actionHelperText, + child: DropdownButtonFormField( + isExpanded: true, + initialValue: widget.selectedAction, + decoration: InputDecoration( + labelText: actionLabel, + hintText: actionHint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + prefixIcon: Icon(actionIcon), + helperText: actionHelperText, + ), + items: + (widget.selectorType == SelectorType.trigger + ? serviceProvider + .getActionsForService( + widget.selectedService!, + ) + ?.map((action) { + return DropdownMenuItem( + value: action.name, + child: Text(action.displayName), + ); + }) + .toList() + : _getFilteredReactions( + serviceProvider, + compatibilityProvider, + )) ?? + [], + onChanged: widget.onActionChanged, + validator: (value) { + if (value == null || value.isEmpty) { + return actionValidatorMessage; + } + return null; + }, + ), + ), + ], + ), + ), + ); + }, ); } } diff --git a/mobile/test/service_card_test.dart b/mobile/test/service_card_test.dart index bbe9ed7c..a052cefe 100644 --- a/mobile/test/service_card_test.dart +++ b/mobile/test/service_card_test.dart @@ -8,7 +8,13 @@ void main() { late Service testService; setUp(() { - testService = Service(id: 1, name: 'github', actions: [], reactions: []); + testService = Service( + id: 1, + name: 'github', + requiresOAuth: true, + actions: [], + reactions: [], + ); }); testWidgets('should display service name correctly', ( @@ -29,6 +35,7 @@ void main() { final serviceWithData = Service( id: 2, name: 'discord', + requiresOAuth: false, actions: [ ServiceAction( id: 1, @@ -59,9 +66,27 @@ void main() { WidgetTester tester, ) async { final services = [ - Service(id: 1, name: 'github', actions: [], reactions: []), - Service(id: 2, name: 'discord', actions: [], reactions: []), - Service(id: 3, name: 'unknown', actions: [], reactions: []), + Service( + id: 1, + name: 'github', + requiresOAuth: true, + actions: [], + reactions: [], + ), + Service( + id: 2, + name: 'discord', + requiresOAuth: false, + actions: [], + reactions: [], + ), + Service( + id: 3, + name: 'unknown', + requiresOAuth: false, + actions: [], + reactions: [], + ), ]; for (final service in services) { diff --git a/mobile/test/service_catalog_provider_test.dart b/mobile/test/service_catalog_provider_test.dart index f45d0879..5ecb6716 100644 --- a/mobile/test/service_catalog_provider_test.dart +++ b/mobile/test/service_catalog_provider_test.dart @@ -51,6 +51,7 @@ void main() { final service = Service( id: 1, name: 'test', + requiresOAuth: false, actions: [ ServiceAction(id: 1, name: 'action1', description: 'Test action'), ], diff --git a/mobile/test/service_models_test.dart b/mobile/test/service_models_test.dart index 65fe7084..4337c988 100644 --- a/mobile/test/service_models_test.dart +++ b/mobile/test/service_models_test.dart @@ -59,6 +59,7 @@ void main() { final service = Service( id: 2, name: 'discord', + requiresOAuth: true, actions: [ ServiceAction( id: 3, @@ -87,26 +88,40 @@ void main() { test('Service.displayName should format names correctly', () { expect( - Service(id: 1, name: 'github', actions: [], reactions: []).displayName, + Service( + id: 1, + name: 'github', + requiresOAuth: true, + actions: [], + reactions: [], + ).displayName, 'Github', ); expect( Service( id: 2, name: 'discord_bot', + requiresOAuth: false, actions: [], reactions: [], ).displayName, 'Discord Bot', ); expect( - Service(id: 3, name: '', actions: [], reactions: []).displayName, + Service( + id: 3, + name: '', + requiresOAuth: false, + actions: [], + reactions: [], + ).displayName, 'Unknown Service', ); expect( Service( id: 4, name: 'test_service', + requiresOAuth: false, actions: [], reactions: [], ).displayName, @@ -118,6 +133,7 @@ void main() { final service = Service( id: 1, name: 'github', + requiresOAuth: true, actions: [], reactions: [], ); From ec68b7e1374ba56d996ea3096c98ba1a83acf81a Mon Sep 17 00:00:00 2001 From: maelemiel Date: Wed, 29 Oct 2025 07:29:00 +0100 Subject: [PATCH 05/31] feat(flutter_svg): dependency to pubspec.yaml and update pubspec.lock --- mobile/pubspec.lock | 56 +++++++++++++++++++++++++++++++++++++++++++++ mobile/pubspec.yaml | 1 + 2 files changed, 57 insertions(+) diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 67e7db76..89fc33a0 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -286,6 +286,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678 + url: "https://pub.dev" + source: hosted + version: "2.2.1" flutter_test: dependency: "direct dev" description: flutter @@ -528,6 +536,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: transitive description: @@ -576,6 +592,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" platform: dependency: transitive description: @@ -837,6 +861,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.4" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + url: "https://pub.dev" + source: hosted + version: "1.1.19" vector_math: dependency: transitive description: @@ -901,6 +949,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 1b2dcd8e..abb7c94b 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: url_launcher: ^6.3.1 intl: ^0.19.0 collection: ^1.18.0 + flutter_svg: ^2.0.0 dev_dependencies: flutter_test: From 6952ed19a94cae6d24315bd87944b44215ed1125 Mon Sep 17 00:00:00 2001 From: maelemiel Date: Wed, 29 Oct 2025 07:30:04 +0100 Subject: [PATCH 06/31] feat(service): add logo field to Service model and update related components --- mobile/lib/models/service.dart | 4 ++ .../lib/pages/service_connections_page.dart | 44 ++++++++++++------- .../lib/services/service_catalog_service.dart | 1 + mobile/lib/widgets/service_card.dart | 24 +++++++--- 4 files changed, 52 insertions(+), 21 deletions(-) diff --git a/mobile/lib/models/service.dart b/mobile/lib/models/service.dart index d86e73d8..65aa88fb 100644 --- a/mobile/lib/models/service.dart +++ b/mobile/lib/models/service.dart @@ -5,6 +5,7 @@ class Service { final int id; final String name; final bool requiresOAuth; + final String? logo; final List actions; final List reactions; @@ -12,6 +13,7 @@ class Service { required this.id, required this.name, required this.requiresOAuth, + this.logo, required this.actions, required this.reactions, }); @@ -21,6 +23,7 @@ class Service { id: json['id'] as int? ?? 0, name: (json['name'] as String?)?.trim() ?? '', requiresOAuth: (json['requires_oauth'] as bool?) ?? false, + logo: (json['logo'] as String?), actions: (json['actions'] as List?) ?.map( @@ -45,6 +48,7 @@ class Service { 'id': id, 'name': name, 'requires_oauth': requiresOAuth, + 'logo': logo, 'actions': actions.map((action) => action.toJson()).toList(), 'reactions': reactions.map((reaction) => reaction.toJson()).toList(), }; diff --git a/mobile/lib/pages/service_connections_page.dart b/mobile/lib/pages/service_connections_page.dart index 0ea1c20e..0c5354b1 100644 --- a/mobile/lib/pages/service_connections_page.dart +++ b/mobile/lib/pages/service_connections_page.dart @@ -1,12 +1,24 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import '../models/service.dart'; import '../models/service_token.dart'; import '../providers/service_catalog_provider.dart'; import '../services/oauth_service.dart'; -import '../utils/service_icons.dart'; import '../config/service_provider_config.dart'; +import '../utils/service_icons.dart'; + +/// Get color filter for logos based on connection status +ColorFilter? _getLogoColorFilter(bool requiresOAuth, bool isConnected) { + if (!requiresOAuth) { + return ColorFilter.mode(Colors.green, BlendMode.srcIn); + } + if (isConnected) { + return ColorFilter.mode(Colors.green, BlendMode.srcIn); + } + return ColorFilter.mode(Colors.blue, BlendMode.srcIn); +} class ServiceConnectionsPage extends StatefulWidget { const ServiceConnectionsPage({super.key}); @@ -302,20 +314,22 @@ class _ServiceConnectionsPageState extends State { ? Colors.green.withValues(alpha: 0.2) : Colors.blue.withValues(alpha: 0.2)) : Colors.green.withValues(alpha: 0.2), - child: Image.network( - ServiceProviderConfig.getIconUrl(service.name), - width: 24, - height: 24, - errorBuilder: (context, error, stackTrace) { - return Icon( - ServiceIcons.getServiceIcon(service.name), - color: requiresOAuth - ? (isConnected ? Colors.green : Colors.blue) - : Colors.green, - size: 20, - ); - }, - ), + child: service.logo != null + ? SvgPicture.network( + service.logo!, + width: 24, + height: 24, + placeholderBuilder: (context) => + const CircularProgressIndicator(strokeWidth: 1), + colorFilter: _getLogoColorFilter(requiresOAuth, isConnected), + ) + : Icon( + ServiceIcons.getServiceIcon(service.name), + color: requiresOAuth + ? (isConnected ? Colors.green : Colors.blue) + : Colors.green, + size: 20, + ), ), title: Row( children: [ diff --git a/mobile/lib/services/service_catalog_service.dart b/mobile/lib/services/service_catalog_service.dart index 6e2bc817..e0b90b84 100644 --- a/mobile/lib/services/service_catalog_service.dart +++ b/mobile/lib/services/service_catalog_service.dart @@ -103,6 +103,7 @@ class ServiceCatalogService { id: serviceId, name: serviceName, requiresOAuth: (serviceJson['requires_oauth'] as bool?) ?? false, + logo: (serviceJson['logo'] as String?), actions: actionsByService[serviceId] ?? [], reactions: reactionsByService[serviceId] ?? [], ); diff --git a/mobile/lib/widgets/service_card.dart b/mobile/lib/widgets/service_card.dart index cb09157b..6491e468 100644 --- a/mobile/lib/widgets/service_card.dart +++ b/mobile/lib/widgets/service_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import '../models/service.dart'; import '../utils/service_icons.dart'; @@ -20,7 +21,7 @@ class ServiceCard extends StatelessWidget { padding: const EdgeInsets.all(16.0), child: Row( children: [ - // Service Icon + // Service Icon/Logo Container( width: 48, height: 48, @@ -28,11 +29,22 @@ class ServiceCard extends StatelessWidget { color: Theme.of(context).primaryColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), - child: Icon( - ServiceIcons.getServiceIcon(service.name), - color: Theme.of(context).primaryColor, - size: 24, - ), + child: service.logo != null + ? SvgPicture.network( + service.logo!, + width: 24, + height: 24, + placeholderBuilder: (context) => Icon( + ServiceIcons.getServiceIcon(service.name), + color: Theme.of(context).primaryColor, + size: 24, + ), + ) + : Icon( + ServiceIcons.getServiceIcon(service.name), + color: Theme.of(context).primaryColor, + size: 24, + ), ), const SizedBox(width: 16), From c94def8c0d3244fb2df802337db79f8d1ba41c6b Mon Sep 17 00:00:00 2001 From: maelemiel Date: Wed, 29 Oct 2025 07:30:08 +0100 Subject: [PATCH 07/31] feat(compatibility_provider): update API endpoint to use base URL for fetching rules --- mobile/lib/providers/compatibility_provider.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mobile/lib/providers/compatibility_provider.dart b/mobile/lib/providers/compatibility_provider.dart index dcf22eb8..6469f73c 100644 --- a/mobile/lib/providers/compatibility_provider.dart +++ b/mobile/lib/providers/compatibility_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'dart:convert'; import '../services/http_client_service.dart'; +import '../config/api_config.dart'; /// Provider for managing action-reaction compatibility rules /// Loads rules dynamically from the backend on first use and caches them @@ -42,7 +43,9 @@ class CompatibilityProvider extends ChangeNotifier { try { debugPrint('[CompatibilityProvider] Fetching rules from backend...'); - final response = await _httpClient.get('/api/compatibility-rules/'); + final response = await _httpClient.get( + '${ApiConfig.baseUrl}/api/compatibility-rules/', + ); if (response.statusCode == 200) { final data = jsonDecode(response.body) as Map; From 212b9a6000e4de4f45deb592024c2db71c1d489e Mon Sep 17 00:00:00 2001 From: maelemiel Date: Wed, 29 Oct 2025 07:33:28 +0100 Subject: [PATCH 08/31] feat(AndroidManifest): add additional OAuth2 callback for port 8080 --- mobile/android/app/src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 13284576..c4662b45 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -57,6 +57,7 @@ +