首页 > 解决方案 > 如何获取每个 StorageVolume 的免费大小和总大小?

问题描述

背景

谷歌(遗憾地)计划破坏存储权限,以便应用程序无法使用标准文件 API(和文件路径)访问文件系统。许多人反对它,因为它改变了应用程序访问存储的方式,并且在许多方面它是一个受限制的 API。

因此,如果我们希望处理各种存储卷并到达那里的所有文件。

因此,例如,假设您要创建一个文件管理器并显示设备的所有存储卷,并显示每个存储卷的总字节数和空闲字节数。这样的事情似乎很合法,但是我找不到做这样的事情的方法。

问题

从 API 24(此处)开始,我们终于能够列出所有存储卷,如下所示:

    val storageManager = getSystemService(Context.STORAGE_SERVICE) as StorageManager
    val storageVolumes = storageManager.storageVolumes

问题是,此列表中的每个项目都没有获取其大小和可用空间的功能。

然而,不知何故,谷歌的“谷歌文件”应用程序在没有任何许可的情况下设法获取了这些信息:

在此处输入图像描述

这是在装有 Android 8 的 Galaxy Note 8 上进行的测试。甚至最新版本的 Android 也没有。

所以这意味着应该有一种方法可以在没有任何许可的情况下获取这些信息,即使在 Android 8 上也是如此。

我发现了什么

有一些类似于获得自由空间的东西,但我不确定是否确实如此。不过,它似乎是这样的。这是它的代码:

    val storageManager = getSystemService(Context.STORAGE_SERVICE) as StorageManager
    val storageVolumes = storageManager.storageVolumes
    AsyncTask.execute {
        for (storageVolume in storageVolumes) {
            val uuid: UUID = storageVolume.uuid?.let { UUID.fromString(it) } ?: StorageManager.UUID_DEFAULT
            val allocatableBytes = storageManager.getAllocatableBytes(uuid)
            Log.d("AppLog", "allocatableBytes:${android.text.format.Formatter.formatShortFileSize(this,allocatableBytes)}")
        }
    }

但是,我找不到类似的东西来获取每个 StorageVolume 实例的总空间。假设我对此是正确的,我已在此处提出请求。

您可以在我写给这个问题的答案中找到更多我发现的内容,但目前这都是变通办法和不是变通办法但在某些情况下有效的东西的混合。

问题

  1. 确实是getAllocatableBytes获取空闲空间的方法吗?
  2. 如何在不请求任何许可的情况下获得每个 StorageVolume 的免费和实际总空间(在某些情况下由于某种原因我得到了较低的值),就像在 Google 的应用程序上一样?

标签: androidstorage-access-framework

解决方案


getAllocatableBytes 确实是获得可用空间的方法吗?

Android 8.0 功能和 API声明getAllocatableBytes(UUID)

最后,当您需要为大文件分配磁盘空间时,可以考虑使用新的 allocateBytes(FileDescriptor, long) API,它会自动清除属于其他应用程序的缓存文件(根据需要)以满足您的请求。在决定设备是否有足够的磁盘空间来保存新数据时,请调用 getAllocatableBytes(UUID) 而不是使用 getUsableSpace(),因为前者会考虑系统愿意代表您清除的任何缓存数据。

因此,getAllocatableBytes()通过清除其他应用程序的缓存来报告新文件可以释放多少字节,但当前可能不可用。这似乎不是对通用文件实用程序的正确调用。

在任何情况下,getAllocatableBytes(UUID)似乎都不适用于除主卷之外的任何卷,因为无法从StorageManager为主卷以外的存储卷获取可接受的 UUID。请参阅从 Android StorageManager 获得的无效 UUID 存储?错误报告 #62982912。(这里提到的完整性;我知道你已经知道这些了。)错误报告现在已经两年多了,没有解决方案或提示解决方法,所以没有爱。

如果您想要“Google 文件”或其他文件管理器报告的可用空间类型,那么您需要以不同的方式处理可用空间,如下所述。

