Skip to content

Move Android file I/O off the main thread and stop reading contents on list - #33

Merged
baijum merged 2 commits into
mainfrom
fix/12-file-io-off-main-thread
Sep 13, 2026
Merged

baijum merged 2 commits into
mainfrom
fix/12-file-io-off-main-thread

Conversation

@baijum

@baijum baijum commented Sep 12, 2026

Copy link
Copy Markdown
Member

Closes #12

Problem

FileBrowserViewModel called the repository synchronously from a
LaunchedEffect, from click handlers and from the evaluateJavascript
result callback — all on the main dispatcher. Worse, listFiles read every
file in full on each listing (every Files-tab open and after every save or
delete) even though the browser never shows contents. With a few large
.scm files that janks the UI and can ANR.

Changes

FileRepository is suspend, I/O on Dispatchers.IO. Every operation
in the expect declaration is now a suspend fun; both actuals wrap their
bodies in withContext(Dispatchers.IO). The Android dir is lazy, so the
mkdirs and the stale-temp-file sweep also run on IO at first use instead
of in the ViewModel factory on the main thread. The contract KDoc gains an
"all I/O is off the main thread" bullet.

Listing never reads contents. SchemeFile loses its content field
(name, path, lastModified remain), so a listing can never carry
fabricated empty content and opening a file goes through readFile. The
listFiles contract bullet is rewritten accordingly: an unreadable or
non-UTF-8 file stays listed and deletable, and its failure or lossy decoding
surfaces from readFile when it is opened, not while listing.

FileBrowserViewModel is suspend all the way through; failures still
propagate as exceptions to the caller.

MainActivity gains one small launcher per file action —
createEmptyFile, openFile, newFile, deleteFile, saveEditorCode —
each scope.launching with the same try/catch + snackbar it had before, and
the FileBrowserScreen callbacks become function references. Two behaviour
details worth a look:

  • openFile switches to the editor only after readFile succeeded, so a
    failed open never shows an empty document under the file's name (which
    the next save would persist). It surfaces "Could not open file: …".
  • The save dialog captures the typed name before the JS round trip, since
    the callback now completes after the dialog has closed.

iOS actual is updated in lockstep (suspend + withContext, no
content). It needs import kotlinx.coroutines.IO — on Kotlin/Native
Dispatchers.IO is an extension property. Verified with
:shared:compileKotlinIosSimulatorArm64 and :shared:compileKotlinIosArm64
locally, since per #14 nothing in CI compiles iosMain. The Swift app keeps
its own SchemeFile model and is unaffected.

Docs: docs/architecture.md contract paragraph and the
docs/development.md test table/conventions.

Testing

  • FileRepositoryTest and FileBrowserViewModelTest run under
    runBlocking / runTest. New cases:
    • listFiles_returnsNamesAndMtimesWithoutContents
    • listFiles_keepsNonUtf8FilesVisible (replaces the lossy-content
      assertion, which now lives in readFile_decodesInvalidUtf8Lossily)
    • listFiles_keepsUnreadableFilesVisible_andReadFileReportsThem —
      assumeFalse(canRead()) so it is skipped rather than failing where
      permission bits are ignored (root, some CI file systems)
    • firstUse_sweepsStaleTempFilesButKeepsRealFiles (was repositoryInit_…;
      now also asserts construction does no I/O)
    • readFile_returnsContentsThatTheListingDoesNotCarry,
      readFile_propagatesMissingFilesInsteadOfReturningEmpty on the ViewModel
  • ./gradlew assembleDebug test :shared:testAndroidHostTest :app:detekt
    passes; detekt reports no findings and the baseline is untouched.
  • The Compose wiring in MainActivity has no automated net (per the repo's
    test layout); verified by build and reasoning.

Not in scope

SettingsRepository uses SharedPreferences with apply(), which is
already asynchronous; left as is.

…n list

