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