Files
iiEsaywebUIapp/lib/core/services/app_intents_service.dart

443 lines
14 KiB
Dart
Raw Normal View History

2025-11-25 00:08:51 +05:30
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
2025-11-25 00:08:51 +05:30
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../providers/app_providers.dart';
import '../utils/debug_logger.dart';
import 'navigation_service.dart';
import '../../features/chat/providers/chat_providers.dart';
import '../../features/chat/providers/context_attachments_provider.dart';
2025-11-25 00:08:51 +05:30
import '../../features/auth/providers/unified_auth_providers.dart';
import '../../features/chat/views/voice_call_page.dart';
import '../../features/chat/services/file_attachment_service.dart';
import '../../shared/services/tasks/task_queue.dart';
part 'app_intents_service.g.dart';
const _askIntentId = 'app.cogwheel.conduit.ask_chat';
const _voiceCallIntentId = 'app.cogwheel.conduit.start_voice_call';
const _sendTextIntentId = 'app.cogwheel.conduit.send_text';
const _sendUrlIntentId = 'app.cogwheel.conduit.send_url';
const _sendImageIntentId = 'app.cogwheel.conduit.send_image';
/// Method channel for receiving App Intent invocations from native iOS code.
/// Native Swift code defines the intents with proper titles and metadata.
/// This Flutter code handles the business logic (navigation, state management).
const _appIntentsChannel = MethodChannel('conduit/app_intents');
/// Handles iOS App Intents for Siri/Shortcuts.
///
/// Native Swift code in AppDelegate.swift defines the App Intents with proper
/// titles, descriptions, and parameters. This coordinator sets up a method
/// channel to receive invocations and execute Flutter-side business logic.
2025-11-25 00:08:51 +05:30
@Riverpod(keepAlive: true)
class AppIntentCoordinator extends _$AppIntentCoordinator {
@override
FutureOr<void> build() {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) {
return null;
}
_setupMethodChannel();
2025-11-25 00:08:51 +05:30
}
void _setupMethodChannel() {
_appIntentsChannel.setMethodCallHandler(_handleMethodCall);
2025-11-25 00:08:51 +05:30
}
Future<Map<String, dynamic>> _handleMethodCall(MethodCall call) async {
final parameters = (call.arguments as Map?)?.cast<String, dynamic>() ?? {};
2025-11-25 00:08:51 +05:30
try {
switch (call.method) {
case _askIntentId:
return await _handleAskIntent(parameters);
case _voiceCallIntentId:
return await _handleVoiceCallIntent(parameters);
case _sendTextIntentId:
return await _handleSendTextIntent(parameters);
case _sendUrlIntentId:
return await _handleSendUrlIntent(parameters);
case _sendImageIntentId:
return await _handleSendImageIntent(parameters);
default:
return {'success': false, 'error': 'Unknown intent: ${call.method}'};
}
} catch (error, stackTrace) {
DebugLogger.error(
'app-intents-dispatch',
scope: 'siri',
error: error,
stackTrace: stackTrace,
);
return {'success': false, 'error': error.toString()};
}
}
Future<Map<String, dynamic>> _handleAskIntent(
2025-11-25 00:08:51 +05:30
Map<String, dynamic> parameters,
) async {
final prompt = (parameters['prompt'] as String?)?.trim();
try {
await _prepareChat(prompt: prompt);
final summary = prompt != null && prompt.isNotEmpty
? 'Opening chat for "$prompt"'
: 'Opening Conduit chat';
return {'success': true, 'value': summary};
2025-11-25 00:08:51 +05:30
} catch (error, stackTrace) {
DebugLogger.error(
'app-intents-handle',
scope: 'siri',
error: error,
stackTrace: stackTrace,
);
return {'success': false, 'error': 'Unable to open chat: $error'};
2025-11-25 00:08:51 +05:30
}
}
Future<Map<String, dynamic>> _handleVoiceCallIntent(
2025-11-25 00:08:51 +05:30
Map<String, dynamic> parameters,
) async {
DebugLogger.log('Starting voice call from Siri/Shortcuts', scope: 'siri');
if (!ref.mounted) {
DebugLogger.log('Ref not mounted for voice call', scope: 'siri');
return {'success': false, 'error': 'App not ready'};
}
// Check authentication state
final navState = ref.read(authNavigationStateProvider);
if (navState != AuthNavigationState.authenticated) {
DebugLogger.log('Not authenticated for voice call', scope: 'siri');
return {
'success': false,
'error': 'Please sign in to start a voice call',
};
}
// Check if a model is selected
final model = ref.read(selectedModelProvider);
if (model == null) {
DebugLogger.log('No model selected for voice call', scope: 'siri');
return {'success': false, 'error': 'Please select a model first'};
}
2025-11-25 00:08:51 +05:30
try {
await _startVoiceCall();
DebugLogger.log('Voice call launched from Siri/Shortcuts', scope: 'siri');
return {'success': true, 'value': 'Starting Conduit voice call'};
2025-11-25 00:08:51 +05:30
} catch (error, stackTrace) {
DebugLogger.error(
'app-intents-voice',
scope: 'siri',
error: error,
stackTrace: stackTrace,
);
return {'success': false, 'error': 'Unable to start voice call: $error'};
2025-11-25 00:08:51 +05:30
}
}
Future<Map<String, dynamic>> _handleSendTextIntent(
2025-11-25 00:08:51 +05:30
Map<String, dynamic> parameters,
) async {
final text = (parameters['text'] as String?)?.trim();
if (text == null || text.isEmpty) {
return {'success': false, 'error': 'No text provided.'};
2025-11-25 00:08:51 +05:30
}
try {
await _prepareChatWithOptions(
prompt: text,
focusComposer: true,
resetChat: true,
);
return {'success': true, 'value': 'Sent to Conduit'};
2025-11-25 00:08:51 +05:30
} catch (error, stackTrace) {
DebugLogger.error(
'app-intents-text',
scope: 'siri',
error: error,
stackTrace: stackTrace,
);
return {'success': false, 'error': 'Unable to send text: $error'};
2025-11-25 00:08:51 +05:30
}
}
Future<Map<String, dynamic>> _handleSendUrlIntent(
2025-11-25 00:08:51 +05:30
Map<String, dynamic> parameters,
) async {
final url = (parameters['url'] as String?)?.trim();
if (url == null || url.isEmpty) {
return {'success': false, 'error': 'No URL provided.'};
2025-11-25 00:08:51 +05:30
}
try {
// Determine if this is a YouTube URL
final isYoutube =
url.startsWith('https://www.youtube.com') ||
url.startsWith('https://youtu.be') ||
url.startsWith('https://youtube.com') ||
url.startsWith('https://m.youtube.com');
// Try to fetch the URL content first
String? content;
String? name;
String? collectionName;
final api = ref.read(apiServiceProvider);
if (api != null) {
final result = isYoutube
? await api.processYoutube(url: url)
: await api.processWebpage(url: url);
final file = (result?['file'] as Map?)?.cast<String, dynamic>();
final fileData = (file?['data'] as Map?)?.cast<String, dynamic>();
content = fileData?['content']?.toString() ?? '';
final meta = (file?['meta'] as Map?)?.cast<String, dynamic>();
name = meta?['name']?.toString() ?? Uri.parse(url).host;
collectionName = result?['collection_name']?.toString();
}
final prompt = isYoutube
? 'Please summarize or analyze this video:'
: 'Please summarize or analyze this page:';
// Reset chat first, then add attachments (startNewChat clears attachments)
2025-11-25 00:08:51 +05:30
await _prepareChatWithOptions(
prompt: prompt,
focusComposer: true,
resetChat: true,
);
// Add attachments after reset so they aren't cleared
final bool contentAttached = content != null && content.isNotEmpty;
if (contentAttached) {
final notifier = ref.read(contextAttachmentsProvider.notifier);
if (isYoutube) {
notifier.addYoutube(
displayName: name ?? Uri.parse(url).host,
content: content,
url: url,
collectionName: collectionName,
);
} else {
notifier.addWeb(
displayName: name ?? Uri.parse(url).host,
content: content,
url: url,
collectionName: collectionName,
);
}
}
if (contentAttached) {
return {
'success': true,
'value': isYoutube
? 'YouTube video attached in Conduit'
: 'Webpage attached in Conduit',
};
} else {
return {
'success': true,
'value': 'Opening Conduit with URL (content could not be fetched)',
};
}
2025-11-25 00:08:51 +05:30
} catch (error, stackTrace) {
DebugLogger.error(
'app-intents-url',
scope: 'siri',
error: error,
stackTrace: stackTrace,
);
return {'success': false, 'error': 'Unable to send URL: $error'};
2025-11-25 00:08:51 +05:30
}
}
Future<Map<String, dynamic>> _handleSendImageIntent(
2025-11-25 00:08:51 +05:30
Map<String, dynamic> parameters,
) async {
final base64 = parameters['bytes'] as String?;
if (base64 == null || base64.isEmpty) {
return {'success': false, 'error': 'No image data provided.'};
2025-11-25 00:08:51 +05:30
}
final filenameRaw = (parameters['filename'] as String?)?.trim();
try {
final file = await _materializeTempFile(
base64,
preferredName: filenameRaw,
);
await _attachFiles([file]);
await _prepareChatWithOptions(focusComposer: true, resetChat: true);
return {'success': true, 'value': 'Image attached in Conduit'};
2025-11-25 00:08:51 +05:30
} catch (error, stackTrace) {
DebugLogger.error(
'app-intents-image',
scope: 'siri',
error: error,
stackTrace: stackTrace,
);
return {'success': false, 'error': 'Unable to send image: $error'};
}
}
2025-11-25 00:08:51 +05:30
Future<void> _prepareChat({String? prompt}) async {
await _prepareChatWithOptions(
prompt: prompt,
focusComposer: false,
resetChat: false,
);
}
Future<void> openChatFromExternal({
String? prompt,
bool focusComposer = false,
bool resetChat = false,
}) {
return _prepareChatWithOptions(
prompt: prompt,
focusComposer: focusComposer,
resetChat: resetChat,
);
}
Future<void> startVoiceCallFromExternal() => _startVoiceCall();
2025-11-25 00:08:51 +05:30
Future<void> _prepareChatWithOptions({
String? prompt,
bool focusComposer = false,
bool resetChat = false,
}) async {
if (!ref.mounted) return;
NavigationService.navigateToChat();
final navState = ref.read(authNavigationStateProvider);
if (prompt != null && prompt.isNotEmpty) {
ref.read(prefilledInputTextProvider.notifier).set(prompt);
}
if (navState == AuthNavigationState.authenticated && resetChat) {
startNewChat(ref);
}
if (focusComposer) {
final tick = ref.read(inputFocusTriggerProvider);
ref.read(inputFocusTriggerProvider.notifier).set(tick + 1);
}
}
Future<void> _startVoiceCall() async {
if (!ref.mounted) return;
// Validate authentication state
2025-11-25 00:08:51 +05:30
final navState = ref.read(authNavigationStateProvider);
if (navState != AuthNavigationState.authenticated) {
throw StateError('Sign in to start a voice call.');
}
// Validate model selection
2025-11-25 00:08:51 +05:30
final model = ref.read(selectedModelProvider);
if (model == null) {
throw StateError('Choose a model before starting a voice call.');
}
// Pre-warm socket connection before navigating to voice call.
// This reduces the chance of "websocket not connected" errors when
// opening voice call right after app start or from Siri/Shortcuts.
final socketService = ref.read(socketServiceProvider);
if (socketService != null && !socketService.isConnected) {
// Start connection attempt in parallel, don't wait for full connection
// The VoiceCallService.startCall() will wait with extended timeout
unawaited(socketService.connect());
}
// Navigate to chat first if not already there
final isOnChatRoute = NavigationService.currentRoute == Routes.chat;
if (!isOnChatRoute) {
await NavigationService.navigateToChat();
}
2025-11-25 00:08:51 +05:30
// Wait a tick for navigation to settle so navigator/context are present.
await Future<void>.delayed(const Duration(milliseconds: 50));
final context = NavigationService.navigatorKey.currentContext;
if (context == null || !context.mounted) {
throw StateError('Navigation context not available.');
2025-11-25 00:08:51 +05:30
}
// Dismiss keyboard before navigating
FocusScope.of(context).unfocus();
// Navigate to voice call page with new conversation flag
if (!context.mounted) return;
await Navigator.of(context).push(
2025-11-25 00:08:51 +05:30
MaterialPageRoute(
builder: (_) => const VoiceCallPage(startNewConversation: true),
2025-11-25 00:08:51 +05:30
fullscreenDialog: true,
),
);
}
Future<File> _materializeTempFile(
String base64Data, {
String? preferredName,
}) async {
final bytes = base64Decode(base64Data);
const maxBytes = 20 * 1024 * 1024; // 20 MB guardrail
if (bytes.length > maxBytes) {
throw StateError('Image too large (max 20 MB).');
}
final tempDir = await getTemporaryDirectory();
final safeName = (preferredName != null && preferredName.isNotEmpty)
? preferredName
: 'conduit_image_${DateTime.now().millisecondsSinceEpoch}.jpg';
final sanitizedName = safeName.replaceAll(RegExp(r'[^\w\.\-]'), '_');
final file = File(p.join(tempDir.path, sanitizedName));
await file.writeAsBytes(bytes, flush: true);
return file;
}
Future<void> _attachFiles(List<File> files) async {
if (files.isEmpty) return;
// Warm the attachment service to ensure dependencies are ready.
final _ = ref.read(fileAttachmentServiceProvider);
final notifier = ref.read(attachedFilesProvider.notifier);
final taskQueue = ref.read(taskQueueProvider.notifier);
final activeConv = ref.read(activeConversationProvider);
final attachments = files
.map((f) => LocalAttachment(file: f, displayName: p.basename(f.path)))
.toList();
notifier.addFiles(attachments);
for (final attachment in attachments) {
try {
await taskQueue.enqueueUploadMedia(
conversationId: activeConv?.id,
filePath: attachment.file.path,
fileName: attachment.displayName,
fileSize: await attachment.file.length(),
);
} catch (error, stackTrace) {
DebugLogger.error(
'app-intents-upload',
scope: 'siri',
error: error,
stackTrace: stackTrace,
);
}
}
}
}