如何在不请求任何许可的情况下获得每个 StorageVolume 的免费和实际总空间(在某些情况下由于某种原因我得到了较低的值),就像在 Google 的应用程序上一样?

以下是获取可用卷的可用空间和总空间的过程:

识别外部目录:使用getExternalFilesDirs(null)发现可用的外部位置。返回的是一个File[]。这些是我们的应用程序被允许使用的目录。

extDirs = {File 2 @9489
0 = {File@9509} "/storage/emulated/0/Android/data/com.example.storagevolumes/files"
1 = {File@9510} "/storage/14E4-120B/Android /data/com.example.storagevolumes/文件”

(注意根据文档,此调用返回被认为是稳定的设备,例如 SD 卡。这不会返回连接的 USB 驱动器。)

识别存储卷:对于上面返回的每个目录,使用StorageManager#getStorageVolume(File)来识别包含该目录的存储卷。我们不需要识别顶级目录来获取存储卷,只需从存储卷中获取一个文件,这些目录就可以了。

计算总空间和已用空间:确定存储卷上的空间。主卷的处理方式与 SD 卡不同。

对于主卷:使用StorageStatsManager#getTotalBytes(UUID使用StorageManager#UUID_DEFAULT 获取主设备上存储的标称总字节数。返回的值将千字节视为 1,000 字节(而不是 1,024),将千兆字节视为 1,000,000,000 字节而不是 2 30 . 在我的三星 Galaxy S7 上报告的值为 32,000,000,000 字节。在我的 Pixel 3 模拟器上运行 API 29 和 16 MB 的存储空间,报告的值为 16,000,000,000。

诀窍是:如果您想要“Google 文件”报告的数字,请使用 10 3表示一千字节,使用 10 6表示一兆字节,使用 10 9表示一千兆字节。对于其他文件管理器 2 10、 2 20和 2 30是有效的。(这在下面演示。)有关这些单元的更多信息,请参阅此。

要获取空闲字节,请使用StorageStatsManager#getFreeBytes(uuid)。已用字节是总字节数和空闲字节数之差。

对于非主卷:非主卷的空间计算很简单:对于已使用的总空间File#getTotalSpaceFile#getFreeSpace作为可用空间。

这里有几个显示音量统计的屏幕截图。第一张图片显示了StorageVolumeStats应用程序(包括在图片下方)和“Google 提供的文件”的输出。顶部顶部的切换按钮将应用程序在使用 1,000 和 1,024 千字节之间切换。正如你所看到的,这些数字是一致的。(这是运行 Oreo 的设备的屏幕截图。我无法将“Google 文件”的测试版加载到 Android Q 模拟器上。)

在此处输入图像描述

下图顶部显示StorageVolumeStats应用程序,底部显示“EZ 文件资源管理器”的输出。这里 1,024 用于表示千字节,两个应用程序就可用的总空间和可用空间达成一致,但舍入除外。

在此处输入图像描述

MainActivity.kt

这个小应用程序只是主要活动。清单是通用的,compileSdkVersiontargetSdkVersion设置为29。minSdkVersion为 26。

class MainActivity : AppCompatActivity() {
    private lateinit var mStorageManager: StorageManager
    private val mStorageVolumesByExtDir = mutableListOf<VolumeStats>()
    private lateinit var mVolumeStats: TextView
    private lateinit var mUnitsToggle: ToggleButton
    private var mKbToggleValue = true
    private var kbToUse = KB
    private var mbToUse = MB
    private var gbToUse = GB

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (savedInstanceState != null) {
            mKbToggleValue = savedInstanceState.getBoolean("KbToggleValue", true)
            selectKbValue()
        }
        setContentView(statsLayout())

        mStorageManager = getSystemService(Context.STORAGE_SERVICE) as StorageManager

        getVolumeStats()
        showVolumeStats()
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putBoolean("KbToggleValue", mKbToggleValue)
    }

    private fun getVolumeStats() {
        // We will get our volumes from the external files directory list. There will be one
        // entry per external volume.
        val extDirs = getExternalFilesDirs(null)

        mStorageVolumesByExtDir.clear()
        extDirs.forEach { file ->
            val storageVolume: StorageVolume? = mStorageManager.getStorageVolume(file)
            if (storageVolume == null) {
                Log.d(TAG, "Could not determinate StorageVolume for ${file.path}")
            } else {
                val totalSpace: Long
                val usedSpace: Long
                if (storageVolume.isPrimary) {
                    // Special processing for primary volume. "Total" should equal size advertised
                    // on retail packaging and we get that from StorageStatsManager. Total space
                    // from File will be lower than we want to show.
                    val uuid = StorageManager.UUID_DEFAULT
                    val storageStatsManager =
                        getSystemService(Context.STORAGE_STATS_SERVICE) as StorageStatsManager
                    // Total space is reported in round numbers. For example, storage on a
                    // SamSung Galaxy S7 with 32GB is reported here as 32_000_000_000. If
                    // true GB is needed, then this number needs to be adjusted. The constant
                    // "KB" also need to be changed to reflect KiB (1024).
//                    totalSpace = storageStatsManager.getTotalBytes(uuid)
                    totalSpace = (storageStatsManager.getTotalBytes(uuid) / 1_000_000_000) * gbToUse
                    usedSpace = totalSpace - storageStatsManager.getFreeBytes(uuid)
                } else {
                    // StorageStatsManager doesn't work for volumes other than the primary volume
                    // since the "UUID" available for non-primary volumes is not acceptable to
                    // StorageStatsManager. We must revert to File for non-primary volumes. These
                    // figures are the same as returned by statvfs().
                    totalSpace = file.totalSpace
                    usedSpace = totalSpace - file.freeSpace
                }
                mStorageVolumesByExtDir.add(
                    VolumeStats(storageVolume, totalSpace, usedSpace)
                )
            }
        }
    }

    private fun showVolumeStats() {
        val sb = StringBuilder()
        mStorageVolumesByExtDir.forEach { volumeStats ->
            val (usedToShift, usedSizeUnits) = getShiftUnits(volumeStats.mUsedSpace)
            val usedSpace = (100f * volumeStats.mUsedSpace / usedToShift).roundToLong() / 100f
            val (totalToShift, totalSizeUnits) = getShiftUnits(volumeStats.mTotalSpace)
            val totalSpace = (100f * volumeStats.mTotalSpace / totalToShift).roundToLong() / 100f
            val uuidToDisplay: String?
            val volumeDescription =
                if (volumeStats.mStorageVolume.isPrimary) {
                    uuidToDisplay = ""
                    PRIMARY_STORAGE_LABEL
                } else {
                    uuidToDisplay = " (${volumeStats.mStorageVolume.uuid})"
                    volumeStats.mStorageVolume.getDescription(this)
                }
            sb
                .appendln("$volumeDescription$uuidToDisplay")
                .appendln(" Used space: ${usedSpace.nice()} $usedSizeUnits")
                .appendln("Total space: ${totalSpace.nice()} $totalSizeUnits")
                .appendln("----------------")
        }
        mVolumeStats.text = sb.toString()
    }

    private fun getShiftUnits(x: Long): Pair<Long, String> {
        val usedSpaceUnits: String
        val shift =
            when {
                x < kbToUse -> {
                    usedSpaceUnits = "Bytes"; 1L
                }
                x < mbToUse -> {
                    usedSpaceUnits = "KB"; kbToUse
                }
                x < gbToUse -> {
                    usedSpaceUnits = "MB"; mbToUse
                }
                else -> {
                    usedSpaceUnits = "GB"; gbToUse
                }
            }
        return Pair(shift, usedSpaceUnits)
    }

    @SuppressLint("SetTextI18n")
    private fun statsLayout(): SwipeRefreshLayout {
        val swipeToRefresh = SwipeRefreshLayout(this)
        swipeToRefresh.setOnRefreshListener {
            getVolumeStats()
            showVolumeStats()
            swipeToRefresh.isRefreshing = false
        }

        val scrollView = ScrollView(this)
        swipeToRefresh.addView(scrollView)
        val linearLayout = LinearLayout(this)
        linearLayout.orientation = LinearLayout.VERTICAL
        scrollView.addView(
            linearLayout, ViewGroup.LayoutParams.MATCH_PARENT,
            ViewGroup.LayoutParams.WRAP_CONTENT
        )

        val instructions = TextView(this)
        instructions.text = "Swipe down to refresh."
        linearLayout.addView(
            instructions, ViewGroup.LayoutParams.WRAP_CONTENT,
            ViewGroup.LayoutParams.WRAP_CONTENT
        )
        (instructions.layoutParams as LinearLayout.LayoutParams).gravity = Gravity.CENTER

        mUnitsToggle = ToggleButton(this)
        mUnitsToggle.textOn = "KB = 1,000"
        mUnitsToggle.textOff = "KB = 1,024"
        mUnitsToggle.isChecked = mKbToggleValue
        linearLayout.addView(
            mUnitsToggle, ViewGroup.LayoutParams.WRAP_CONTENT,
            ViewGroup.LayoutParams.WRAP_CONTENT
        )
        mUnitsToggle.setOnClickListener { v ->
            val toggleButton = v as ToggleButton
            mKbToggleValue = toggleButton.isChecked
            selectKbValue()
            getVolumeStats()
            showVolumeStats()
        }

        mVolumeStats = TextView(this)
        mVolumeStats.typeface = Typeface.MONOSPACE
        val padding =
            16 * (resources.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT).toInt()
        mVolumeStats.setPadding(padding, padding, padding, padding)

        val lp = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0)
        lp.weight = 1f
        linearLayout.addView(mVolumeStats, lp)

        return swipeToRefresh
    }

    private fun selectKbValue() {
        if (mKbToggleValue) {
            kbToUse = KB
            mbToUse = MB
            gbToUse = GB
        } else {
            kbToUse = KiB
            mbToUse = MiB
            gbToUse = GiB
        }
    }

    companion object {
        fun Float.nice(fieldLength: Int = 6): String =
            String.format(Locale.US, "%$fieldLength.2f", this)

        // StorageVolume should have an accessible "getPath()" method that will do
        // the following so we don't have to resort to reflection.
        @Suppress("unused")
        fun StorageVolume.getStorageVolumePath(): String {
            return try {
                javaClass
                    .getMethod("getPath")
                    .invoke(this) as String
            } catch (e: Exception) {
                e.printStackTrace()
                ""
            }
        }

        // See https://en.wikipedia.org/wiki/Kibibyte for description
        // of these units.

        // These values seems to work for "Files by Google"...
        const val KB = 1_000L
        const val MB = KB * KB
        const val GB = KB * KB * KB

        // ... and these values seems to work for other file manager apps.
        const val KiB = 1_024L
        const val MiB = KiB * KiB
        const val GiB = KiB * KiB * KiB

        const val PRIMARY_STORAGE_LABEL = "Internal Storage"

        const val TAG = "MainActivity"
    }

    data class VolumeStats(
        val mStorageVolume: StorageVolume,
        var mTotalSpace: Long = 0,
        var mUsedSpace: Long = 0
    )
}

附录

让我们更熟悉使用getExternalFilesDirs()

我们在代码中调用Context#getExternalFilesDirs() 。在此方法中,调用Environment#buildExternalStorageAppFilesDirs()调用Environment#getExternalDirs()从StorageManager获取卷列表。这个存储列表用于创建我们看到的从Context#getExternalFilesDirs()返回的路径,方法是将一些静态路径段附加到每个存储卷标识的路径。

我们真的很想访问Environment#getExternalDirs()以便我们可以立即确定空间利用率,但我们受到限制。由于我们进行的调用依赖于从卷列表生成的文件列表,因此我们可以放心所有卷都被 out 代码覆盖,并且我们可以获得所需的空间利用率信息。


推荐阅读