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)

Introduction

In the ever-evolving landscape of mobile technology, managing digital content efficiently remains a challenge for many users. Driven by a passion to simplify this experience, I developed Zim, an Android file manager that seamlessly integrates advanced file management capabilities with a user-friendly design. This journey not only pushed the boundaries of my technical skills using Flutter and Kotlin but also deepened my understanding of user-centric design.

Project Overview: Blending Flutter with Android

Zim leverages Flutter's robust framework for its UI and logic, while tapping into Android's native capabilities for file management through platform-specific code written in Kotlin. This hybrid approach allows us to create a seamless user experience while accessing deep system functionalities.

Let's dive into the key components that make Zim tick:

The Technical Backbone: Isolates and MethodChannels

One of the core components of Zim's architecture is the use of Dart's Isolates for multi-thread computing. Think of Isolates as independent workers in a factory, each focused on their task without interrupting others. This ensures that the app's UI remains responsive while performing intensive file operations.

But how do we get these Isolates to talk to the Android system? Enter MethodChannel - our bilingual interpreters that facilitate communication between Flutter and Android. Here's how we set it up:

File: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())
          }
}

This Kotlin code is like setting up a hotline between Flutter and Android. When Flutter needs to know about storage space, it dials this hotline, and Android picks up to provide the information. It's a simple yet powerful way to bridge the gap between the two platforms.

Managing State with Change Notifier Providers

In the world of Flutter, state management is king. Zim utilizes three Change Notifier Providers to keep everything in check:

Let's take a closer look at how the Core Provider works its magic:

File:core_provider.dart

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

This Core Provider is like the engine room of our app. It's constantly checking storage space, fetching recent files, and keeping everyone updated. The checkSpace() method is particularly interesting - it's like a health check for your device's storage, using our MethodChannel hotline to get the latest info from Android.

But the real star of the show is the getRecentFiles() method. It's using Isolates to fetch files without breaking a sweat (or freezing the UI). Here's how it works:

It's like having a dedicated assistant who goes to fetch your recent files while you continue to use the app smoothly.

Advanced Categorization with Category Provider

Now, let's talk about our file organization expert - the Category Provider. This provider is all about making sure you can find the right file at the right time.

File:category_provider.dart

// CategoryProvider handles file categorization and updates UI accordingly.
class CategoryProvider extends ChangeNotifier {
  CategoryProvider() {
    getHidden();
    getSort();
  }
  final isolates = IsolateHandler();
  bool loading = false;
  bool showHidden = false;
  int sort = 0;
  
  List<FileSystemEntity> downloads = <FileSystemEntity>[];
  List<String> downloadTabs = <String>[];

  List<FileSystemEntity> thumbnailFiles = <FileSystemEntity>[];
  List<String> thumbnailTabs = <String>[];

  List<FileSystemEntity> nonThumbnailFiles = <FileSystemEntity>[];
  List<String> nonThumbnailTabs = <String>[];

  List<FileSystemEntity> currentFiles = [];

  getDownloads() async {
    await getFiles('downloads', downloads, downloadTabs, 'Download');
  }

  getThumbnailFiles(String type) async {
    await getFiles(type, thumbnailFiles, thumbnailTabs);
  }
  // Other get File type get funcitons...

  
    Future<void> getFiles(
      String type, List<FileSystemEntity> files, List<String> tabs,
      [String? dirName]) async {
      setLoading(true);
      tabs.clear();
      files.clear();
      tabs.add('All');
      if (dirName != null) {
        List<Directory> storages = await FileUtils.getStorageList();
        for (var dir in storages) {
          if (Directory('${dir.path}$dirName').existsSync()) {
            List<FileSystemEntity> dirFiles =
                Directory('${dir.path}$dirName').listSync();
            for (var file in dirFiles) {
              if (FileSystemEntity.isFileSync(file.path)) {
                files.add(file);
                tabs.add(file.path.split('/')[file.path.split('/').length - 2]);
                tabs = tabs.toSet().toList();
                notifyListeners();
              }
            }
          }
        }
      } else {
        String isolateName = type;
        isolates.spawn<String>(
          getAllFilesWithIsolate,
          name: isolateName,
          onReceive: (val) {
            print('getAllFilesWithIsolate completed');
            isolates.kill(isolateName);
          },
          onInitialized: () => isolates.send('hey', to: isolateName),
        );
        ReceivePort port = ReceivePort();
        IsolateNameServer.registerPortWithName(port.sendPort, '${isolateName}_2');
        port.listen((filePaths) {
          processFilePaths(filePaths, type, files, tabs);
          currentFiles = files;
          setLoading(false);
          print('getFiles completed');
          port.close();
          IsolateNameServer.removePortNameMapping('${isolateName}_2');
        });
      }
    }

  //...

  static getAllFilesWithIsolate(Map<String, dynamic> context) async {
    String isolateName = context['name'];
    List<FileSystemEntity> files =
        await FileUtils.getAllFiles(showHidden: false);
    final messenger = HandledIsolate.initialize(context);
    try {
      final SendPort? send =
          IsolateNameServer.lookupPortByName('${isolateName}_2');
      // Convert the FileSystemEntity objects to their paths before sending
      List<String> filePaths = files.map((file) => file.path).toList();
      print('Found ${filePaths.length} files');
      send!.send(filePaths);
      // Wait for the send operation to complete before sending 'done'
      await Future.delayed(Duration(seconds: 1));
      messenger.send('done');
    } catch (e) {
      print(e);
    }
  }

  // Other class functions...
}

The Category Provider is like a super-organized librarian. It's constantly categorizing files, making sure everything is in its right place. The getFiles() method is particularly clever:

The getAllFilesWithIsolate() method is our file-finding ninja. It sneaks through your device, finds all the files you're looking for, and reports back without you even noticing it's gone.

Key Learnings and Challenges

Building Zim was like trying to solve a Rubik's cube while juggling - challenging, but incredibly rewarding. Here are some of the key takeaways:

Conclusion: A Stepping Stone to Advanced Applications

Building Zim has been more than just creating an app - it's been a journey into the depths of mobile development. It's pushed me to blend complex functionalities with user-friendly design, all while juggling different programming paradigms.

But the journey doesn't end here. Zim is an ongoing project, and I invite you to be part of its evolution. Whether you want to take it for a spin, contribute some code, or just share your thoughts, your input is valuable. Let's continue to push the boundaries of what's possible in mobile app development together.

After all, in the world of tech, the only constant is change - and with tools like Zim, we're ready to manage whatever files the future throws our way!

Share Project