fix: SharedPreferences OOM + low-RAM onboarding for mid-range devices#6
Conversation
|
Reviewer's GuideMoves chat history persistence from SharedPreferences to a capped JSON file on disk, strips screenshot bytes to prevent OOMs, adds native-side migration/cleanup of oversized preferences before Flutter starts, and updates tests and Android manifest accordingly. Sequence diagram for startup migration and chat history loadingsequenceDiagram
participant NA as NovaApplication
participant SP as SharedPreferences
participant FS as conversations.json
participant CHS as ChatHistoryService
NA->>SP: getSharedPreferences(FLUTTER_PREFS)
NA->>SP: getString(CONVERSATIONS_KEY)
alt value small enough
NA->>FS: writeText(value)
NA->>SP: edit().remove(CONVERSATIONS_KEY).apply()
else value too large
NA->>SP: edit().remove(CONVERSATIONS_KEY).apply()
end
NA->>SP: edit().remove(OLD_CHAT_KEY).apply()
CHS->>CHS: loadConversations()
CHS->>CHS: ensureMigrated()
CHS->>FS: _conversationsFile()
alt file exists and nonempty
CHS->>FS: length()
alt length > _maxFileBytes * 4
CHS->>FS: delete()
CHS-->>CHS: return []
else ok size
CHS->>FS: readAsString()
CHS-->>CHS: _capConversations()
CHS->>FS: writeAsString(encoded) [if length > _maxFileBytes]
end
else no file
CHS-->>CHS: return []
end
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Move conversations to a documents JSON file, drop oversized prefs keys natively before Flutter starts, and never persist screenshot bytes. The crash was a 122MB prefs string decoding against the 256MB Java heap, not system RAM. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Hey - I've found 3 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="lib/services/chat_history_service.dart" line_range="191-204" />
<code_context>
- 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,
+ },
+ )
</code_context>
<issue_to_address>
**suggestion:** Manually constructing the JSON map for `Conversation` risks divergence from `Conversation.toJson()` over time.
This recreates the JSON structure of `Conversation` and `ChatMessage` instead of using their `toJson()` methods. Any new/renamed fields in those models would also need to be updated here or they’ll be missing from persistence. Consider starting from `c.toJson()` and then adjusting only the fields that need special handling (e.g. `messages` via `_messageToPersistJson`, date formatting, etc.) so the main mapping logic lives in one place.
```suggestion
final encoded = jsonEncode(
capped
.map(
(c) {
final baseJson = c.toJson();
return {
...baseJson,
'messages':
c.messages.map(_messageToPersistJson).toList(),
'createdAt': c.createdAt.toIso8601String(),
'updatedAt': c.updatedAt.toIso8601String(),
};
},
)
.toList(),
);
```
</issue_to_address>
### Comment 2
<location path="lib/services/chat_history_service.dart" line_range="153-143" />
<code_context>
+ return [];
+ }
+
+ if (length > _maxFileBytes * 4) {
+ debugPrint(
+ 'ChatHistoryService: conversations file too large '
+ '($length bytes) — resetting to avoid OOM',
+ );
+ await file.delete();
+ _cachedConversations = [];
+
+ return [];
+ }
+
</code_context>
<issue_to_address>
**nitpick:** The `length > _maxFileBytes * 4` threshold is a bit magic; consider making the intent clearer or tying it to a named constant.
This introduces two implicit thresholds: one where you delete/reset (`4x`) and one where you only rewrite (`1x`). Consider defining these as two named limits (e.g. `_maxFileBytesSoftLimit`, `_maxFileBytesHardLimit`) or adding a brief comment explaining why `4x` is the reset point so future tuning is straightforward.
</issue_to_address>
### Comment 3
<location path="android/app/src/main/AndroidManifest.xml" line_range="40" />
<code_context>
android:icon="@mipmap/ic_launcher"
android:label="Nova"
-
+ android:largeHeap="true"
android:supportsRtl="true"
android:theme="@style/LaunchTheme"
</code_context>
<issue_to_address>
**question (performance):** Enabling `android:largeHeap` has system-wide implications; verify it’s truly needed after the migration changes.
With blobs now out of Flutter prefs and conversations moved to capped disk storage, the main OOM risk should already be reduced. Since `android:largeHeap` is generally discouraged due to higher memory footprint, please profile memory usage with the new scheme first and only keep this flag if you still observe OOMs under realistic loads.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| 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(), | ||
| ); |
There was a problem hiding this comment.
suggestion: Manually constructing the JSON map for Conversation risks divergence from Conversation.toJson() over time.
This recreates the JSON structure of Conversation and ChatMessage instead of using their toJson() methods. Any new/renamed fields in those models would also need to be updated here or they’ll be missing from persistence. Consider starting from c.toJson() and then adjusting only the fields that need special handling (e.g. messages via _messageToPersistJson, date formatting, etc.) so the main mapping logic lives in one place.
| 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 encoded = jsonEncode( | |
| capped | |
| .map( | |
| (c) { | |
| final baseJson = c.toJson(); | |
| return { | |
| ...baseJson, | |
| 'messages': | |
| c.messages.map(_messageToPersistJson).toList(), | |
| 'createdAt': c.createdAt.toIso8601String(), | |
| 'updatedAt': c.updatedAt.toIso8601String(), | |
| }; | |
| }, | |
| ) | |
| .toList(), | |
| ); |
| if (!await file.exists()) { | ||
| _cachedConversations = []; | ||
|
|
||
| return []; |
There was a problem hiding this comment.
nitpick: The length > _maxFileBytes * 4 threshold is a bit magic; consider making the intent clearer or tying it to a named constant.
This introduces two implicit thresholds: one where you delete/reset (4x) and one where you only rewrite (1x). Consider defining these as two named limits (e.g. _maxFileBytesSoftLimit, _maxFileBytesHardLimit) or adding a brief comment explaining why 4x is the reset point so future tuning is straightforward.
| android:icon="@mipmap/ic_launcher" | ||
| android:label="Nova" | ||
|
|
||
| android:largeHeap="true" |
There was a problem hiding this comment.
question (performance): Enabling android:largeHeap has system-wide implications; verify it’s truly needed after the migration changes.
With blobs now out of Flutter prefs and conversations moved to capped disk storage, the main OOM risk should already be reduced. Since android:largeHeap is generally discouraged due to higher memory footprint, please profile memory usage with the new scheme first and only keep this flag if you still observe OOMs under realistic loads.
There was a problem hiding this comment.
Code Review
This pull request migrates chat history storage from SharedPreferences to a local JSON file to prevent Out-Of-Memory (OOM) crashes on startup, while also introducing native migration logic, stripping screenshot bytes, and capping conversation sizes. The review feedback highlights critical issues including potential startup crashes on older Android devices due to API incompatibility with dataDir, silent data loss from deleting legacy keys prematurely, and risks of data corruption due to non-atomic file writes in both Kotlin and Dart. Additionally, the reviewer recommends removing the redundant largeHeap attribute and cleaning up unused constants.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| private fun migrateConversationsOutOfPrefs() { | ||
| try { | ||
| val prefs = getSharedPreferences(FLUTTER_PREFS, MODE_PRIVATE) | ||
| val value = prefs.getString(CONVERSATIONS_KEY, null) | ||
| val oldValue = prefs.getString(OLD_CHAT_KEY, null) | ||
| if (value == null && oldValue == null) return | ||
|
|
||
| val outDir = File(dataDir, "app_flutter") | ||
| if (!outDir.exists()) outDir.mkdirs() | ||
| val outFile = File(outDir, CONVERSATIONS_FILE) | ||
|
|
||
| if (value != null) { | ||
| if (value.length > MAX_MIGRATE_CHARS) { | ||
| Log.w( | ||
| TAG, | ||
| "Dropping oversized $CONVERSATIONS_KEY " + | ||
| "(${value.length} chars) — would OOM Flutter prefs codec", | ||
| ) | ||
| } else if (!outFile.exists() || outFile.length() == 0L) { | ||
| outFile.writeText(value) | ||
| Log.i( | ||
| TAG, | ||
| "Migrated conversations prefs (${value.length} chars) → ${outFile.path}", | ||
| ) | ||
| } | ||
| prefs.edit().remove(CONVERSATIONS_KEY).apply() | ||
| } | ||
|
|
||
| if (oldValue != null) { | ||
| prefs.edit().remove(OLD_CHAT_KEY).apply() | ||
| } | ||
| } catch (e: Exception) { | ||
| Log.e(TAG, "migrateConversationsOutOfPrefs failed", e) | ||
| try { | ||
| getSharedPreferences(FLUTTER_PREFS, MODE_PRIVATE) | ||
| .edit() | ||
| .remove(CONVERSATIONS_KEY) | ||
| .remove(OLD_CHAT_KEY) | ||
| .apply() | ||
| } catch (_: Exception) { | ||
| // Ignore — next launch may still crash until data is cleared. | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
This method has three critical issues that need to be addressed:
- API Compatibility Crash: Using
dataDirdirectly requires API level 24 (Android 7.0) or higher. If the application'sminSdkVersionis lower than 24, accessingdataDirwill throw aNoSuchMethodErrorat runtime on older devices, causing the app to crash immediately on startup. UsefilesDir.parentFileinstead, which is available from API level 1. - Silent Data Loss: Deleting
OLD_CHAT_KEY(flutter.chat_history) in the native Android code before the Flutter engine starts will cause silent data loss for users upgrading from older versions. The migration ofOLD_CHAT_KEYis handled in Dart (ChatHistoryService.ensureMigrated()). If the native code deletes this key on boot, the Dart code will find it empty and skip the migration entirely, losing the user's old chat history. We should remove all references and deletion ofOLD_CHAT_KEYfrom the native code. - Non-Atomic Writes: Writing directly to
outFileviawriteTextis not atomic. If the write operation is interrupted (e.g., due to a crash, power loss, or the OS killing the app), the file will be left in a corrupted or incomplete state. On subsequent boots, the check!outFile.exists() || outFile.length() == 0Lwill evaluate tofalse, meaning the migration won't be retried, and the SharedPreferences key will be deleted, leading to permanent data loss. To prevent this, write the content to a temporary file first and then atomically rename it to the destination file.
private fun migrateConversationsOutOfPrefs() {
try {
val prefs = getSharedPreferences(FLUTTER_PREFS, MODE_PRIVATE)
val value = prefs.getString(CONVERSATIONS_KEY, null) ?: return
val outDir = File(filesDir.parentFile, "app_flutter")
if (!outDir.exists()) outDir.mkdirs()
val outFile = File(outDir, CONVERSATIONS_FILE)
if (value.length > MAX_MIGRATE_CHARS) {
Log.w(
TAG,
"Dropping oversized $CONVERSATIONS_KEY " +
"(${value.length} chars) — would OOM Flutter prefs codec",
)
} else if (!outFile.exists() || outFile.length() == 0L) {
val tempFile = File(outDir, "$CONVERSATIONS_FILE.tmp")
tempFile.writeText(value)
if (tempFile.renameTo(outFile)) {
Log.i(
TAG,
"Migrated conversations prefs (${value.length} chars) → ${outFile.path}",
)
} else {
tempFile.delete()
throw java.io.IOException("Failed to rename temp file to $CONVERSATIONS_FILE")
}
}
prefs.edit().remove(CONVERSATIONS_KEY).apply()
} catch (e: Exception) {
Log.e(TAG, "migrateConversationsOutOfPrefs failed", e)
try {
getSharedPreferences(FLUTTER_PREFS, MODE_PRIVATE)
.edit()
.remove(CONVERSATIONS_KEY)
.apply()
} catch (_: Exception) {
// Ignore — next launch may still crash until data is cleared.
}
}
}| static Future<void> 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<dynamic>; | ||
| if (list.isNotEmpty) { | ||
| final messages = list | ||
| .map((e) => ChatMessage.fromJson(e as Map<String, dynamic>)) | ||
| .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<dynamic>; | ||
| if (list.isNotEmpty) { | ||
| final messages = list | ||
| .map((e) => ChatMessage.fromJson(e as Map<String, dynamic>)) | ||
| .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); | ||
| } |
There was a problem hiding this comment.
This method has two critical issues that need to be addressed:
- Data Overwrite Bug: If a user has both
_keyand_oldKeyin SharedPreferences, migrating_keyfirst writes to the file. Then, migrating_oldKeycalls_saveConversationsInternal([conversation]), which completely overwrites the file and destroys the newly migrated_keydata. Similarly, if the native code already migrated_key(sofileexists), Dart will delete_oldKeywithout migrating it. - Non-Atomic Writes: Writing directly to the file via
writeAsStringcan leave the file corrupted if interrupted. On the next boot,file.exists() && file.length() > 0will be true, causing the SharedPreferences keys to be deleted, and the corrupted file will be cleared on load, resulting in complete data loss.
To fix both issues, we should:
- Perform atomic writes using a temporary file and renaming it.
- Safely decode and merge both
_keyand_oldKeydata if both are present before saving them together, even if the file already exists.
static Future<void> ensureMigrated() async {
final file = await _conversationsFile();
try {
final p = await _p;
final json = p.getString(_key);
final oldJson = p.getString(_oldKey);
if (await file.exists() && await file.length() > 0) {
if (oldJson != null && oldJson.isNotEmpty) {
try {
final list = jsonDecode(oldJson) as List<dynamic>;
if (list.isNotEmpty) {
final messages = list
.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>))
.toList();
final conversation = Conversation(messages: messages);
final fileContent = await file.readAsString();
final existingList = jsonDecode(fileContent) as List<dynamic>;
final existing = existingList
.map((e) => Conversation.fromJson(e as Map<String, dynamic>))
.toList();
await _saveConversationsInternal([...existing, conversation]);
}
} on Exception catch (e) {
debugPrint('ChatHistoryService: old chat_history merge failed: $e');
}
}
await p.remove(_key);
await p.remove(_oldKey);
return;
}
if (oldJson != null && oldJson.isNotEmpty) {
final List<Conversation> migrated = [];
try {
final list = jsonDecode(oldJson) as List<dynamic>;
if (list.isNotEmpty) {
final messages = list
.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>))
.toList();
migrated.add(Conversation(messages: messages));
}
} on Exception catch (e) {
debugPrint('ChatHistoryService: old chat_history migrate failed: $e');
}
if (json != null && json.isNotEmpty) {
try {
final list = jsonDecode(json) as List<dynamic>;
migrated.addAll(
list.map((e) => Conversation.fromJson(e as Map<String, dynamic>)),
);
} on Exception catch (e) {
debugPrint('ChatHistoryService: conversations migrate failed: $e');
}
}
if (migrated.isNotEmpty) {
await _saveConversationsInternal(migrated);
}
await p.remove(_key);
await p.remove(_oldKey);
} else if (json != null && json.isNotEmpty) {
final tempFile = File('${file.path}.tmp');
await tempFile.writeAsString(json);
await tempFile.rename(file.path);
await p.remove(_key);
debugPrint(
'ChatHistoryService: migrated conversations prefs → file '
'(${json.length} chars)',
);
}
} on Exception catch (e) {
debugPrint('ChatHistoryService.ensureMigrated failed: $e');
}
}| final file = await _conversationsFile(); | ||
| await file.writeAsString(encoded); |
There was a problem hiding this comment.
Writing directly to the conversations file is not atomic. If the application crashes or is killed by the OS during the write operation, the file will be corrupted, leading to a complete loss of the user's chat history on the next load.
To ensure data integrity, write the JSON string to a temporary file first, and then atomically rename it to the target file.
| final file = await _conversationsFile(); | |
| await file.writeAsString(encoded); | |
| final file = await _conversationsFile(); | |
| final tempFile = File('${file.path}.tmp'); | |
| await tempFile.writeAsString(encoded); | |
| await tempFile.rename(file.path); |
| android:icon="@mipmap/ic_launcher" | ||
| android:label="Nova" | ||
|
|
||
| android:largeHeap="true" |
There was a problem hiding this comment.
Since the root cause of the OOM (storing large base64 screenshot strings in SharedPreferences) has been resolved by migrating the chat history to a JSON file, stripping image data, and capping conversation sizes, adding android:largeHeap="true" is no longer necessary.
Enabling largeHeap should generally be avoided as it increases the application's memory footprint, which can lead to longer garbage collection pauses and degrade overall system performance. It is recommended to remove this attribute.
| android:largeHeap="true" | |
| 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 CONVERSATIONS_FILE = "conversations.json" | ||
| /** Above this, skip content migrate and only delete the prefs key. */ | ||
| private const val MAX_MIGRATE_CHARS = 8_000_000 | ||
| } |
There was a problem hiding this comment.
Since we removed the handling of OLD_CHAT_KEY from the native migration logic to prevent data loss, we should also clean up the unused constant OLD_CHAT_KEY from the companion object.
companion object {
private const val TAG = "NovaApplication"
private const val FLUTTER_PREFS = "FlutterSharedPreferences"
private const val CONVERSATIONS_KEY = "flutter.conversations"
private const val CONVERSATIONS_FILE = "conversations.json"
/** Above this, skip content migrate and only delete the prefs key. */
private const val MAX_MIGRATE_CHARS = 8_000_000
}|
|
||
| // Rewrite stripped/capped form so the blob shrinks on disk. | ||
| if (length > _maxFileBytes) { | ||
| await _saveConversationsInternal(conversations); |
There was a problem hiding this comment.
WARNING: _cachedConversations is assigned valid data on line 170, but if _saveConversationsInternal throws during the rewrite on line 174, the catch block on line 180 sets _cachedConversations = [], discarding the valid loaded conversations.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| "Migrated conversations prefs (${value.length} chars) → ${outFile.path}", | ||
| ) | ||
| } | ||
| prefs.edit().remove(CONVERSATIONS_KEY).apply() |
There was a problem hiding this comment.
WARNING: prefs.edit().remove(CONVERSATIONS_KEY).apply() is always executed even if outFile.writeText(value) fails on line 37. If the file write throws for a non-oversized value, the catch block on line 51-59 also removes the key, causing irreversible data loss.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| final encoded = jsonEncode( | ||
| capped | ||
| .map( | ||
| (c) => { |
There was a problem hiding this comment.
SUGGESTION: _saveConversationsInternal manually constructs the JSON map instead of using Conversation.toJson(). If new fields are added to Conversation, they will be silently dropped from persistence unless this manual map is updated.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
Code Review SummaryStatus: 3 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
Files Reviewed (15 files)
Fix these issues in Kilo Cloud Previous Review Summary (commit 4fcf430)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit 4fcf430)Status: 3 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (4 files)
Reviewed by step-3.7-flash · Input: 150K · Output: 39.5K · Cached: 1.4M |
Stream-scrub oversized SharedPreferences without loading XML into the heap, onboard SmolLM on <=6GB phones (total RAM, not free), and fall back to installed/small models when Gemma 4 cannot load. Device verification still pending on POCO F1. Co-authored-by: Cursor <cursoragent@cursor.com>
| } catch (e: Exception) { | ||
| Log.e(TAG, "AGENT_DBG session=7abc09 hyp=A scrub failed", e) | ||
| try { | ||
| prefsFile.writeText(EMPTY_PREFS_XML) |
There was a problem hiding this comment.
WARNING: Catch block calls prefsFile.writeText(EMPTY_PREFS_XML) on any scrub failure, wiping ALL SharedPreferences data instead of only the oversized conversation keys.
If streamRemoveStringEntry throws (e.g., disk full or I/O error), the catch block overwrites the entire SharedPreferences XML with an empty <map>, destroying unrelated user settings/preferences. The old code only removed specific keys in the failure path; this new catch block is far more destructive.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| } | ||
|
|
||
| if (found) { | ||
| if (!tmp.renameTo(prefsFile)) { |
There was a problem hiding this comment.
WARNING: tmp.renameTo(prefsFile) fallback to tmp.copyTo(prefsFile, overwrite = true) can corrupt the destination file if the copy is interrupted.
File.copyTo truncates the destination before copying. If the copy throws mid-way (e.g., disk full), the original prefs file is left partially written. The outer catch block then overwrites it with empty prefs, causing total data loss. Prefer a safer fallback or ensure the temp file is only renamed atomically.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| }) async { | ||
| // Serialize installs — concurrent Gemma 4 onboarding + Gemma 3 fallback | ||
| // races corrupt flutter_gemma's active identity (POCO F1). | ||
| while (_installLock != null) { |
There was a problem hiding this comment.
WARNING: The install lock (_installLock) has no timeout. If _installFromNetworkUnlocked hangs indefinitely (e.g., stalled network stream), the lock is never released and all subsequent installs deadlock.
Dart's Future.timeout at the caller (e.g., 300s in model_orchestrator.dart) does not cancel the underlying future. The finally block that clears _installLock only runs when the original future completes. A hung download blocks every later install forever.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
Summary
conversationsSharedPreferences blob (base64 screenshots), not "only 35% system RAM"NovaApplication)app_flutter/conversations.json; screenshots are not persistedTest plan
flutter run→ onboarding offers SmolLM (~135 MB), not Gemma 4AGENT_DBGshowstotalMemMb~6000 andrecommended":"SmolLMflutter test test/services/chat_history_export_test.dart test/services/free_ram_gate_test.dartAgentDebugLog/AGENT_DBGinstrumentation, bump 0.4.1, mergeFollow-ups (not in this PR)