Skip to content

fix: SharedPreferences OOM + low-RAM onboarding for mid-range devices#6

Merged
involvex merged 2 commits into
mainfrom
fix/chat-history-prefs-oom
Jul 18, 2026
Merged

fix: SharedPreferences OOM + low-RAM onboarding for mid-range devices#6
involvex merged 2 commits into
mainfrom
fix/chat-history-prefs-oom

Conversation

@involvex

@involvex involvex commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Summary

  • Crash was Java-heap OOM decoding a huge conversations SharedPreferences blob (base64 screenshots), not "only 35% system RAM"
  • Boot stream-scrubs oversized prefs keys without loading XML into the heap (NovaApplication)
  • Chat history lives in app_flutter/conversations.json; screenshots are not persisted
  • ≤6.5 GB phones onboard SmolLM (total RAM gate) so POCO F1 does not download Gemma 4 it cannot load
  • RAM gate auto-falls back to installed/small models; install mutex avoids concurrent download races
  • Docs note LiteRT diffusion image-gen (FLUX / Z-Image) is out of scope for this APK

Test plan

  • POCO F1: uninstall → full flutter run → onboarding offers SmolLM (~135 MB), not Gemma 4
  • Logcat AGENT_DBG shows totalMemMb ~6000 and recommended":"SmolLM
  • Cold start after prior OOM — no SharedPreferences OOM; app opens
  • Send a short chat — real reply (not only "need ~1800 MB")
  • Status line has no yellow overflow stripe on long errors
  • flutter test test/services/chat_history_export_test.dart test/services/free_ram_gate_test.dart
  • After device OK: strip AgentDebugLog / AGENT_DBG instrumentation, bump 0.4.1, merge

Follow-ups (not in this PR)

  • On-device FLUX / Z-Image LiteRT image generation (separate native pipeline)
  • Heavy soak / Gemma 4 stays on X8 Pro Max or LAN remote inference

@changeset-bot

changeset-bot Bot commented Jul 18, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: ff949c8

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@sourcery-ai

sourcery-ai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Reviewer's Guide

Moves 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 loading

sequenceDiagram
    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
Loading

File-Level Changes

Change Details Files
Persist conversations to a capped JSON file instead of SharedPreferences, trimming oversized history and stripping screenshots to avoid OOM.
  • Introduce file-backed storage using application documents directory and a shared conversations.json file with size limits and caps on conversations/messages.
  • Add sanitization that removes imageData from persisted ChatMessage objects and replaces empty-text screenshot messages with a placeholder label.
  • Implement logic to cap the number of conversations and messages per conversation and to rewrite the on-disk file when it exceeds a soft size threshold.
  • Change load/save logic to read/write the JSON file, handle oversized or corrupt files defensively, and ensure SharedPreferences keys are removed after migration.
  • Enhance clear() to delete the conversations file, reset file cache, and remove legacy SharedPreferences keys with error logging.
lib/services/chat_history_service.dart
Add native Android boot-time migration that moves conversations out of Flutter SharedPreferences into a file and drops oversized keys pre-emptively.
  • Initialize migration in NovaApplication.onCreate() so it runs before the Flutter engine accesses SharedPreferences.
  • Implement migrateConversationsOutOfPrefs() to move reasonable-sized flutter.conversations values into app_flutter/conversations.json and delete legacy keys, while dropping excessively large values with logging.
  • Handle migration errors by best-effort removal of problematic SharedPreferences keys to reduce repeat crashes.
  • Define constants for SharedPreferences names, keys, file name, and migration size threshold.
android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt
Update tests to validate file-based persistence and non-persistence of screenshot bytes using a fake path provider.
  • Introduce a fake PathProviderPlatform implementation that redirects application documents directory to a temporary test directory.
  • Adjust test setup/teardown to configure the fake path provider, clear ChatHistoryService, and clean up temporary directories.
  • Add tests that verify conversations are written to conversations.json instead of SharedPreferences and that screenshot imageData is nulled and replaced with a placeholder in the persisted file.
