•
4 min read

Storage Access Framework (SAF) in Jetpack Compose

Jetpack compose is the new defacto for new android/kmm projects for 2023, for many of these projects the filesystem is a key part of the app, yet all these years it has been tough to get it right. but hopefully not anymore.

With the introduction of scoped storage on android 10 (Api Level 29), Google closed access to device external files (i.e. files that are outside your app-specific/cache directories), and in return presented these three alternative methods, more about them in the official documentation, we simply have

  • MediaStore API
  • Storage Access Framework (SAF)
  • Manage Files Permission

One thing great about SAF compared to the other APIs is that no permissions required in AndroidManifest.xml for it to work, which means less hassle if you are publishing to Google Play. It works with Android Level 19 (Kitkat 4.4) β€˜til latest as of writing of this article. In fact the API was implemented alongside scoped storage to move apps from having full access to filesystem, of which google says developers abused this access for years for untended use and raised many security risks. All these points mean this API is built to stay. So how does it work?

Here is a simple example on opening a directory, and persisting the given Uri

@Composable
fun SimpleScreen(modifier: Modifier = Modifier) {
  val context = LocalContext.current
  val isDirectoryPicked = remember { mutableStateOf(false) }
  val dirPickerLauncher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.OpenDocumentTree(),
    onResult = { maybeUri ->
      maybeUri?.let { uri ->
        val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
          Intent.FLAG_GRANT_WRITE_URI_PERMISSION
        if (checkUriPersisted(context.contentResolver, uri)) {
          context.contentResolver.releasePersistableUriPermission(uri, flags)
        }
        context.contentResolver.takePersistableUriPermission(uri, flags)
        // Do Something
        isDirectoryPicked.value = true
      }
    }
  )

  Column(
    modifier = modifier.fillMaxSize(),
    horizontalAlignment = Alignment.CenterHorizontally,
    verticalArrangement = Arrangement.Center,
  ) {
    Button(onClick = { dirPickerLauncher.launch(Uri.EMPTY) }) {
      Text(text = "Open Directory Picker")
    }
    Spacer(modifier = Modifier.height(30.dp))
    when (isDirectoryPicked.value) {
      true -> Text(text = "Directory picking is success")
      false -> Text(text = "Waiting for you to choose a directory")
    }
  }
}

fun checkUriPersisted(contentResolver: ContentResolver, uri: Uri): Boolean {
  return contentResolver.persistedUriPermissions.any { perm -> perm.uri == uri }
}

If you are familiar with the old way of using ACTION_OPEN_DOCUMENT_TREE intent, you know we are missing something. That is right, we are missing flags, we surely persist Uri with Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, but if we don’t specify these flags to the rememberLauncherForActivityResult() we cannot read/write to that given Uri, so how do we do it? Simply you have to subclass the ActivityResultContracts.OpenDocumentTree class, and override the createIntent() method to specify your prefered set of flags, here is the subclass that I work with called PermissibleOpenDocumentTreeContract

class PermissibleOpenDocumentTreeContract(
    private val write: Boolean = false,
) : ActivityResultContracts.OpenDocumentTree() {
    override fun createIntent(context: Context, input: Uri?): Intent {
        val intent = super.createIntent(context, input)
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
        if (write) {
            intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
        }
        intent.addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
        intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)

        return intent
    }
}

You can see that our new class is flexible in terms of permissions, if you need write permissions, pass true to the constructor, otherwise, all is good, you can read more about FLAG_GRANT_PREFIX_URI_PERMISSION and FLAG_GRANT_PERSISTABLE_URI_PERMISSION here if you want. Now we update our SimpleScreen, change the contract parameter of rememberLauncherForActivityResult(). Here is how it should look like

val dirPickerLauncher = rememberLauncherForActivityResult(
  contract = PermissibleOpenDocumentTreeContract(), // pass true if you want write permissions
  onResult = ...
)

Note: the OpenDocumentTree Activity result contract is supported from Android Level 21 (Lolipop 5.0), as is the example.

The code for this working app can be found here.

Happy coding.

Originally published on Medium.