01Why build a file manager
Zim is an Android file manager I built to get past the surface of mobile development. A file manager teaches you that fast, because it cannot live entirely inside the framework. It has to ask the device real questions, like how much storage is left. And it has to do slow work, like walking the whole filesystem, without locking up the screen. I wrote the interface and the logic in Flutter, then dropped down to native Kotlin for the parts Flutter cannot reach on its own.
Zim also has two lives now: the original build, and a 2026 pass where I came back to fix the things the first version had apologized for in its own comments. The snippets in the next few sections link to the original code, pinned to the commit where it lived, warts deliberately included. The comeback gets its own section at the end, because the difference between the two is the most useful thing this project has to say.
02Two languages, one bridge
Flutter handles UI and app logic well, but it does not expose every Android API. Exact storage figures are one of those gaps. To get them I set up a MethodChannel, the named pipe between Dart and the native side. Dart calls a method by name. On the Kotlin side, MainActivity listens on the same channel, runs Android's StatFs against the storage partition, and sends the number back. From Dart it reads like an ordinary async call, but the work happens in native code.
The MethodChannel bridge
What the bridge taught me is that a MethodChannel is really an API contract, just one with its two halves written in different languages. The method names and the shapes of what gets passed have to line up on the Dart side and the Kotlin side, and nothing checks that for you. Rename a method in one place, forget the other, and it fails at runtime, not at compile time. My favorite native detail is how the SD card figures come out: Android will only hand you the card's location buried inside the app-specific Android folder, so the Kotlin side splits the path at "Android" to recover the card's root, and runs StatFs there.
03Scanning the disk without freezing the screen
Listing recent files means walking the filesystem, and that is slow enough to stutter the UI on the main thread. Dart's usual answer is an isolate, a separate thread with its own memory. But the easy helpers did not fit. The scan calls a plugin, and compute cannot run plugin code. The isolate library I reached for next would not pass a complex FileSystemEntity back across the thread boundary.
So I used each tool for what it does well: the isolate library to spawn the background work, and an IsolateNameServer port to ship the results home as plain file paths, which I rebuild into file objects on the main thread. I left the why in a comment, the kind of note I would want if I came back to this in a year.
/// I had to use a combination of [isolate_handler] plugin and
/// [IsolateNameServer] because compute doesn't work as my function uses
/// an external plugin and also [isolate_handler] plugin doesn't allow me
/// to pass complex data (in this case List<FileSystemEntity>). so basically
/// i used the [isolate_handler] to do get the file and use [IsolateNameServer]
/// to send it back to the main Thread
getRecentFiles() async {
String isolateName = 'recent';
isolates.spawn<String>(
getFilesWithIsolate,
name: isolateName,
onReceive: (val) => isolates.kill(isolateName),
onInitialized: () => isolates.send('hey', to: isolateName),
);
ReceivePort port = ReceivePort();
IsolateNameServer.registerPortWithName(port.sendPort, '${isolateName}_2');
port.listen((filePaths) {
// rebuild File objects from the paths sent back across the boundary
recentFiles.addAll((filePaths as List<String>).map((p) => File(p)));
setRecentLoading(false);
port.close();
IsolateNameServer.removePortNameMapping('${isolateName}_2');
});
}
static getFilesWithIsolate(Map<String, dynamic> context) async {
String isolateName = context['name'];
List<FileSystemEntity> l =
await FileUtils.getRecentFiles(showHidden: false);
final messenger = HandledIsolate.initialize(context);
final SendPort? send =
IsolateNameServer.lookupPortByName('${isolateName}_2');
// Convert the FileSystemEntity objects to their paths before sending
send!.send(l.map((file) => file.path).toList());
messenger.send('done');
}The same pattern did double duty elsewhere. Sorting files by type, the images tab, downloads, documents, ran the exact same scan on a background isolate and shipped the paths home the same way, so the category screens filled in without the list ever stuttering.
The workaround looks fiddly, but the model underneath it is the part worth keeping. Isolates do not share memory. Each one has its own heap, and you pass data across explicitly or not at all. That sounds limiting until you notice what it buys you: with no shared state to lock, the race conditions and deadlocks that come with normal threading simply cannot happen here.
04Unpacking archives
Reading the disk safely was one half of the job. The other was acting on what I found there. Decompression is one of those features people expect a file manager to just have. Rather than implement each format by hand, I leaned on the archive package and let it handle the common ones. A quick extension check guards the call, so an unsupported file gets a clear message instead of a crash.
static Future<bool> extractArchive(String source, String destination) async {
decompressing = true;
List supported = ['.zip', '.tar', '.zlib', '.gz', 'bz2', '.xz'];
if (supported.contains(extension(source))) {
try {
await extractFileToDisk(source, destination)
.then((value) => { decompressing = false });
return true;
} catch (e) {
return false;
}
} else {
Fluttertoast.showToast(msg: 'File Type not supported!');
return false;
}
}Sharp eyes will catch the bug preserved in that snippet: 'bz2' in the supported list is missing its leading dot, so a .bz2 archive never actually matched the check. It sat there for years, which probably says something about how many people decompress bz2 archives on their phones. Hold that thought, though, because it comes up again.
05Coming back for the rematch
For years this write-up ended on a sad note. Android's storage model had tightened release after release, scoped storage broke the direct filesystem access the whole app was built on, and I put the project on hold rather than rewrite its foundation against a moving target. In 2026 I came back for the rematch. The manifest now declares the all-files access permission Android carved out for real file managers, and Zim runs on a modern phone again.
The part of the comeback I enjoyed most was deleting the isolate workaround. My old comment had already diagnosed the problem precisely: compute could not run the scan, because the scan called a plugin. Which means the fix had nothing to do with finding a cleverer isolate library. I just had to stop calling the plugin inside the isolate. The modern code resolves the storage roots on the main thread first, over the platform channel, and hands the walk, by then pure Dart, to compute. The whole apologized-for contraption collapsed into two short functions.
/// Full recursive device scan, offloaded to a background isolate. Storage
/// roots are resolved here (platform channel) then the walk runs via
/// `compute`, so it never blocks the frame.
Future<List<Entry>> scan({bool showHidden = false}) async {
final roots =
(await FileUtils.getStorageList()).map((d) => d.path).toList();
return compute(scanEntries, (roots, showHidden));
}
/// Device files, newest-modified first. Built on the offloaded [scan].
Future<List<Entry>> recent({bool showHidden = false}) async {
final entries = await scan(showHidden: showHidden);
entries.sort((a, b) => b.modified.compareTo(a.modified));
return entries;
}The rest of the pass followed the same rule: fix the cause the first version worked around. Files became a small Entry value type, statted once, instead of raw FileSystemEntity objects passed around and re-statted everywhere. The walk skips symlinks so a cycle cannot recurse forever, and an unreadable root gets skipped instead of killing the whole scan. Settings moved into their own provider, the fourth store, wired so the category screens rebuild automatically when the show-hidden toggle flips. And yes, bz2 finally got its dot.
06What I took from it
The UI talks to the Provider stores, and the interesting work hides inside them: one calls into native Kotlin for the storage figures, one owns the scans, one sorts the categories, one holds the settings. When a value changes, the store notifies its listeners, so only the widgets that read it rebuild and the rest of the tree is left alone. I kept the native footprint deliberately small, only the things Flutter genuinely could not do, and that kept the rest of the app easy to follow.
The hardest part of a file manager was never my own code. It was Android's storage model, the one part of the project that kept moving underneath me. What finally pushed me to revive the app was rereading that old isolate comment and realizing I now knew exactly what it should have said. Zim is where platform channels and isolates stopped being documentation to me and became tools I had wired up, shipped, regretted, and eventually repaired.