test/services/chat_history_export_test.dart
Tweak Android manifest to allow a larger Java heap for the app process.
  • Enable android:largeHeap="true" on the Application in AndroidManifest.xml to slightly increase available Java heap for edge cases.
android/app/src/main/AndroidManifest.xml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

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>

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +191 to +204
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(),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +18 to +61
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.
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This method has three critical issues that need to be addressed:

  1. API Compatibility Crash: Using dataDir directly requires API level 24 (Android 7.0) or higher. If the application's minSdkVersion is lower than 24, accessing dataDir will throw a NoSuchMethodError at runtime on older devices, causing the app to crash immediately on startup. Use filesDir.parentFile instead, which is available from API level 1.
  2. 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 of OLD_CHAT_KEY is 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 of OLD_CHAT_KEY from the native code.
  3. Non-Atomic Writes: Writing directly to outFile via writeText is 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() == 0L will evaluate to false, 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.
            }
        }
    }

Comment on lines +84 to 131
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This method has two critical issues that need to be addressed:

  1. Data Overwrite Bug: If a user has both _key and _oldKey in SharedPreferences, migrating _key first writes to the file. Then, migrating _oldKey calls _saveConversationsInternal([conversation]), which completely overwrites the file and destroys the newly migrated _key data. Similarly, if the native code already migrated _key (so file exists), Dart will delete _oldKey without migrating it.
  2. Non-Atomic Writes: Writing directly to the file via writeAsString can leave the file corrupted if interrupted. On the next boot, file.exists() && file.length() > 0 will 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 _key and _oldKey data 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');
    }
  }

Comment on lines +205 to +206
final file = await _conversationsFile();
await file.writeAsString(encoded);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
android:largeHeap="true"

Comment on lines +63 to +71
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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kilo-code-bot

kilo-code-bot Bot commented Jul 18, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
WARNING 3
Issue Details (click to expand)

WARNING

File Line Issue
android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt 66 Catch block wipes ALL SharedPreferences data if scrub fails
android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt 152 renameTo fallback copyTo can corrupt destination if interrupted
lib/services/model_manager.dart 121 Install lock has no timeout; hung network operation deadlocks all subsequent installs
Files Reviewed (15 files)
  • android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt - 2 issues
  • android/app/src/main/kotlin/dev/nova/assistant/MainActivity.kt
  • docs/models.md
  • lib/main.dart
  • lib/screens/assistant_screen.dart
  • lib/screens/onboarding/beginner/model_download_screen.dart
  • lib/services/memory_diagnostics_service.dart
  • lib/services/model_manager.dart - 1 issue
  • lib/services/model_orchestrator.dart
  • lib/services/platform_adaptation_service.dart
  • lib/utils/agent_debug_log.dart
  • pubspec.yaml
  • test/services/free_ram_gate_test.dart
  • android/app/src/main/AndroidManifest.xml
  • test/services/chat_history_export_test.dart

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

Severity Count
WARNING 2
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
lib/services/chat_history_service.dart 174 _cachedConversations discarded if rewrite throws
android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt 43 Prefs key removed even if file write fails

SUGGESTION

File Line Issue
lib/services/chat_history_service.dart 194 Manual JSON map could miss future model fields
Files Reviewed (4 files)
  • lib/services/chat_history_service.dart - 2 issues
  • android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt - 1 issue
  • test/services/chat_history_export_test.dart
  • android/app/src/main/AndroidManifest.xml

Fix these issues in Kilo Cloud


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>
@involvex involvex changed the title fix: SharedPreferences OOM from chat history fix: SharedPreferences OOM + low-RAM onboarding for mid-range devices Jul 18, 2026
} catch (e: Exception) {
Log.e(TAG, "AGENT_DBG session=7abc09 hyp=A scrub failed", e)
try {
prefsFile.writeText(EMPTY_PREFS_XML)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@involvex
involvex merged commit a45cf79 into main Jul 18, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant