DEV Community

Cover image for Home sweet home: some thoughts on storing files
Thomas Künneth
Thomas Künneth

Posted on

2

Home sweet home: some thoughts on storing files

This is the second article in a series of tips and tricks about Compose Multiplatform. The content is based on a sample app called CMP Unit Converter. It runs on Android, iOS, and the Desktop. As its name suggests, you can convert between various units. While this may provide some value, the main goal is to show how to use Compose Multiplatform and a couple of other multiplatform libraries. This time, we will be looking at where to store files.

Getting directories

Since the notion of files is pretty broad, let's narrow it down by defining a few types:

enum class DirectoryType {
  Configuration, Database, Files
}
Enter fullscreen mode Exit fullscreen mode

In your app, you might want to add (or remove) some of these enum constants, based on what your app does and which libraries it uses. CMPUnitConverter depends on DataStore and Room. Both libraries maintain files that inevitably need to be stored somewhere.

expect fun getDirectoryForType(type: DirectoryType): String
Enter fullscreen mode Exit fullscreen mode

This function will be called upon the setup of Room and DataStore. Let's find out how the platform-specific implementations work.

@OptIn(ExperimentalForeignApi::class)
actual fun getDirectoryForType(type: DirectoryType): String {
  val url = NSFileManager.defaultManager.URLForDirectory(
    directory = when (type) {
      DirectoryType.Configuration -> NSApplicationSupportDirectory
      DirectoryType.Database -> NSApplicationSupportDirectory
      DirectoryType.Files -> NSDocumentDirectory
    },
    inDomain = NSUserDomainMask,
    appropriateForURL = null,
    create = true,
    error = null,
  )
  return url?.path ?: NSFileManager.defaultManager.currentDirectoryPath
}
Enter fullscreen mode Exit fullscreen mode

On iOS, NSFileManager.defaultManager.URLForDirectory provides directories for different purposes. NSApplicationSupportDirectory refers to application support files, which reside in Library/Application Support. NSDocumentDirectory is used for, well, documents.

Have you noticed NSFileManager.defaultManager.currentDirectoryPath? If things go wrong, we will store files in the current directory as a fallback.

So, how are we going to use getDirectoryForType()? To set up Room, we have implementations of a getDatabaseBuilder() function for every target. The iOS implementation looks like this:

actual fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> =
  getDirectoryForType(DirectoryType.Database).let { dir ->
    Room.databaseBuilder<AppDatabase>(
      name = "$dir/CMPUnitConverter.db"
    )
  }
Enter fullscreen mode Exit fullscreen mode

The file CMPUnitConverter.db will be created immediately inside Library/Application Support. iOS doesn't focus on user-visible directory trees, so putting the file inside an app base folder (for example CMPUnitConverter) feels unnecessary. On the Desktop, however, the file system is way more visible to the user, so we will use another approach which I'll explain to you soon. But before, let's see how getDirectoryForType() is implemented on Android.

actual fun getDirectoryForType(type: DirectoryType): String =
  when (type) {
    DirectoryType.Database -> throw IllegalArgumentException(
        "Use context.getDatabasePath() instead"
    )
    else -> context.filesDir.absolutePath
}
Enter fullscreen mode Exit fullscreen mode

The IllegalArgumentException may come as a surprise. Allow me to explain. Since there is Context.getDatabasePath() which receives a database name, it makes more sense to use that; particularly as there is no function to only get a database directory.

actual fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> =
  Room.databaseBuilder<AppDatabase>(
    context = context,
    name = context.getDatabasePath("CMPUnitConverter.db")
                  .absolutePath
  )
Enter fullscreen mode Exit fullscreen mode

We pass the database name to getDatabasePath() and get a File instance in return. databaseBuilder() expects the name of the database file to be a String so we obtain it using absolutePath.

Kindly recall that on iOS, the file CMPUnitConverter.db will be created immediately inside Library/Application Support. getDatabasePath() makes sure that the database file is located inside the directory tree for our application; therefore, we don't need to add some app-specific folder, which I omitted on iOS because the file system is not immediately visible to the user.

