An android file manager featuring a clean UI & multi-threaded processing, offering essential features like file organization, renaming, and decompression.
Efficient file management is a persistent challenge on mobile devices. To address this, I developed Zim, an Android file manager designed to combine powerful features with a clean, user-friendly interface. This project was an opportunity to build a practical tool while diving deep into advanced mobile development, using Flutter for the front end and Kotlin for native Android capabilities.
Zim is built on a hybrid model that leverages the strengths of two platforms. Flutter provides the declarative UI toolkit and business logic, allowing for rapid development and a consistent user experience. For performance-critical tasks and access to deep system functionalities, the app calls on native Android code written in Kotlin.
This architecture is made possible by two core concepts in the Flutter ecosystem:
Flutter's framework doesn't have built-in access to every native Android API. To query device-specific information like precise storage space, Zim uses a MethodChannel. This acts as a clearly defined bridge between the Flutter front end and the native Kotlin back end.
In the app's MainActivity.kt, a MethodChannel is set up to listen for calls from Flutter. When Flutter invokes a method like
getStorageFreeSpace, the corresponding native Kotlin function is executed, and the result is returned to the Dart code.
This approach allows the app to feel entirely native while benefiting from Flutter's development speed, creating a seamless experience for the user.
A file manager must perform intensive I/O operations, such as searching for all files of a specific type. If run on the main thread, these tasks would block the UI, leading to a frozen or unresponsive app (an effect known as "jank").
An android file manager featuring a clean UI & multi-threaded processing, offering essential features like file organization, renaming, and decompression.
Efficient file management is a persistent challenge on mobile devices. To address this, I developed Zim, an Android file manager designed to combine powerful features with a clean, user-friendly interface. This project was an opportunity to build a practical tool while diving deep into advanced mobile development, using Flutter for the front end and Kotlin for native Android capabilities.
Zim is built on a hybrid model that leverages the strengths of two platforms. Flutter provides the declarative UI toolkit and business logic, allowing for rapid development and a consistent user experience. For performance-critical tasks and access to deep system functionalities, the app calls on native Android code written in Kotlin.
This architecture is made possible by two core concepts in the Flutter ecosystem:
Flutter's framework doesn't have built-in access to every native Android API. To query device-specific information like precise storage space, Zim uses a MethodChannel. This acts as a clearly defined bridge between the Flutter front end and the native Kotlin back end.
In the app's MainActivity.kt, a MethodChannel is set up to listen for calls from Flutter. When Flutter invokes a method like
getStorageFreeSpace, the corresponding native Kotlin function is executed, and the result is returned to the Dart code.
This approach allows the app to feel entirely native while benefiting from Flutter's development speed, creating a seamless experience for the user.
A file manager must perform intensive I/O operations, such as searching for all files of a specific type. If run on the main thread, these tasks would block the UI, leading to a frozen or unresponsive app (an effect known as "jank").
To solve this, Zim offloads all heavy file operations to Dart Isolates. Isolates are similar to threads but have a key difference: they do not share memory. Each isolate runs in its own memory heap, ensuring that its work cannot interfere with the main UI thread. Communication between the main thread and an isolate is handled by passing messages through ports (ReceivePort).
The getRecentFiles() method in the CoreProvider is a perfect example. It spawns an isolate to handle the file search. The isolate performs the scan, sends a list of file paths back to the main thread via a port, and is then terminated. The UI remains perfectly smooth throughout the entire process.
To manage the application's state, Zim uses Flutter's Provider package. The state is divided across three main providers, each with a distinct responsibility:
These providers encapsulate the application's logic. For example, when the CoreProvider needs to check storage space, it uses the MethodChannel to call the native Kotlin code. When the CategoryProvider needs to find all images on the device, it spawns an Isolate. Once the data is retrieved, the provider calls.
notifyListeners(), which efficiently rebuilds only the widgets that depend on that specific piece of state.
Building Zim was a rewarding challenge that offered several key insights into mobile development:
Building Zim was a valuable deep dive into hybrid mobile development, successfully demonstrating how to build a performant app by blending Flutter's UI with Kotlin's native power. It stands as a project I'm proud of, showcasing a complex architecture that was effective for its time.
Today, Zim exists as a snapshot of that era. Due to significant changes in Android's file system permissions introduced after Android 9, the app's core functionality is no longer compatible with modern versions of the OS. As a result, the project is currently on hold.
Adapting Zim to work with today's scoped storage APIs would be a fascinating challenge, requiring a significant architectural update. While I haven't had the chance to dive back into it yet, the source code remains available for anyone to explore. It serves as a practical example of a file manager built for a different generation of Android, and I encourage others to learn from it or even take on the challenge of modernizing it.
To solve this, Zim offloads all heavy file operations to Dart Isolates. Isolates are similar to threads but have a key difference: they do not share memory. Each isolate runs in its own memory heap, ensuring that its work cannot interfere with the main UI thread. Communication between the main thread and an isolate is handled by passing messages through ports (ReceivePort).
The getRecentFiles() method in the CoreProvider is a perfect example. It spawns an isolate to handle the file search. The isolate performs the scan, sends a list of file paths back to the main thread via a port, and is then terminated. The UI remains perfectly smooth throughout the entire process.
To manage the application's state, Zim uses Flutter's Provider package. The state is divided across three main providers, each with a distinct responsibility:
These providers encapsulate the application's logic. For example, when the CoreProvider needs to check storage space, it uses the MethodChannel to call the native Kotlin code. When the CategoryProvider needs to find all images on the device, it spawns an Isolate. Once the data is retrieved, the provider calls.
notifyListeners(), which efficiently rebuilds only the widgets that depend on that specific piece of state.
Building Zim was a rewarding challenge that offered several key insights into mobile development:
Building Zim was a valuable deep dive into hybrid mobile development, successfully demonstrating how to build a performant app by blending Flutter's UI with Kotlin's native power. It stands as a project I'm proud of, showcasing a complex architecture that was effective for its time.
Today, Zim exists as a snapshot of that era. Due to significant changes in Android's file system permissions introduced after Android 9, the app's core functionality is no longer compatible with modern versions of the OS. As a result, the project is currently on hold.
Adapting Zim to work with today's scoped storage APIs would be a fascinating challenge, requiring a significant architectural update. While I haven't had the chance to dive back into it yet, the source code remains available for anyone to explore. It serves as a practical example of a file manager built for a different generation of Android, and I encourage others to learn from it or even take on the challenge of modernizing it.
// Setup a MethodChannel to communicate with Flutter.
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "dev.maiz.zim/storage").setMethodCallHandler { call, result ->
// Handle method calls from Flutter, like fetching storage space.
when {
call.method == "getStorageFreeSpace" -> result.success(getStorageFreeSpace())
call.method == "getStorageTotalSpace" -> result.success(getStorageTotalSpace())
call.method == "getExternalStorageTotalSpace" -> result.success(getExternalStorageTotalSpace())
call.method == "getExternalStorageFreeSpace" -> result.success(getExternalStorageFreeSpace())
}
}
// Setup a MethodChannel to communicate with Flutter.
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "dev.maiz.zim/storage").setMethodCallHandler { call, result ->
// Handle method calls from Flutter, like fetching storage space.
when {
call.method == "getStorageFreeSpace" -> result.success(getStorageFreeSpace())
call.method == "getStorageTotalSpace" -> result.success(getStorageTotalSpace())
call.method == "getExternalStorageTotalSpace" -> result.success(getExternalStorageTotalSpace())
call.method == "getExternalStorageFreeSpace" -> result.success(getExternalStorageFreeSpace())
}
}
// CoreProvider class manages storage using ChangeNotifier for updates.
class CoreProvider extends ChangeNotifier {
List<FileSystemEntity> availableStorage = <FileSystemEntity>[];
List<File> recentFiles = <File>[];
final isolates = IsolateHandler();
int totalSpace = 0;
int freeSpace = 0;
int totalSDSpace = 0;
int freeSDSpace = 0;
// other state variables
// Check and update storage space asynchronously.
checkSpace() async {
//....
List<Directory> dirList = (await getExternalStorageDirectories())!;
availableStorage.addAll(dirList);
notifyListeners();
MethodChannel platform = const MethodChannel('dev.maiz.zim/storage');
var free = await platform.invokeMethod('getStorageFreeSpace');
var total = await platform.invokeMethod('getStorageTotalSpace');
setFreeSpace(free);
setTotalSpace(total);
setUsedSpace(total - free);
if (dirList.length > 1) {
var freeSD = await platform.invokeMethod('getExternalStorageFreeSpace');
var totalSD = await platform.invokeMethod('getExternalStorageTotalSpace');
setFreeSDSpace(freeSD);
setTotalSDSpace(totalSD);
setUsedSDSpace(totalSD - freeSD);
}
setStorageLoading(false);
getRecentFiles();
}
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) {
// Recreate the File objects from the paths
recentFiles.addAll(
(filePaths as List<String>).map((filePath) => File(filePath)));
setRecentLoading(false);
port.close();
IsolateNameServer.removePortNameMapping('${isolateName}_2');
});
}
// Isolates in Dart are independent workers that are similar to threads
// but don't share memory, communicating only via messages.
// Each isolate has its own event loop and memory heap, ensuring that no
// isolate can directly access any other isolate's state.
// In the context of Flutter, isolates allow for concurrent processing.
// They are especially useful when you need to perform a computationally
// intensive task that, if run on the main isolate (UI thread),
// could potentially block the UI and lead to a poor user experience.
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');
}
// Other class Functions...
}
// CoreProvider class manages storage using ChangeNotifier for updates.
class CoreProvider extends ChangeNotifier {
List<FileSystemEntity> availableStorage = <FileSystemEntity>[];
List<File> recentFiles = <File>[];
final isolates = IsolateHandler();
int totalSpace = 0;
int freeSpace = 0;
int totalSDSpace = 0;
int freeSDSpace = 0;
// other state variables
// Check and update storage space asynchronously.
checkSpace() async {
//....
List<Directory> dirList = (await getExternalStorageDirectories())!;
availableStorage.addAll(dirList);
notifyListeners();
MethodChannel platform = const MethodChannel('dev.maiz.zim/storage');
var free = await platform.invokeMethod('getStorageFreeSpace');
var total = await platform.invokeMethod('getStorageTotalSpace');
setFreeSpace(free);
setTotalSpace(total);
setUsedSpace(total - free);
if (dirList.length > 1) {
var freeSD = await platform.invokeMethod('getExternalStorageFreeSpace');
var totalSD = await platform.invokeMethod('getExternalStorageTotalSpace');
setFreeSDSpace(freeSD);
setTotalSDSpace(totalSD);
setUsedSDSpace(totalSD - freeSD);
}
setStorageLoading(false);
getRecentFiles();
}
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) {
// Recreate the File objects from the paths
recentFiles.addAll(
(filePaths as List<String>).map((filePath) => File(filePath)));
setRecentLoading(false);
port.close();
IsolateNameServer.removePortNameMapping('${isolateName}_2');
});
}
// Isolates in Dart are independent workers that are similar to threads
// but don't share memory, communicating only via messages.
// Each isolate has its own event loop and memory heap, ensuring that no
// isolate can directly access any other isolate's state.
// In the context of Flutter, isolates allow for concurrent processing.
// They are especially useful when you need to perform a computationally
// intensive task that, if run on the main isolate (UI thread),
// could potentially block the UI and lead to a poor user experience.
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');
}
// Other class Functions...
}