`FileBrowserViewModel` called the repository synchronously from a
`LaunchedEffect`, from click handlers and from the `evaluateJavascript`
result callback, all on the main dispatcher, and `listFiles` read every
file in full on each listing (on every Files-tab open and after every save
or delete) even though the browser never shows contents. With a few large
.scm files that janks the UI and can ANR (issue #12).

Every `FileRepository` operation is now a `suspend` function that does its
disk access inside `withContext(Dispatchers.IO)`, including the lazily
deferred mkdirs and temp-file sweep that used to run in the ViewModel
factory on the main thread. `listFiles` returns name, path and mtime only:
`SchemeFile` no longer has a `content` field, so a listing can never carry
fabricated empty content and opening a file goes through `readFile`. An
unreadable or non-UTF-8 file therefore stays listed and deletable, and its
failure or lossy decoding surfaces when it is opened, not while listing.

`FileBrowserViewModel` is suspend all the way through. MainActivity gains
one small launcher per file action (`openFile`, `newFile`, `deleteFile`,
`saveEditorCode`, `createEmptyFile`) on the composition scope; each keeps
its existing try/catch and snackbar. Opening a file switches to the editor
only after the read succeeded, so a failed open never shows an empty
document under the file's name for the next save to persist. The save
dialog captures the typed name before the JS round trip.

The iOS actual is updated in lockstep with the expect declaration; the Swift
app keeps its own model and is unaffected (see #14).

Tests: `FileRepositoryTest` and `FileBrowserViewModelTest` run under
`runBlocking`/`runTest`; new cases cover listing without contents, an
unreadable file staying listed while `readFile` throws (skipped where
permission bits are ignored), the sweep deferring to first use, and
`readFile` through the ViewModel.

Closes #12

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Baiju Muthukadan <baiju.m.mail@gmail.com>

@baijum baijum left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Verified locally on f44515e:

  • ./gradlew assembleDebug test :shared:testAndroidHostTest :app:detekt — all green (re-ran tests and detekt with --rerun-tasks, not from cache)
  • :shared:compileKotlinIosSimulatorArm64 — compiles, confirming the kotlinx.coroutines.IO import for the Native Dispatchers.IO extension
  • No stray SchemeFile.content consumers left on the Kotlin side; the iOS app's Swift SchemeFile is a separate struct, so it is unaffected as stated
  • SchemeFile JSON is only exercised in commonTest round-trips — nothing persists it, so dropping content outright is safe

Overall this is the right shape: the suspend boundary sits at the repository, both actuals shift their disk access to Dispatchers.IO, dir is lazy on both platforms so construction does no I/O, and the Activity owns error UX in one place. The listing-without-contents contract change is well documented in the expect KDoc and both docs pages, and the new tests pin the interesting behaviors (unreadable files stay listed and deletable, a failed open never plants an empty document under the file's name, construction touches no disk).

Only nits below — nothing blocking.

Comment thread app/src/main/java/com/kaappi/studio/MainActivity.kt
Comment thread app/src/main/java/com/kaappi/studio/MainActivity.kt Outdated
Comment thread app/src/test/java/com/kaappi/studio/viewmodel/FileBrowserViewModelTest.kt Outdated
…shared suspend assert

`newFile` launched a second coroutine from inside the one it had already
launched when it fell through to `createEmptyFile`. Extract the body into a
local suspend `createEmptyFileInternal` that both the overwrite dialog's
`createEmptyFile` and `newFile` call, so the new-file decision and the
write stay in one coroutine.

The Android repository's lazy `dir` only keeps the one-time temp-file sweep
from deleting a concurrent write's `.tmp` because `by lazy` defaults to
`LazyThreadSafetyMode.SYNCHRONIZED`: every `writeFile` obtains `dir` before
creating its temp file and so blocks until the initializer has finished.
Record that in a comment so a future startup-time tweak of the mode does
not silently drop the guarantee.

`FileRepositoryTest`'s private `assertThrowsSuspend` helper moves to a
shared `SuspendAsserts.kt`, and `FileBrowserViewModelTest` uses it instead
of wrapping each call in `runBlocking` inside `assertThrows`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Baiju Muthukadan <baiju.m.mail@gmail.com>
@baijum
baijum merged commit a30706b into main Sep 13, 2026
3 checks passed
@baijum
baijum deleted the fix/12-file-io-off-main-thread branch September 13, 2026 04:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Android: all file I/O runs on the main thread; listing files reads the full contents of every file

1 participant