tauri-plugin-android-fs 28.2.1

Android file system API for Tauri.
Documentation
package com.plugin.android_fs

import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.os.storage.StorageVolume
import android.provider.MediaStore
import java.io.File
import java.util.Locale
import java.util.UUID


class AFStorageVolume private constructor() {

    data class Metadata(
        val mediaStoreVolumeName: String?,
        val topDir: File?,
        val externalFilesDir: File?,
        val externalCacheDir: File?,
        val externalMediaDir: File?,
        val description: String,
        val storageUuid: UUID?,
        val uuid: String?,
        val isPrimary: Boolean,
        val isRemovable: Boolean,
        val isEmulated: Boolean,
        val state: String
    )

    companion object {

        fun getAvailableStorageVolumes(ctx: Context): List<Metadata> {
            val svc = StorageVolumeContext(ctx)
            val externalDirs = getExternalFilesCacheMediaDirsWithStorageVolume(svc)
            val buf = mutableListOf<Metadata>()

            for (sv in svc.storageVolumes) {
                if (!svc.checkAvailable(sv)) continue

                val externalDirEntry = externalDirs[sv]

                buf.add(
                    Metadata(
                        mediaStoreVolumeName = svc.getMediaStoreVolumeName(sv),
                        topDir = svc.getTopDir(sv),
                        externalFilesDir = externalDirEntry?.first,
                        externalCacheDir = externalDirEntry?.second,
                        externalMediaDir = externalDirEntry?.third,
                        storageUuid = svc.getStorageUuid(sv),
                        uuid = sv.uuid,
                        description = svc.getDescription(sv),
                        isPrimary = sv.isPrimary,
                        isRemovable = sv.isRemovable,
                        isEmulated = sv.isEmulated,
                        state = sv.state
                    )
                )
            }

            return buf
        }

        fun getPrimaryStorageVolumeIfAvailable(ctx: Context): Metadata? {
            val svc = StorageVolumeContext(ctx)
            val sv = svc.storageVolumes.find { it.isPrimary } ?: return null
            return createStorageVolumeFromRawIfAvailable(sv, svc)
        }

        fun getStorageVolumeByFileIfAvailable(file: File, ctx: Context): Metadata? {
            val svc = StorageVolumeContext(ctx)
            val sv = svc.storageManager.getStorageVolume(file) ?: return null
            return createStorageVolumeFromRawIfAvailable(sv, svc)
        }

        fun checkMediaStoreVolumeNameAvailable(
            mediaStoreVolumeName: String,
            ctx: Context
        ): Boolean {

            val svc = StorageVolumeContext(ctx)

            for (sv in svc.storageVolumes) {
                if (mediaStoreVolumeName == svc.getMediaStoreVolumeName(sv)) {
                    return svc.checkAvailable(sv)
                }
            }

            return false
        }

        fun checkStorageVolumeAvailableByFile(
            file: File,
            ctx: Context
        ): Boolean {

            val svc = StorageVolumeContext(ctx)
            val sv = svc.storageManager.getStorageVolume(file)
            return sv != null && svc.checkAvailable(sv)
        }

        fun getStorageVolumeTopDirsAndMediaStoreVolumeNames(ctx: Context): List<Pair<File, String>> {
            val svc = StorageVolumeContext(ctx)
            val buf = mutableListOf<Pair<File, String>>()
            for (vol in svc.storageVolumes) {
                val topDir = svc.getTopDir(vol) ?: continue
                val volumeName = svc.getMediaStoreVolumeName(vol) ?: continue
                buf.add(Pair(topDir, volumeName))
            }
            return buf.toList()
        }
    }
}

private fun createStorageVolumeFromRawIfAvailable(
    sv: StorageVolume,
    svc: StorageVolumeContext
): AFStorageVolume.Metadata? {

    if (!svc.checkAvailable(sv)) return null

    return AFStorageVolume.Metadata(
        mediaStoreVolumeName = svc.getMediaStoreVolumeName(sv),
        topDir = svc.getTopDir(sv),
        externalFilesDir = svc.getExternalFilesDir(sv),
        externalCacheDir = svc.getExternalCacheDir(sv),
        externalMediaDir = svc.getExternalMediaDir(sv),
        storageUuid = svc.getStorageUuid(sv),
        uuid = sv.uuid,
        description = svc.getDescription(sv),
        isPrimary = sv.isPrimary,
        isRemovable = sv.isRemovable,
        isEmulated = sv.isEmulated,
        state = sv.state
    )
}

private fun getExternalFilesCacheMediaDirsWithStorageVolume(
    svc: StorageVolumeContext
): Map<StorageVolume, Triple<File?, File?, File?>> {

    val entries = mutableMapOf<StorageVolume, Triple<File?, File?, File?>>()

    for (dataDir in svc.externalFilesDirs) {
        val sv = svc.storageManager.getStorageVolume(dataDir)
        if (sv != null) {
            entries[sv] = (entries[sv] ?: Triple(null, null, null)).copy(first = dataDir)
        }
    }
    for (cacheDir in svc.externalCacheDirs) {
        val sv = svc.storageManager.getStorageVolume(cacheDir)
        if (sv != null) {
            entries[sv] = (entries[sv] ?: Triple(null, null, null)).copy(second = cacheDir)
        }
    }
    for (mediaDir in svc.externalMediaDirs) {
        val sv = svc.storageManager.getStorageVolume(mediaDir)
        if (sv != null) {
            entries[sv] = (entries[sv] ?: Triple(null, null, null)).copy(third = mediaDir)
        }
    }

    return entries
}

