diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index aef434f..32d6a67 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -37,7 +37,7 @@
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="Nova"
-
+ android:largeHeap="true"
android:supportsRtl="true"
android:theme="@style/LaunchTheme"
tools:targetApi="34">
diff --git a/android/app/src/main/kotlin/dev/nova/assistant/MainActivity.kt b/android/app/src/main/kotlin/dev/nova/assistant/MainActivity.kt
index 8557cb2..d1cb610 100644
--- a/android/app/src/main/kotlin/dev/nova/assistant/MainActivity.kt
+++ b/android/app/src/main/kotlin/dev/nova/assistant/MainActivity.kt
@@ -82,6 +82,7 @@ class MainActivity : FlutterActivity() {
"pssKb" to pssKb,
"rssKb" to pssKb,
"availMemMb" to (info.availMem / (1024 * 1024)),
+ "totalMemMb" to (info.totalMem / (1024 * 1024)),
)
)
}
diff --git a/android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt b/android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt
index 9393b1d..dfedb73 100644
--- a/android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt
+++ b/android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt
@@ -1,9 +1,173 @@
package dev.nova.assistant
import android.app.Application
+import android.util.Log
+import java.io.BufferedInputStream
+import java.io.BufferedOutputStream
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+/**
+ * Scrubs oversized chat-history keys from Flutter SharedPreferences **without**
+ * calling [getSharedPreferences]. Loading a 100MB+ prefs XML OOMs during
+ * KXmlParser (POCO F1 / mid-RAM devices).
+ */
class NovaApplication : Application() {
override fun onCreate() {
super.onCreate()
+ scrubOversizedFlutterPrefs()
}
-}
\ No newline at end of file
+
+ private fun scrubOversizedFlutterPrefs() {
+ val prefsFile = File(dataDir, "shared_prefs/$FLUTTER_PREFS.xml")
+ val size = if (prefsFile.exists()) prefsFile.length() else 0L
+ // #region agent log
+ Log.i(
+ TAG,
+ "AGENT_DBG session=7abc09 hyp=A scrub start size=$size path=${prefsFile.absolutePath}",
+ )
+ // #endregion
+ if (!prefsFile.exists() || size == 0L) return
+
+ try {
+ var removed = false
+ for (key in listOf(CONVERSATIONS_KEY, OLD_CHAT_KEY)) {
+ if (streamRemoveStringEntry(prefsFile, key)) {
+ removed = true
+ // #region agent log
+ Log.i(TAG, "AGENT_DBG session=7abc09 hyp=A removed key=$key")
+ // #endregion
+ }
+ }
+
+ val after = prefsFile.length()
+ // #region agent log
+ Log.i(
+ TAG,
+ "AGENT_DBG session=7abc09 hyp=A scrub done removed=$removed sizeAfter=$after",
+ )
+ // #endregion
+
+ if (after > MAX_SAFE_PREFS_BYTES) {
+ // Do not copyTo() a huge file (that also OOMs). Just replace.
+ prefsFile.delete()
+ prefsFile.writeText(EMPTY_PREFS_XML)
+ // #region agent log
+ Log.w(
+ TAG,
+ "AGENT_DBG session=7abc09 hyp=A wiped prefs after=$after",
+ )
+ // #endregion
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "AGENT_DBG session=7abc09 hyp=A scrub failed", e)
+ try {
+ prefsFile.writeText(EMPTY_PREFS_XML)
+ } catch (_: Exception) {
+ // User must clear app data.
+ }
+ }
+ }
+
+ /**
+ * Removes `…` via streaming so the huge value
+ * is never materialized as a Java String.
+ */
+ private fun streamRemoveStringEntry(prefsFile: File, key: String): Boolean {
+ val open = """""".toByteArray(Charsets.UTF_8)
+ val close = "".toByteArray(Charsets.UTF_8)
+ val tmp = File(prefsFile.parent, "${prefsFile.name}.tmp")
+ var found = false
+
+ FileInputStream(prefsFile).use { fis ->
+ BufferedInputStream(fis, 256 * 1024).use { input ->
+ FileOutputStream(tmp).use { fos ->
+ BufferedOutputStream(fos, 256 * 1024).use { output ->
+ val buffer = ByteArray(256 * 1024)
+ val pending = ArrayList(open.size)
+ var openIdx = 0
+ var skipping = false
+ var closeIdx = 0
+
+ while (true) {
+ val n = input.read(buffer)
+ if (n < 0) break
+ var i = 0
+ while (i < n) {
+ val byte = buffer[i]
+
+ if (skipping) {
+ if (byte == close[closeIdx]) {
+ closeIdx++
+ if (closeIdx == close.size) {
+ skipping = false
+ closeIdx = 0
+ found = true
+ }
+ } else {
+ closeIdx = if (byte == close[0]) 1 else 0
+ }
+ i++
+ continue
+ }
+
+ if (byte == open[openIdx]) {
+ pending.add(byte)
+ openIdx++
+ if (openIdx == open.size) {
+ skipping = true
+ openIdx = 0
+ pending.clear()
+ }
+ i++
+ continue
+ }
+
+ if (openIdx > 0) {
+ for (p in pending) {
+ output.write(p.toInt())
+ }
+ pending.clear()
+ openIdx = 0
+ continue
+ }
+
+ output.write(byte.toInt())
+ i++
+ }
+ }
+
+ if (!skipping) {
+ for (p in pending) {
+ output.write(p.toInt())
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (found) {
+ if (!tmp.renameTo(prefsFile)) {
+ tmp.copyTo(prefsFile, overwrite = true)
+ tmp.delete()
+ }
+ } else {
+ tmp.delete()
+ }
+
+ return found
+ }
+
+ companion object {
+ private const val TAG = "NovaApplication"
+ private const val FLUTTER_PREFS = "FlutterSharedPreferences"
+ private const val CONVERSATIONS_KEY = "flutter.conversations"
+ private const val OLD_CHAT_KEY = "flutter.chat_history"
+ private const val MAX_SAFE_PREFS_BYTES = 4L * 1024L * 1024L
+ private const val EMPTY_PREFS_XML =
+ "\n" +
+ "\n"
+ }
+}
diff --git a/docs/models.md b/docs/models.md
index 344059f..bf00699 100644
--- a/docs/models.md
+++ b/docs/models.md
@@ -57,4 +57,24 @@ When a thread gets long, **Compact context** (manual button in chat) or **Auto-c
| 8 GB+ | Gemma 4 E2B OK; close heavy apps first |
| Debug all day | Enable battery/idle unload; avoid continuous screen capture |
+Onboarding uses **total device RAM** (not only free RAM): phones under ~6.5 GB
+default to **SmolLM** so a wiped POCO F1 does not start a 2.4 GB Gemma 4 download
+it cannot load for chat.
+
Android idle unload is shorter (~2 minutes) to reduce LMK pressure.
+
+## On-device image generation (not in this build)
+
+Repos such as
+[FLUX.2-klein-4B-LiteRT](https://huggingface.co/litert-community/FLUX.2-klein-4B-LiteRT)
+and
+[Z-Image-Turbo-LiteRT](https://huggingface.co/litert-community/Z-Image-Turbo-LiteRT)
+are **LiteRT `CompiledModel` multi-graph diffusion pipelines** (many `.tflite`
+chunks + host tokenizer/scheduler), not `flutter_gemma` / LiteRT-LM chat
+(`.litertlm` / `.task`) models.
+
+Nova’s stack only runs LLM/VLM chat via `flutter_gemma`. Shipping those image
+models would mean a separate native LiteRT GPU pipeline, multi‑GB downloads, and
+phones in the Pixel 8a / 8 GB+ class for usable demos — out of scope for the
+current APK (and unsuitable for POCO F1). Prefer **remote LAN** or a PC host
+for image generation for now.
diff --git a/lib/main.dart b/lib/main.dart
index 88ebd12..768390b 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -27,13 +27,28 @@ import 'package:nova_assistant/services/chat_history_service.dart';
import 'package:nova_assistant/services/widget_service.dart';
import 'package:nova_assistant/models/conversation.dart';
import 'package:nova_assistant/models/user_preferences.dart';
+import 'package:nova_assistant/utils/agent_debug_log.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
try {
+ // #region agent log
+ await AgentDebugLog.log(
+ hypothesisId: 'A',
+ location: 'main.dart:beforePrefs',
+ message: 'About to call SharedPreferences.getInstance',
+ );
+ // #endregion
await SharedPreferences.getInstance();
+ // #region agent log
+ await AgentDebugLog.log(
+ hypothesisId: 'A',
+ location: 'main.dart:afterPrefs',
+ message: 'SharedPreferences.getInstance succeeded',
+ );
+ // #endregion
await FlutterGemma.initialize(
inferenceEngines: const [LiteRtLmEngine(), MediaPipeEngine()],
@@ -55,6 +70,7 @@ void main() async {
await TtsService.instance.initialize();
await PromptPresetsService.instance.initialize();
+ await ModelOrchestrator.instance.applyRamAwareModelDefaults();
_prefetchModels();
} catch (e) {
debugPrint('Initialization error: $e');
diff --git a/lib/screens/assistant_screen.dart b/lib/screens/assistant_screen.dart
index 996ce0a..9d68383 100644
--- a/lib/screens/assistant_screen.dart
+++ b/lib/screens/assistant_screen.dart
@@ -1943,11 +1943,14 @@ class _AssistantScreenState extends State
),
),
const SizedBox(width: 6),
- Text(
- _status,
- style: TextStyle(fontSize: 11, color: Colors.grey[500]),
+ Expanded(
+ child: Text(
+ _status,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(fontSize: 11, color: Colors.grey[500]),
+ ),
),
- const Spacer(),
if (_thinkingMode)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
diff --git a/lib/screens/onboarding/beginner/model_download_screen.dart b/lib/screens/onboarding/beginner/model_download_screen.dart
index 62d2076..822d6a1 100644
--- a/lib/screens/onboarding/beginner/model_download_screen.dart
+++ b/lib/screens/onboarding/beginner/model_download_screen.dart
@@ -4,7 +4,10 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:nova_assistant/models/model_info.dart';
import 'package:nova_assistant/models/user_preferences.dart';
+import 'package:nova_assistant/services/memory_diagnostics_service.dart';
import 'package:nova_assistant/services/model_manager.dart';
+import 'package:nova_assistant/services/model_orchestrator.dart';
+import 'package:nova_assistant/services/platform_adaptation_service.dart';
import 'package:nova_assistant/services/user_preferences_service.dart';
import 'package:nova_assistant/utils/agent_debug_log.dart';
@@ -18,29 +21,68 @@ class ModelDownloadScreen extends StatefulWidget {
}
class _ModelDownloadScreenState extends State {
- static const _targetModel = NovaModel.gemma4E2b;
+ NovaModel _targetModel = NovaModel.smollm;
+ bool _resolvedTarget = false;
double _progress = 0;
- String _status = 'Preparing...';
+ String _status = 'Checking device memory...';
bool _isComplete = false;
bool _hasError = false;
String _errorMessage = '';
bool _isBusy = false;
+ String get _capabilitiesLabel {
+ final parts = [];
+ if (_targetModel.hasVision) parts.add('Vision');
+ if (_targetModel.hasThinking) parts.add('Thinking');
+ parts.add('Chat');
+ return parts.join(' + ');
+ }
+
@override
void initState() {
super.initState();
- _checkExisting();
+ _resolveTargetAndCheck();
}
Future _finishReady() async {
await UserPreferencesService.instance.setMode(UserMode.beginner);
+ ModelOrchestrator.instance.preferredModelType = _targetModel;
+ ModelOrchestrator.instance.selector.primaryHeavy = _targetModel;
+ ModelOrchestrator.instance.selector.fastModel = NovaModel.smollm;
await Future.delayed(const Duration(milliseconds: 800));
if (mounted) {
widget.onDownloadComplete();
}
}
+ Future _resolveTargetAndCheck() async {
+ final recommended = await PlatformAdaptationService.instance
+ .recommendModelForDevice();
+ // #region agent log
+ await AgentDebugLog.log(
+ hypothesisId: 'E',
+ location: 'model_download_screen.dart:_resolveTargetAndCheck',
+ message: 'Onboarding model recommendation',
+ data: {
+ 'recommended': recommended.displayName,
+ 'sizeMB': recommended.sizeMB,
+ 'availMemMb': MemoryDiagnosticsService.instance.lastAvailMemMb,
+ 'totalMemMb': MemoryDiagnosticsService.instance.lastTotalMemMb,
+ },
+ runId: 'post-fix',
+ );
+ // #endregion
+
+ if (!mounted) return;
+ setState(() {
+ _targetModel = recommended;
+ _resolvedTarget = true;
+ _status = 'Choose how to install ${recommended.displayName}';
+ });
+ await _checkExisting();
+ }
+
Future _checkExisting() async {
final fileName = ModelHuggingFaceURLs.fileNameFor(_targetModel);
final path = await ModelManager.instance.findModelPath(fileName);
@@ -69,7 +111,7 @@ class _ModelDownloadScreenState extends State {
if (mounted) {
setState(() {
- _status = 'Choose how to install Gemma 4 E2B';
+ _status = 'Choose how to install ${_targetModel.displayName}';
_progress = 0;
});
}
@@ -80,7 +122,7 @@ class _ModelDownloadScreenState extends State {
_isBusy = true;
_progress = 0;
_hasError = false;
- _status = 'Downloading Gemma 4 E2B...';
+ _status = 'Downloading ${_targetModel.displayName}...';
});
try {
@@ -157,7 +199,7 @@ class _ModelDownloadScreenState extends State {
if (mounted) {
setState(() {
_isBusy = false;
- _status = 'Choose how to install Gemma 4 E2B';
+ _status = 'Choose how to install ${_targetModel.displayName}';
});
}
return;
@@ -188,8 +230,8 @@ class _ModelDownloadScreenState extends State {
_isBusy = false;
_hasError = true;
_errorMessage =
- 'Import failed. Select a .litertlm file '
- '(e.g. gemma-4-E2B-it.litertlm).';
+ 'Import failed. Select a matching model file '
+ '(e.g. ${ModelHuggingFaceURLs.fileNameFor(_targetModel)}).';
});
}
} catch (e) {
@@ -205,6 +247,12 @@ class _ModelDownloadScreenState extends State {
@override
Widget build(BuildContext context) {
+ final sizeLabel = '~${_targetModel.sizeMB} MB';
+ final subtitle = _resolvedTarget
+ ? '${_targetModel.displayName} — download ($sizeLabel) or import a\n'
+ 'model file you already have. Chosen for this device\'s free RAM.'
+ : 'Checking free RAM to pick a model that can actually load...';
+
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
@@ -239,8 +287,7 @@ class _ModelDownloadScreenState extends State {
),
const SizedBox(height: 12),
Text(
- 'Gemma 4 E2B — download (~2.4 GB) or import a\n'
- '.litertlm file you already have.',
+ subtitle,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey[400],
@@ -249,7 +296,7 @@ class _ModelDownloadScreenState extends State {
),
),
const SizedBox(height: 48),
- _buildModelInfo(),
+ _buildModelInfo(sizeLabel),
const SizedBox(height: 32),
if (_hasError) ...[
Container(
@@ -276,7 +323,7 @@ class _ModelDownloadScreenState extends State {
SizedBox(
width: double.infinity,
child: FilledButton(
- onPressed: _isBusy ? null : _downloadModel,
+ onPressed: _isBusy || !_resolvedTarget ? null : _downloadModel,
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
padding: const EdgeInsets.symmetric(vertical: 14),
@@ -291,14 +338,16 @@ class _ModelDownloadScreenState extends State {
),
),
TextButton(
- onPressed: _isBusy ? null : _importFromStorage,
+ onPressed: _isBusy || !_resolvedTarget
+ ? null
+ : _importFromStorage,
child: const Text('Import from storage instead'),
),
] else if (!_isComplete && !_isBusy) ...[
SizedBox(
width: double.infinity,
child: FilledButton(
- onPressed: _downloadModel,
+ onPressed: !_resolvedTarget ? null : _downloadModel,
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
padding: const EdgeInsets.symmetric(vertical: 14),
@@ -316,7 +365,7 @@ class _ModelDownloadScreenState extends State {
SizedBox(
width: double.infinity,
child: OutlinedButton(
- onPressed: _importFromStorage,
+ onPressed: !_resolvedTarget ? null : _importFromStorage,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: BorderSide(color: Colors.white.withValues(alpha: 0.3)),
@@ -334,13 +383,16 @@ class _ModelDownloadScreenState extends State {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- Text(
- _status,
- style: TextStyle(
- color: _isComplete
- ? const Color(0xFF4CAF50)
- : Colors.grey[400],
- fontSize: 13,
+ Flexible(
+ child: Text(
+ _status,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: _isComplete
+ ? const Color(0xFF4CAF50)
+ : Colors.grey[400],
+ fontSize: 13,
+ ),
),
),
Text(
@@ -389,7 +441,7 @@ class _ModelDownloadScreenState extends State {
)
else if (!_hasError && !_isBusy)
Text(
- 'Tip: If you already have the .litertlm file, use Import.',
+ 'Tip: If you already have the model file, use Import.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
@@ -399,7 +451,7 @@ class _ModelDownloadScreenState extends State {
);
}
- Widget _buildModelInfo() {
+ Widget _buildModelInfo(String sizeLabel) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@@ -409,13 +461,17 @@ class _ModelDownloadScreenState extends State {
),
child: Column(
children: [
- _buildInfoRow('Model', 'Gemma 4 E2B', Icons.model_training_outlined),
+ _buildInfoRow(
+ 'Model',
+ _targetModel.displayName,
+ Icons.model_training_outlined,
+ ),
const SizedBox(height: 12),
- _buildInfoRow('Size', '~2400 MB', Icons.storage_outlined),
+ _buildInfoRow('Size', sizeLabel, Icons.storage_outlined),
const SizedBox(height: 12),
_buildInfoRow(
'Capabilities',
- 'Vision + Thinking + Function Calling',
+ _capabilitiesLabel,
Icons.auto_awesome_outlined,
),
],
diff --git a/lib/services/chat_history_service.dart b/lib/services/chat_history_service.dart
index abe1ac1..a807802 100644
--- a/lib/services/chat_history_service.dart
+++ b/lib/services/chat_history_service.dart
@@ -1,40 +1,133 @@
import 'dart:convert';
+import 'dart:io';
import 'package:flutter/foundation.dart';
+import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:nova_assistant/models/chat_message.dart';
import 'package:nova_assistant/models/conversation.dart';
+/// Persists conversations as a JSON file — never in SharedPreferences.
+///
+/// Storing chat (especially base64 screenshots) in prefs OOMs Android when
+/// Flutter decodes the prefs map across the platform channel (~256 MB Java
+/// heap). Native [NovaApplication] migrates/drops the old prefs key on boot.
class ChatHistoryService {
static const _oldKey = 'chat_history';
static const _key = 'conversations';
- static const _migratedKey = 'conversations_migrated';
+ static const _fileName = 'conversations.json';
+ static const _maxConversations = 40;
+ static const _maxMessagesPerConversation = 200;
+
+ /// Soft cap for the on-disk blob; oversized files are trimmed on load.
+ static const _maxFileBytes = 4 * 1024 * 1024;
+
static SharedPreferences? _prefs;
static List? _cachedConversations;
+ static File? _file;
static Future get _p async =>
_prefs ??= await SharedPreferences.getInstance();
- static Future ensureMigrated() async {
- final p = await _p;
- final alreadyMigrated = p.getBool(_migratedKey) ?? false;
- if (alreadyMigrated) return;
+ static Future _conversationsFile() async {
+ if (_file != null) return _file!;
+ final dir = await getApplicationDocumentsDirectory();
+ _file = File('${dir.path}/$_fileName');
+
+ return _file!;
+ }
+
+ /// Strips screenshot bytes — they bloated prefs to 100MB+ and crash startup.
+ static Map _messageToPersistJson(ChatMessage message) {
+ final json = message.toJson();
+ json['imageData'] = null;
+
+ return json;
+ }
+
+ static Conversation _sanitizeConversation(Conversation conversation) {
+ final messages = conversation.messages.map((m) {
+ if (m.imageData == null || m.imageData!.isEmpty) return m;
+ final text = m.text.trim().isEmpty ? '[Screenshot]' : m.text;
- final oldJson = p.getString(_oldKey);
- if (oldJson != null && oldJson.isNotEmpty) {
+ return ChatMessage(
+ id: m.id,
+ text: text,
+ isUser: m.isUser,
+ timestamp: m.timestamp,
+ modelName: m.modelName,
+ isStreaming: m.isStreaming,
+ isError: m.isError,
+ thinking: m.thinking,
+ toolCalls: m.toolCalls,
+ inferenceTimeMs: m.inferenceTimeMs,
+ reactions: m.reactions,
+ );
+ }).toList();
+ final trimmed = messages.length > _maxMessagesPerConversation
+ ? messages.sublist(messages.length - _maxMessagesPerConversation)
+ : messages;
+
+ return conversation.copyWith(messages: trimmed);
+ }
+
+ static List _capConversations(List list) {
+ final sorted = List.from(list)
+ ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
+ if (sorted.length <= _maxConversations) {
+ return sorted.map(_sanitizeConversation).toList();
+ }
+
+ return sorted.take(_maxConversations).map(_sanitizeConversation).toList();
+ }
+
+ static Future ensureMigrated() async {
+ final file = await _conversationsFile();
+ if (await file.exists() && await file.length() > 0) {
+ // Drop leftover prefs keys if native migrate already wrote the file.
try {
- final list = jsonDecode(oldJson) as List;
- if (list.isNotEmpty) {
- final messages = list
- .map((e) => ChatMessage.fromJson(e as Map))
- .toList();
- final conversation = Conversation(messages: messages);
- await _saveConversationsInternal([conversation]);
- await p.remove(_oldKey);
+ final p = await _p;
+ await p.remove(_key);
+ await p.remove(_oldKey);
+ } on Exception catch (e) {
+ debugPrint('ChatHistoryService: prefs cleanup after file migrate: $e');
+ }
+
+ return;
+ }
+
+ // Legacy path: small prefs payloads only (native drops oversized ones).
+ try {
+ final p = await _p;
+ final json = p.getString(_key);
+ if (json != null && json.isNotEmpty) {
+ await file.writeAsString(json);
+ await p.remove(_key);
+ debugPrint(
+ 'ChatHistoryService: migrated conversations prefs → file '
+ '(${json.length} chars)',
+ );
+ }
+
+ final oldJson = p.getString(_oldKey);
+ if (oldJson != null && oldJson.isNotEmpty) {
+ try {
+ final list = jsonDecode(oldJson) as List;
+ if (list.isNotEmpty) {
+ final messages = list
+ .map((e) => ChatMessage.fromJson(e as Map))
+ .toList();
+ final conversation = Conversation(messages: messages);
+ await _saveConversationsInternal([conversation]);
+ }
+ } on Exception catch (e) {
+ debugPrint('ChatHistoryService: old chat_history migrate failed: $e');
}
- } catch (_) {}
+ await p.remove(_oldKey);
+ }
+ } on Exception catch (e) {
+ debugPrint('ChatHistoryService.ensureMigrated prefs read failed: $e');
}
- await p.setBool(_migratedKey, true);
}
static Future> loadConversations() async {
@@ -43,20 +136,49 @@ class ChatHistoryService {
}
await ensureMigrated();
try {
- final p = await _p;
- final json = p.getString(_key);
- if (json == null || json.isEmpty) {
+ final file = await _conversationsFile();
+ if (!await file.exists()) {
_cachedConversations = [];
+
return [];
}
+
+ final length = await file.length();
+ if (length == 0) {
+ _cachedConversations = [];
+
+ return [];
+ }
+
+ if (length > _maxFileBytes * 4) {
+ debugPrint(
+ 'ChatHistoryService: conversations file too large '
+ '($length bytes) — resetting to avoid OOM',
+ );
+ await file.delete();
+ _cachedConversations = [];
+
+ return [];
+ }
+
+ final json = await file.readAsString();
final list = jsonDecode(json) as List;
- _cachedConversations = list
+ var conversations = list
.map((e) => Conversation.fromJson(e as Map))
.toList();
- _cachedConversations!.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
+ conversations = _capConversations(conversations);
+ _cachedConversations = conversations;
+
+ // Rewrite stripped/capped form so the blob shrinks on disk.
+ if (length > _maxFileBytes) {
+ await _saveConversationsInternal(conversations);
+ }
+
return List.unmodifiable(_cachedConversations!);
- } catch (_) {
+ } on Exception catch (e) {
+ debugPrint('ChatHistoryService.loadConversations error: $e');
_cachedConversations = [];
+
return [];
}
}
@@ -65,11 +187,31 @@ class ChatHistoryService {
List conversations,
) async {
try {
- final p = await _p;
- final json = jsonEncode(conversations.map((c) => c.toJson()).toList());
- await p.setString(_key, json);
- _cachedConversations = conversations;
- } catch (e) {
+ final capped = _capConversations(conversations);
+ final encoded = jsonEncode(
+ capped
+ .map(
+ (c) => {
+ 'id': c.id,
+ 'title': c.title,
+ 'messages': c.messages.map(_messageToPersistJson).toList(),
+ 'createdAt': c.createdAt.toIso8601String(),
+ 'updatedAt': c.updatedAt.toIso8601String(),
+ 'summary': c.summary,
+ },
+ )
+ .toList(),
+ );
+ final file = await _conversationsFile();
+ await file.writeAsString(encoded);
+ _cachedConversations = capped;
+
+ // Ensure prefs never holds the blob again.
+ try {
+ final p = await _p;
+ if (p.containsKey(_key)) await p.remove(_key);
+ } on Exception catch (_) {}
+ } on Exception catch (e) {
debugPrint('ChatHistoryService._saveConversationsInternal error: $e');
}
}
@@ -85,6 +227,7 @@ class ChatHistoryService {
final conversation = Conversation(title: title);
final updated = [conversation, ...conversations];
await _saveConversationsInternal(updated);
+
return conversation;
}
@@ -106,6 +249,7 @@ class ChatHistoryService {
static Future getConversation(String id) async {
final conversations = await loadConversations();
+
return conversations.where((c) => c.id == id).firstOrNull;
}
@@ -127,32 +271,11 @@ class ChatHistoryService {
}
}
- static Future updateMessage(
- String conversationId,
- ChatMessage message,
- ) async {
- final conversations = await loadConversations();
- final convoIndex = conversations.indexWhere((c) => c.id == conversationId);
- if (convoIndex != -1) {
- final conversation = conversations[convoIndex];
- final messages = List.from(conversation.messages);
- final msgIndex = messages.indexWhere((m) => m.id == message.id);
- if (msgIndex != -1) {
- messages[msgIndex] = message;
- final updated = List.from(conversations);
- updated[convoIndex] = conversation.copyWith(
- messages: messages,
- updatedAt: DateTime.now(),
- );
- await _saveConversationsInternal(updated);
- }
- }
- }
-
static Future> load() async {
final conversations = await loadConversations();
if (conversations.isEmpty) return [];
final first = conversations.first;
+
return first.messages;
}
@@ -181,8 +304,21 @@ class ChatHistoryService {
static Future clear() async {
_cachedConversations = [];
- final p = await _p;
- await p.remove(_key);
+ try {
+ final file = await _conversationsFile();
+ if (await file.exists()) await file.delete();
+ } on Exception catch (e) {
+ debugPrint('ChatHistoryService.clear file error: $e');
+ } finally {
+ _file = null;
+ }
+ try {
+ final p = await _p;
+ await p.remove(_key);
+ await p.remove(_oldKey);
+ } on Exception catch (e) {
+ debugPrint('ChatHistoryService.clear prefs error: $e');
+ }
}
/// Builds a plain-text export of all conversations (no file write).
diff --git a/lib/services/memory_diagnostics_service.dart b/lib/services/memory_diagnostics_service.dart
index 7a4b914..d1bc064 100644
--- a/lib/services/memory_diagnostics_service.dart
+++ b/lib/services/memory_diagnostics_service.dart
@@ -15,12 +15,31 @@ class MemoryDiagnosticsService {
int? _lastPssKb;
int? _lastRssKb;
int? _lastAvailMemMb;
+ int? _lastTotalMemMb;
int? get lastPssMb =>
_lastPssKb != null ? (_lastPssKb! / 1024).round() : null;
int? get lastRssMb =>
_lastRssKb != null ? (_lastRssKb! / 1024).round() : null;
int? get lastAvailMemMb => _lastAvailMemMb;
+ int? get lastTotalMemMb => _lastTotalMemMb;
+
+ void _ingestMemoryMap(Map