From ccc8c4e655cf4f4252d093c6992c7b89d99f3781 Mon Sep 17 00:00:00 2001 From: uday chauhan Date: Sun, 28 Jun 2026 12:52:26 +0530 Subject: [PATCH 1/2] chore(issues): align model download manager issue with native WorkManager spec and commit test repairs --- .../offline-llm-03-model-download-manager.md | 21 ++--- .../core/services/gemini_nano_service.dart | 25 ------ .../services/agent_skill_orchestrator.dart | 2 + .../screens/model_library_screen.dart | 7 +- .../litert_lm_service_warmup_test.dart | 9 ++- .../agent_connector_registry_test.dart | 2 +- .../screens/model_library_screen_test.dart | 76 +++++++++++++++++++ 7 files changed, 101 insertions(+), 41 deletions(-) diff --git a/.github/issues/offline-llm-03-model-download-manager.md b/.github/issues/offline-llm-03-model-download-manager.md index bb6a74c2..67ad7c03 100644 --- a/.github/issues/offline-llm-03-model-download-manager.md +++ b/.github/issues/offline-llm-03-model-download-manager.md @@ -42,23 +42,24 @@ Downloading large LLM models (1-8GB) requires reliable background downloads that ### Proposed Enhancement 1. Create `ModelDownloadService` for download orchestration -2. Implement Android platform channel for DownloadManager -3. Create iOS background download using URLSession +2. Implement Android platform channel wrapping WorkManager + OkHttp for robust HTTP redirect handling +3. Create iOS background download using URLSession with throttled delegate callbacks 4. Add download progress tracking with Riverpod state 5. Implement download queue with priority support -6. Add storage management (usage tracking, cleanup) +6. Add storage management (usage tracking, cleanup, SHA-256 verification) 7. Handle download resume after network interruption ### User Value - Reliable downloads that complete even when app is backgrounded -- Clear progress indication +- Clear progress indication without main-thread UI lag - Ability to pause/resume downloads -- Storage awareness before downloading +- Storage awareness and SHA-256 verified files before loading ## Acceptance Criteria - [ ] `ModelDownloadService` interface created -- [ ] Android DownloadManager platform channel implemented -- [ ] iOS URLSession background download implemented +- [ ] Android WorkManager + OkHttp background download worker implemented +- [ ] iOS URLSession background download with throttled progress implemented +- [ ] SHA-256 integrity verification implemented - [ ] Download progress state management working - [ ] Download queue with pause/resume functionality - [ ] Storage usage tracking implemented @@ -74,10 +75,10 @@ Downloading large LLM models (1-8GB) requires reliable background downloads that ## Files to Modify ``` -packages/core_ai/lib/src/download/model_download_service.dart (new) -packages/core_ai/lib/src/download/download_progress.dart (new) +packages/core_ai/lib/src/download/model_download_service.dart (modify) +packages/core_ai/lib/src/download/model_download_progress.dart (modify) packages/core_ai/lib/src/storage/model_storage_manager.dart (new) -app/android/app/src/main/kotlin/.../ModelDownloadPlugin.kt (new) +app/android/app/src/main/kotlin/io/airo/app/ModelDownloadPlugin.kt (new) app/ios/Runner/ModelDownloadPlugin.swift (new) app/lib/core/ai/providers/download_providers.dart (new) ``` diff --git a/app/lib/core/services/gemini_nano_service.dart b/app/lib/core/services/gemini_nano_service.dart index b6397a3b..43ea80f5 100644 --- a/app/lib/core/services/gemini_nano_service.dart +++ b/app/lib/core/services/gemini_nano_service.dart @@ -132,31 +132,6 @@ class GeminiNanoService { } } - /// Warm the model with a lightweight native inference request. - /// - /// This is intended to hide the first-request cold start after the chat - /// screen loads on supported Android devices. - Future warmup() async { - if (kIsWeb) { - return false; - } - - try { - if (!_isInitialized) { - final initialized = await initialize(); - if (!initialized) { - return false; - } - } - - final warmed = await _channel.invokeMethod('warmup'); - return warmed ?? false; - } catch (e) { - debugPrint('Error warming up Gemini Nano: $e'); - return false; - } - } - /// Generate content from a prompt /// Returns the generated text response, or fallback message on web/uninitialized Future generateContent(String prompt) async { diff --git a/app/lib/features/agent_chat/domain/services/agent_skill_orchestrator.dart b/app/lib/features/agent_chat/domain/services/agent_skill_orchestrator.dart index 195906f1..894ea1b8 100644 --- a/app/lib/features/agent_chat/domain/services/agent_skill_orchestrator.dart +++ b/app/lib/features/agent_chat/domain/services/agent_skill_orchestrator.dart @@ -551,6 +551,8 @@ Map? _calendarEventFromNotificationResult( 'source': 'reminder_confirmation', }; } + + SkillModelAction? parseSkillModelAction(String text) { try { final decoded = jsonDecode(_stripCodeFence(text)); diff --git a/app/lib/features/agent_chat/presentation/screens/model_library_screen.dart b/app/lib/features/agent_chat/presentation/screens/model_library_screen.dart index 2d975bf4..318505d6 100644 --- a/app/lib/features/agent_chat/presentation/screens/model_library_screen.dart +++ b/app/lib/features/agent_chat/presentation/screens/model_library_screen.dart @@ -1056,15 +1056,16 @@ class _ProjectTemplateCard extends StatelessWidget { const SizedBox(height: 12), Align( alignment: Alignment.centerRight, - child: Row( - mainAxisSize: MainAxisSize.min, + child: OverflowBar( + alignment: MainAxisAlignment.end, + spacing: 8, + overflowAlignment: OverflowBarAlignment.end, children: [ if ((package ?? candidate.package)?.learnMoreUri != null) TextButton( onPressed: onLearnMore, child: const Text('Learn more'), ), - const SizedBox(width: 8), FilledButton.icon( onPressed: onStart, icon: Icon( diff --git a/app/test/core/services/litert_lm_service_warmup_test.dart b/app/test/core/services/litert_lm_service_warmup_test.dart index acfa08af..d1b03551 100644 --- a/app/test/core/services/litert_lm_service_warmup_test.dart +++ b/app/test/core/services/litert_lm_service_warmup_test.dart @@ -57,10 +57,15 @@ class _FakeLiteRtLmClient implements LiteRtLmClient { final generatedPrompts = []; @override - Future activeModelExists() async => activeModelExistsValue; + Future activeModelExists({String? modelPath}) async => activeModelExistsValue; @override - Future initialize({String? huggingFaceToken}) async { + Future initialize({ + String? huggingFaceToken, + String? modelPath, + LiteRtLmBackend? backend, + int? maxTokens, + }) async { initializeCalls += 1; } diff --git a/app/test/features/agent_chat/domain/services/agent_connector_registry_test.dart b/app/test/features/agent_chat/domain/services/agent_connector_registry_test.dart index 635f3984..16cabf08 100644 --- a/app/test/features/agent_chat/domain/services/agent_connector_registry_test.dart +++ b/app/test/features/agent_chat/domain/services/agent_connector_registry_test.dart @@ -83,7 +83,7 @@ void main() { }); expect(result.isError, true); - expect(result.errorCode, 'invalid_calendar_event'); + expect(result.errorCode, 'missing_date'); }); test( diff --git a/app/test/features/agent_chat/presentation/screens/model_library_screen_test.dart b/app/test/features/agent_chat/presentation/screens/model_library_screen_test.dart index 36792c45..55abf067 100644 --- a/app/test/features/agent_chat/presentation/screens/model_library_screen_test.dart +++ b/app/test/features/agent_chat/presentation/screens/model_library_screen_test.dart @@ -2,6 +2,7 @@ import 'package:airo_app/features/agent_chat/application/assistant_model_prefere import 'package:airo_app/features/agent_chat/data/services/assistant_runtime_service.dart'; import 'package:airo_app/features/agent_chat/domain/models/assistant_runtime_ids.dart'; import 'package:airo_app/features/agent_chat/presentation/screens/model_library_screen.dart'; +import 'package:core_ai/core_ai.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -77,6 +78,81 @@ void main() { expect(selected, isFalse); }, ); + + testWidgets('project cards do not overflow on narrow mobile widths', ( + tester, + ) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(360, 900); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + SharedPreferences.setMockInitialValues({}); + + final package = OfflineModelInfo( + id: 'mobile-actions-270m-litertlm', + name: 'MobileActions-270M', + family: ModelFamily.gemma, + fileSizeBytes: 276 * 1024 * 1024, + backendPreference: ModelBackendPreference.npu, + provider: AIProvider.gemma, + capabilities: const [ModelCapability.mobileActions], + learnMoreUrl: 'https://example.com/models/mobile-actions', + ); + + final candidate = AssistantModelCandidate( + id: 'litert-gemma', + name: 'Gemma mobile package', + runtime: 'LiteRT-LM local model', + description: + 'Default local package for planning, documents, and medium reasoning.', + bestFor: const [AssistantTask.chat], + tags: const ['Local', 'Downloadable', 'Gemma'], + privacyLabel: 'Prompt stays on device', + sizeLabel: '2 GB to 4 GB typical', + available: false, + actionLabel: 'Download package', + unavailableReason: + 'Set LITERT_LM_MODEL_PATH or LITERT_LM_MODEL_URL, or install a compatible local model.', + local: true, + opensModelManager: true, + package: package, + ); + + final state = AssistantModelLibraryState( + task: AssistantTask.chat, + deviceLabel: 'Pixel 9', + platformLabel: 'ANDROID', + candidates: [candidate], + recommended: candidate, + defaultPackages: {AssistantTask.chat: package}, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + assistantModelLibraryProvider.overrideWith((ref) async => state), + selectedAssistantModelIdProvider.overrideWith( + (ref) => _SelectedAssistantModelNotifier(), + ), + ], + child: MaterialApp( + home: ModelLibraryScreen( + onModelSelected: (_) {}, + onOpenModelManager: () {}, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('General Chat'), findsOneWidget); + expect(find.text('Download package'), findsWidgets); + expect(tester.takeException(), isNull); + }); } class _SelectedAssistantModelNotifier extends SelectedAssistantModelNotifier { From 929f6b7221dea9c29f964f67cf3d553e88cfd63b Mon Sep 17 00:00:00 2001 From: uday chauhan Date: Sun, 28 Jun 2026 13:02:50 +0530 Subject: [PATCH 2/2] feat(core-ai): implement native-backed model download manager with progress and storage tracking --- app/android/app/build.gradle.kts | 4 + app/android/app/src/main/AndroidManifest.xml | 12 +- .../main/kotlin/io/airo/app/MainActivity.kt | 6 + .../kotlin/io/airo/app/ModelDownloadPlugin.kt | 247 ++++++++++++ app/ios/Runner/AppDelegate.swift | 1 + app/ios/Runner/ModelDownloadPlugin.swift | 216 +++++++++++ .../core/ai/providers/download_providers.dart | 38 ++ .../core/services/gemini_nano_service.dart | 16 + packages/core_ai/lib/core_ai.dart | 1 + .../src/download/model_download_service.dart | 354 +++++++++++------- .../src/storage/model_storage_manager.dart | 111 ++++++ packages/core_ai/pubspec.yaml | 1 + .../download/model_download_service_test.dart | 174 +++++++++ .../test/llm/gemini_nano_client_test.dart | 1 - .../storage/model_storage_manager_test.dart | 139 +++++++ 15 files changed, 1190 insertions(+), 131 deletions(-) create mode 100644 app/android/app/src/main/kotlin/io/airo/app/ModelDownloadPlugin.kt create mode 100644 app/ios/Runner/ModelDownloadPlugin.swift create mode 100644 app/lib/core/ai/providers/download_providers.dart create mode 100644 packages/core_ai/lib/src/storage/model_storage_manager.dart create mode 100644 packages/core_ai/test/download/model_download_service_test.dart create mode 100644 packages/core_ai/test/storage/model_storage_manager_test.dart diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index e86fd578..cd8e221f 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -158,6 +158,10 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.11.0") + // WorkManager and OkHttp for background model downloading + implementation("androidx.work:work-runtime-ktx:2.9.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + } flutter { diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 8f83ae07..7528dad8 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + @@ -14,6 +15,8 @@ + + + + +