Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
android:allowBackup="true"
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.

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"

android:supportsRtl="true"
android:theme="@style/LaunchTheme"
tools:targetApi="34">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
)
)
}
Expand Down
166 changes: 165 additions & 1 deletion android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}

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)

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.

} catch (_: Exception) {
// User must clear app data.
}
}
}

/**
* Removes `<string name="KEY">…</string>` via streaming so the huge value
* is never materialized as a Java String.
*/
private fun streamRemoveStringEntry(prefsFile: File, key: String): Boolean {
val open = """<string name="$key">""".toByteArray(Charsets.UTF_8)
val close = "</string>".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<Byte>(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)) {

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.

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 =
"<?xml version='1.0' encoding='utf-8' standalone='yes' ?>\n" +
"<map>\n</map>\n"
}
Comment on lines +163 to +172

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
    }

}
20 changes: 20 additions & 0 deletions docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 16 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
Expand All @@ -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');
Expand Down
11 changes: 7 additions & 4 deletions lib/screens/assistant_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1943,11 +1943,14 @@ class _AssistantScreenState extends State<AssistantScreen>
),
),
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),
Expand Down
Loading
Loading