Avatar
Home
Projects
Contact
Available For Work
HomeProjectsContact
Local Time (Africa/Juba)
Local Time (Africa/Juba)
HomeProjectsContact
©2025, All Rights ReservedBuilt with by Maged

Crafting Zim: Building a File Manager App with Flutter and Kotlin

An android file manager featuring a clean UI & multi-threaded processing, offering essential features like file organization, renaming, and decompression.

Dec, 2022CompletedSource
Carousel image (1)
Carousel image (2)

Table of Contents

  • Introduction
  • Architectural Overview: A Hybrid Approach
  • Bridging the Gap: Flutter to Kotlin with MethodChannel
  • Snippet: MainActivity.kt
  • Performance Under Pressure: Concurrency with Dart Isolates
  • State Management with Provider
  • Snippet: core_provider.dart
  • Snippet: category_provider.dart
  • Key Learnings and Challenges
  • Conclusion: A Snapshot in Time

Introduction

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.

Architectural Overview: A Hybrid Approach

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:

  • MethodChannels: A communication bridge that allows Flutter's Dart code to invoke methods on the native Android platform (and vice-versa).
  • Dart Isolates: A concurrency model used to perform intensive operations, like scanning the file system, without freezing the user interface.

Bridging the Gap: Flutter to Kotlin with MethodChannel

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.

Performance Under Pressure: Concurrency with Dart Isolates

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.

State Management with Provider

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:

  • AppProvider: Manages user preferences, such as theme settings.
  • CoreProvider: Handles storage information and the list of recent files.
  • CategoryProvider: Manages the logic for categorizing files by type (e.g., downloads, images, documents).

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.

Key Learnings and Challenges

Building Zim was a rewarding challenge that offered several key insights into mobile development:

  • Concurrency Models: Working with Dart's message-passing isolates requires a different approach than traditional shared-memory threading. It forces a clear separation of concerns and prevents entire classes of concurrency bugs.
  • Platform Integration: A successful hybrid app requires a well-defined and stable API contract between the Flutter and native code. The MethodChannel serves as this contract, and any changes must be managed carefully on both sides.
  • Android Permissions: Navigating Android's complex file system permissions, especially with the introduction of scoped storage, is a significant challenge for any file manager app. It requires careful handling of runtime permissions and platform-specific APIs.

Conclusion: A Snapshot in Time

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.

Share Project

Crafting Zim: Building a File Manager App with Flutter and Kotlin

An android file manager featuring a clean UI & multi-threaded processing, offering essential features like file organization, renaming, and decompression.

Dec, 2022CompletedSource
Carousel image (1)
Carousel image (2)

Table of Contents

  • Introduction
  • Architectural Overview: A Hybrid Approach
  • Bridging the Gap: Flutter to Kotlin with MethodChannel
  • Snippet: MainActivity.kt
  • Performance Under Pressure: Concurrency with Dart Isolates
  • State Management with Provider
  • Snippet: core_provider.dart
  • Snippet: category_provider.dart
  • Key Learnings and Challenges
  • Conclusion: A Snapshot in Time

Introduction

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.

Architectural Overview: A Hybrid Approach

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:

  • MethodChannels: A communication bridge that allows Flutter's Dart code to invoke methods on the native Android platform (and vice-versa).
  • Dart Isolates: A concurrency model used to perform intensive operations, like scanning the file system, without freezing the user interface.

Bridging the Gap: Flutter to Kotlin with MethodChannel

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.

Performance Under Pressure: Concurrency with Dart Isolates

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.

State Management with Provider

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:

  • AppProvider: Manages user preferences, such as theme settings.
  • CoreProvider: Handles storage information and the list of recent files.
  • CategoryProvider: Manages the logic for categorizing files by type (e.g., downloads, images, documents).

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.

Key Learnings and Challenges

Building Zim was a rewarding challenge that offered several key insights into mobile development:

  • Concurrency Models: Working with Dart's message-passing isolates requires a different approach than traditional shared-memory threading. It forces a clear separation of concerns and prevents entire classes of concurrency bugs.
  • Platform Integration: A successful hybrid app requires a well-defined and stable API contract between the Flutter and native code. The MethodChannel serves as this contract, and any changes must be managed carefully on both sides.
  • Android Permissions: Navigating Android's complex file system permissions, especially with the introduction of scoped storage, is a significant challenge for any file manager app. It requires careful handling of runtime permissions and platform-specific APIs.

Conclusion: A Snapshot in Time

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.

Share Project

MainActivity.kt

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

MainActivity.kt

// 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...
}