@tduncan / Cortana / commits / 559b9b8

refactor: implement background message persistence service, update foreground service type to dataSync, and add Opencode workflow

abdulmominsakib committed May 23, 2026 at 12:47 UTC 559b9b8b31d9e132ae64b06e8e45b1c124d28fd5
7 files changed +171 -10
.github/workflows/opencode.yml new
+33
@@ -0,0 +1,33 @@
1 +name: opencode
2 +
3 +on:
4 + issue_comment:
5 + types: [created]
6 + pull_request_review_comment:
7 + types: [created]
8 +
9 +jobs:
10 + opencode:
11 + if: |
12 + contains(github.event.comment.body, ' /oc') ||
13 + startsWith(github.event.comment.body, '/oc') ||
14 + contains(github.event.comment.body, ' /opencode') ||
15 + startsWith(github.event.comment.body, '/opencode')
16 + runs-on: ubuntu-latest
17 + permissions:
18 + id-token: write
19 + contents: read
20 + pull-requests: read
21 + issues: read
22 + steps:
23 + - name: Checkout repository
24 + uses: actions/checkout@v6
25 + with:
26 + persist-credentials: false
27 +
28 + - name: Run opencode
29 + uses: anomalyco/opencode/github@latest
30 + env:
31 + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
32 + with:
33 + model: opencode/deepseek-v4-flash-free
\ No newline at end of file
android/app/src/main/AndroidManifest.xml
+2 -2
@@ -2,7 +2,7 @@
2 xmlns:tools="http://schemas.android.com/tools">
3 <uses-permission android:name="android.permission.INTERNET" />
4 <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
5 - <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SHORT_SERVICE" />
5 + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
6 <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
7 <uses-permission android:name="android.permission.WAKE_LOCK" />
8
@@ -43,7 +43,7 @@
43 android:name=".ChatForegroundService"
44 android:enabled="true"
45 android:exported="false"
46 - android:foregroundServiceType="shortService" />
46 + android:foregroundServiceType="dataSync" />
47 </application>
48 <!-- Required to query activities that can process text, see:
49 https://developer.android.com/training/package-visibility and
lib/core/services/message_save_service.dart new
+103
@@ -0,0 +1,103 @@
1 +import 'dart:async';
2 +
3 +import '../../features/chat/data/models/message.dart';
4 +import '../../objectbox.g.dart';
5 +import '../logger/app_logger.dart';
6 +import '../storage/entities.dart';
7 +import '../storage/objectbox_store.dart';
8 +
9 +/// Batches [Message] saves and flushes them off the UI thread using
10 +/// ObjectBox's [Store.runInTransactionAsync].
11 +///
12 +/// Call [enqueue] from the streaming listener (fire-and-forget).
13 +/// Call [flush] when the stream ends or is cancelled to ensure the
14 +/// final message state is persisted before updating UI state.
15 +class MessageSaveService {
16 + final ObjectBoxStore _db;
17 +
18 + final List<Message> _queue = [];
19 + Timer? _flushTimer;
20 + bool _disposed = false;
21 +
22 + MessageSaveService(this._db);
23 +
24 + /// Enqueues [message] for a background save. Never blocks the caller.
25 + ///
26 + /// If the same message ID is already in the queue it is replaced
27 + /// (streaming updates supersede the previous checkpoint).
28 + void enqueue(Message message) {
29 + if (_disposed) return;
30 + final idx = _queue.indexWhere((m) => m.id == message.id);
31 + if (idx != -1) {
32 + _queue[idx] = message;
33 + } else {
34 + _queue.add(message);
35 + }
36 + // Start a periodic flush timer on the first enqueue.
37 + _flushTimer ??= Timer.periodic(
38 + const Duration(seconds: 1),
39 + (_) => _doFlush(),
40 + );
41 + }
42 +
43 + /// Drains the queue immediately. Awaiting this guarantees persistence
44 + /// before the caller updates final UI state.
45 + Future<void> flush() async {
46 + _flushTimer?.cancel();
47 + _flushTimer = null;
48 + await _doFlush();
49 + }
50 +
51 + Future<void> _doFlush() async {
52 + if (_queue.isEmpty) return;
53 + final batch = List<Message>.from(_queue);
54 + _queue.clear();
55 +
56 + try {
57 + await _db.store.runInTransactionAsync(TxMode.write, _writeBatch, batch);
58 + } catch (e) {
59 + Log.error('MessageSaveService flush error: $e');
60 + }
61 + }
62 +
63 + /// Runs inside a background isolate provided by ObjectBox.
64 + static void _writeBatch(Store store, List<Message> messages) {
65 + final box = store.box<MessageEntity>();
66 + final convBox = store.box<ConversationEntity>();
67 +
68 + for (final message in messages) {
69 + // Look up existing entity so we preserve the ObjectBox internal ID.
70 + final query = box.query(MessageEntity_.id.equals(message.id)).build();
71 + final existing = query.findFirst();
72 + query.close();
73 +
74 + final entity = MessageEntity.fromDomain(message);
75 + if (existing != null) {
76 + entity.internalId = existing.internalId;
77 + }
78 +
79 + // Re-establish the ToOne relation to the parent conversation.
80 + final convQuery = convBox
81 + .query(ConversationEntity_.id.equals(message.conversationId))
82 + .build();
83 + final convEntity = convQuery.findFirst();
84 + convQuery.close();
85 +
86 + if (convEntity != null) {
87 + entity.conversation.target = convEntity;
88 + entity.conversationUid = convEntity.id;
89 + } else {
90 + entity.conversationUid = message.conversationId;
91 + }
92 +
93 + box.put(entity);
94 + }
95 + }
96 +
97 + void dispose() {
98 + _disposed = true;
99 + _flushTimer?.cancel();
100 + _flushTimer = null;
101 + _queue.clear();
102 + }
103 +}
lib/features/chat/providers/chat_providers.dart
+18 -2
@@ -3,6 +3,7 @@ import 'dart:io';
3 import 'dart:math';
4
5 import '../../../core/logger/app_logger.dart';
6 +import '../../../core/services/message_save_service.dart';
7
8 import 'package:flutter_riverpod/flutter_riverpod.dart';
9 import '../../../core/providers/app_providers.dart';
@@ -362,10 +363,16 @@ class ChatNotifier extends Notifier<ChatState> {
363 int _lastSavedContentLength = 0;
364 int _lastSavedReasoningLength = 0;
365
366 + /// Batches checkpoint saves off the UI thread during streaming.
367 + MessageSaveService? _saveService;
368 +
369 @override
370 ChatState build() {
371 + final db = ref.read(databaseProvider);
372 + _saveService = MessageSaveService(db);
373 ref.onDispose(() {
374 _streamSubscription?.cancel();
375 + _saveService?.dispose();
376 });
377 return const ChatState();
378 }
@@ -640,7 +647,8 @@ class ChatNotifier extends Notifier<ChatState> {
647 _chunkCount++;
648
649 if (_shouldCheckpointSave(streamingAssistantMessage)) {
643 - await _saveMessage(streamingAssistantMessage);
650 + // Non-blocking enqueue — never stalls the stream listener.
651 + _saveService?.enqueue(streamingAssistantMessage);
652 _updateSavedMetrics(streamingAssistantMessage);
653 _resetCheckpointMetrics();
654 }
@@ -677,7 +685,8 @@ class ChatNotifier extends Notifier<ChatState> {
685 _chunkCount++;
686
687 if (_shouldCheckpointSave(streamingAssistantMessage)) {
680 - await _saveMessage(streamingAssistantMessage);
688 + // Non-blocking enqueue — never stalls the stream listener.
689 + _saveService?.enqueue(streamingAssistantMessage);
690 _updateSavedMetrics(streamingAssistantMessage);
691 _resetCheckpointMetrics();
692 }
@@ -762,6 +771,8 @@ class ChatNotifier extends Notifier<ChatState> {
771 }
772 },
773 onDone: () async {
774 + // Flush batched checkpoint saves before finalising UI state.
775 + await _saveService?.flush();
776 ref.read(chatBackgroundServiceProvider).stop();
777 final streamConvId = assistantMessage.conversationId;
778 final isCurrentContext = _currentConversationId == streamConvId;
@@ -858,6 +869,8 @@ class ChatNotifier extends Notifier<ChatState> {
869 _lastCheckpointTime = null;
870 _lastSavedContentLength = 0;
871 _lastSavedReasoningLength = 0;
872 + // Flush any batched saves before processing the error state.
873 + await _saveService?.flush();
874 final errorMessage = state.streamingMessage?.copyWith(
875 status: MessageStatus.error,
876 errorMessage: error.toString(),
@@ -1023,6 +1036,9 @@ class ChatNotifier extends Notifier<ChatState> {
1036 _lastSavedContentLength = 0;
1037 _lastSavedReasoningLength = 0;
1038
1039 + // Flush any in-flight checkpoint saves before writing the final state.
1040 + await _saveService?.flush();
1041 +
1042 final streamingMessage = state.streamingMessage;
1043 if (streamingMessage != null) {
1044 final finalMessage = streamingMessage.copyWith(
lib/features/on_device/data/model_downloader.dart
+5 -5
@@ -40,17 +40,18 @@ class ModelDownloader {
40
41 int receivedBytes = 0;
42 bool isResumed = false;
43 + int initialReceivedBytes = 0; // captured before download, used for session speed calc
44
45 // 3. Check for partial file to resume
46 if (await partialFile.exists()) {
47 receivedBytes = await partialFile.length();
48 + initialReceivedBytes = receivedBytes;
49 isResumed = receivedBytes > 0;
50 Log.info('Resuming download for ${model.id} from $receivedBytes bytes');
51 }
52
53 final startTime = DateTime.now();
54 DateTime lastProgressUpdate = DateTime.now();
53 - int bytesSinceLastUpdate = 0;
55
56 try {
57 // 4. Resolve redirects manually to handle Auth header safety
@@ -78,7 +79,6 @@ class ModelDownloader {
79 await for (final List<int> chunk in stream) {
80 sink.add(chunk);
81 receivedBytes += chunk.length;
81 - bytesSinceLastUpdate += chunk.length;
82
83 final now = DateTime.now();
84 final elapsedSinceLastUpdate = now.difference(lastProgressUpdate);
@@ -86,9 +86,10 @@ class ModelDownloader {
86 // Update progress every 500ms
87 if (elapsedSinceLastUpdate.inMilliseconds >= 500) {
88 final totalElapsed = now.difference(startTime);
89 - final bytesDownloadedInThisSession = receivedBytes - (isResumed ? (await partialFile.length() - bytesSinceLastUpdate) : 0);
90 -
89 // Use a simple bytes per second based on the current session's progress
90 + // Use in-memory initialReceivedBytes instead of calling partialFile.length() (stat syscall)
91 + final bytesDownloadedInThisSession =
92 + receivedBytes - (isResumed ? initialReceivedBytes : 0);
93 final bytesPerSecond = (bytesDownloadedInThisSession / totalElapsed.inMilliseconds * 1000).toInt();
94
95 final remainingBytes = totalBytes - receivedBytes;
@@ -113,7 +114,6 @@ class ModelDownloader {
114 );
115
116 lastProgressUpdate = now;
116 - bytesSinceLastUpdate = 0;
117 }
118 }
119
lib/features/on_device/data/on_device_model_download_service.dart
+1 -1
@@ -49,7 +49,7 @@ class OnDeviceModelDownloadService {
49 }
50
51 final List<DownloadedModel> result = [];
52 - final entities = dir.listSync();
52 + final entities = await dir.list().toList();
53 for (final entity in entities) {
54 if (entity is File && entity.path.endsWith('.litertlm')) {
55 final fileName = entity.path.split('/').last;
lib/features/on_device/providers/on_device_providers.dart
+9
@@ -11,6 +11,7 @@ import '../data/on_device_model_download_service.dart';
11 import '../data/foreground_download_service.dart';
12 import '../data/model_downloader.dart';
13 import '../data/download_notification_service.dart';
14 +import '../../../core/providers/chat_background_service_provider.dart';
15
16 final onDeviceEngineProvider =
17 NotifierProvider<OnDeviceEngineNotifier, OnDeviceEngineState>(() {
@@ -108,6 +109,11 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
109 Future<void> loadModel(String modelId, LiteLmBackendType backend) async {
110 state = state.copyWith(status: OnDeviceEngineStatus.loading, error: null);
111
112 + // Protect the heavy native model-load (mmap of 1–7 GB) from Android
113 + // CPU throttling by keeping a foreground service active for its duration.
114 + final bgService = ref.read(chatBackgroundServiceProvider);
115 + await bgService.start();
116 +
117 try {
118 final modelPath = await OnDeviceEngineService.getModelPath(modelId);
119 if (modelPath == null) {
@@ -134,6 +140,9 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
140 status: OnDeviceEngineStatus.error,
141 error: 'Failed to load model: ${e.toString()}',
142 );
143 + } finally {
144 + // Always stop the foreground service — even if load fails.
145 + await bgService.stop();
146 }
147 }
148