Buy one, get two more free

Jetpack DataStore also needs to read and write files. I will show you how CMPUnitConverter determines the filename, and how to set up DataStore, in a later part of this series. But now, let's look at the Desktop.

First, here's the implementation of getDatabaseBuilder():

actual fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> =
  with(File(getDirectoryForType(DirectoryType.Database),
            "CMPUnitConverter.db")) {
    Room.databaseBuilder<AppDatabase>(
      name = absolutePath
    )
  }
Enter fullscreen mode Exit fullscreen mode

It looks very similar to what we have on iOS. But we use File to combine the directory and the database name for us. absolutePath provides the String we can pass to Room.databaseBuilder.

When talking about Compose for Desktop, we tend to forget that there are actually three platforms involved. In my opinion, this underlines how well the JVM and its class libraries encapsulate the differences between Linux, macOS, and Windows. While some may think that having chosen the Java platform as the base for Compose for Desktop was not a particularly good decision, it in fact was a brilliant one. You get so much for free that you would never want to implement on your own. Just to mention a few:

  • Top-level window management
  • File system access
  • Localization and internationalization

In case you think I'm a little too enthusiastic, please try to implement locale-aware number, date and time formatting and parsing on Android, iOS, and the Desktop using shared code. While the related Kotlin libraries offer a lot, I think they can't compete with what we get on the JVM (regarding date and time formatting and parsing). I ended up using expect/actual. In case you want to learn more, kindly look at convertToLocalizedString() and convertLocalizedStringToFloat() in the CMPUnitConverter source code.

But I got carried away. Here's the implementation of getDirectoryForType() for the Desktop.

actual fun getDirectoryForType(type: DirectoryType): String {
  val home = System.getProperty("user.home") ?: "."
  val path = mutableListOf<String>().apply {
    add(home)
    addAll(
      when (operatingSystem) {
        OperatingSystem.MacOS -> listOf("Library",
                                        "Application Support",
                                        "CMPUnitConverter")

        OperatingSystem.Windows -> listOf("AppData",
                                          "Roaming",
                                          "CMPUnitConverter")

        else -> listOf(".CMPUnitConverter")
      }
    )
    add(type.name)
  }.joinToString(File.separator)
  with(File(path).apply { mkdirs() }) {
    return if (exists() && isDirectory && canRead() && canWrite()) {
      absolutePath
    } else {
      home
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

On all operating systems, we store files somewhere inside the home directory of the user (System.getProperty("user.home")). Depending on the directory type and the operating system, we add a couple of subdirectories, for example, an application base directory (CMPUnitConverter). On Linux, its name is .CMPUnitConverter. The reason is that the ls command line tool hides directories starting with . if no other options are provided. As you can see, just like on iOS, there's Library/Application Support on macOS. On Windows, files and folders inside Roaming will roam with a user's profile: if a user logs in to a different computer on the same network domain, the data in this folder will be synchronized. Files that should stay on a particular computer are put inside Local.

To make sure that all listed directories exist, we invoke mkdirs(). A final exists() && isDirectory && canRead() && canWrite() checks if the directory to be returned can really be used by our app. If that check fails, we fall back to the home directory.

Wrap-up

That's basically it. Well, besides one thing: where does operatingSystem come from?

enum class OperatingSystem {
  Linux, Windows, MacOS, Unknown;
}

expect val platformName: String

val operatingSystem = platformName.lowercase().let { platformName ->
  if (platformName.contains("mac os x")) {
    OperatingSystem.MacOS
  } else if (platformName.contains("windows")) {
    OperatingSystem.Windows
  } else if (platformName.contains("linux")) {
    OperatingSystem.Linux
  } else {
    OperatingSystem.Unknown
  }
}
Enter fullscreen mode Exit fullscreen mode

On the Desktop, platformName is implemented like this:

actual val platformName: String = System.getProperty("os.name") ?: ""
Enter fullscreen mode Exit fullscreen mode

In this part I showed you how to handle directory types across platforms. I hope you enjoyed the read. Kindly share your thoughts in the comments.

Top comments (0)