private class StorageVolumeContext(val ctx: Context) {

    val storageManager: StorageManager by lazy {
        ctx.getSystemService(Context.STORAGE_SERVICE) as StorageManager
    }

    val storageVolumes: List<StorageVolume> by lazy {
        storageManager.storageVolumes
    }

    val mediaStoreVolumeNames: Set<String> by lazy {
        when {
            // 一部のデバイスでは getExternalVolumeNames ではなく getRecentExternalVolumeNames でないと
            // Primary Storage Volume 以外を検知できないバグがある。
            // https://forum.developer.samsung.com/t/usb-massive-storage-not-recognized-in-galaxy-s20-android-11/9758/6
            Build.VERSION_CODES.R <= Build.VERSION.SDK_INT->
                MediaStore.getRecentExternalVolumeNames(ctx)

            Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT ->
                MediaStore.getExternalVolumeNames(ctx)

            else -> emptySet()
        }
    }

    val externalFilesDirs: List<File> by lazy {
        ctx.getExternalFilesDirs(null).filterNotNull()
    }

    val externalCacheDirs: List<File> by lazy {
        ctx.externalCacheDirs.filterNotNull()
    }

    val externalFilesDir: File? by lazy {
        ctx.getExternalFilesDir(null)
    }

    val externalCacheDir: File? by lazy {
        ctx.externalCacheDir
    }

    val externalMediaDirs: List<File> by lazy {
        try {
            @Suppress("DEPRECATION")
            ctx.externalMediaDirs.filterNotNull()
        }
        catch (_: Exception) {
            listOf()
        }
    }

    fun checkAvailable(sv: StorageVolume): Boolean {
        // これは StorageVolume.getDirectory で使われる判定処理と同じである
        // https://android.googlesource.com/platform/frameworks/base/+/HEAD/core/java/android/os/storage/StorageVolume.java

        return when (sv.state) {
            Environment.MEDIA_MOUNTED,
            Environment.MEDIA_MOUNTED_READ_ONLY -> true

            else -> false
        }
    }

    fun getExternalFilesDir(sv: StorageVolume): File? {
        if (sv.isPrimary) {
            return externalFilesDir
        }

        return externalFilesDirs.find { storageManager.getStorageVolume(it) == sv }
    }

    fun getExternalCacheDir(sv: StorageVolume): File? {
        if (sv.isPrimary) {
            return externalCacheDir
        }

        return externalCacheDirs.find { storageManager.getStorageVolume(it) == sv }
    }

    fun getExternalMediaDir(sv: StorageVolume): File? {
        return externalMediaDirs.find { storageManager.getStorageVolume(it) == sv }
    }

    fun getMediaStoreVolumeName(sv: StorageVolume): String? {
        // Q は Android 10
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
            return null
        }

        val volumeName: String = when {
            sv.isPrimary -> MediaStore.VOLUME_EXTERNAL_PRIMARY

            // R は Android 11
            Build.VERSION_CODES.R <= Build.VERSION.SDK_INT -> sv.mediaStoreVolumeName ?: return null

            // https://android.googlesource.com/platform/frameworks/base/+/HEAD/core/java/android/os/storage/StorageVolume.java
            // の getMediaStoreVolumeName の実装をそのまま使用
            else -> sv.uuid?.lowercase(Locale.US) ?: return null
        }

        return mediaStoreVolumeNames.find { it == volumeName }
    }

    fun getTopDir(sv: StorageVolume): File? {
        // この関数内で使用する StorageVolume.getDirectory は現在有効でない場合に null を返すのでこの動作に統一する
        if (!checkAvailable(sv)) {
            return null
        }

        // Q は Android 10
        if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
            if (sv.isPrimary) {
                return Environment.getExternalStorageDirectory()
            }

            return try {
                // https://qiita.com/wa2c/items/4b3bacfec9667a5a99d7
                // https://android.googlesource.com/platform/frameworks/base/+/HEAD/core/java/android/os/storage/StorageVolume.java
                @SuppressLint("PrivateApi")
                val getPath = StorageVolume::class.java.getDeclaredMethod("getPath")
                val path = getPath.invoke(sv) as String?
                return path?.let { File(it) }
            }
            catch (_: Exception) {
                null
            }
        }

        return sv.directory
    }

    fun getStorageUuid(sv: StorageVolume): UUID? {
        return when {
            Build.VERSION_CODES.S <= Build.VERSION.SDK_INT -> sv.storageUuid
            else -> null
        }
    }

    fun getDescription(sv: StorageVolume): String {
        return sv.getDescription(ctx)
    }
}