如何获取每个存储卷的免费和总大小?

31

背景

Google(可悲的是)计划破坏存储权限,使应用程序不能使用标准的文件API(和文件路径)访问文件系统。许多人反对此举,因为它改变了应用程序访问存储的方式,在许多方面都是受限和有限的API。

因此,如果我们希望处理各种存储卷并访问其中所有文件,则在某个未来的Android版本上(在Android Q上,我们可以至少暂时地使用标志来使用普通存储权限),我们将需要完全使用SAF(存储访问框架)。

因此,例如,假设您想制作一个文件管理器,并显示设备的所有存储卷,并为每个存储卷显示总字节数和可用字节数。这种事情似乎非常合理,但由于我无法找到这样做的方法。

问题

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

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

问题是,这个列表中的每个项目都没有一个函数可以获取它的大小和可用空间。

然而,一些方式,谷歌的"Files by Google" 应用程序成功地获取了这些信息,而且并没有授予任何权限:

enter image description here

在Galaxy Note 8上测试,使用的是Android 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的应用程序上一样?

@Cheticamp 你说的“好协议”是什么意思?你跟他们谈过这个问题吗? - android developer
我的意思是,在我的测试中,演示应用程序和“Files by Google”的数字匹配。 - Cheticamp
我已经在GitHub上更新了演示应用程序。此次更新增强了数据结构并将反射隔离到扩展函数中。这个扩展函数实现了API应该做的事情。在我看来,这种功能是值得请求的。 - Cheticamp
NDK和使用getInternalPath(),然后使用Unix C功能呢?这似乎是最简单和最优越的解决方案。 - Lothar
@Lother 如果您有一个更好的解决方案,可以覆盖所有存储卷,请将其编写为答案。 - android developer
显示剩余10条评论
3个回答

15
“getAllocatableBytes”确实是获取可用空间的方法吗? Android 8.0 特性和API中提到了“getAllocatableBytes(UUID)”:
“当你需要为大文件分配磁盘空间时,可以考虑使用新的allocateBytes(FileDescriptor, long) API,它将自动清除属于其他应用程序的缓存文件(根据需要),以满足您的请求。在决定设备是否有足够的磁盘空间来容纳您的新数据时,请调用getAllocatableBytes(UUID)而不是使用getUsableSpace(),因为前者将考虑系统愿意代表您清除的任何缓存数据。”
因此,“getAllocatableBytes()”报告通过清除其他应用程序的缓存可以释放多少字节的空间,但可能当前并不是空闲状态。这似乎不是通用文件实用程序的正确调用。
无论如何,由于无法从StorageManager获取除主要卷以外的存储卷的可接受UUID,因此似乎无法对除主要卷以外的任何卷使用getAllocatableBytes(UUID)。请参见Invalid UUID of storage gained from Android StorageManager?Bug report #62982912。(在这里提及是为了完整性; 我知道您已经知道这些内容。)缺陷报告现在已经超过两年,没有解决方案或工作提示,所以不要期望有帮助。
如果您想要“Files by Google”或其他文件管理器报告的空闲空间类型,则需要以下述方式使用不同方法来处理可用空间。 如何获取每个StorageVolume的免费和实际总空间(在某些情况下,由于某些原因,我得到了较低的值),而不需要请求任何权限,就像在Google的应用程序中一样? 以下是获取可用卷的免费和总空间的过程: 识别外部目录:使用getExternalFilesDirs(null)查找可用的外部位置。返回的是一个File[]。这些是我们的应用程序被允许使用的目录。
extDirs = {File2@9489 0 = {File@9509} "/storage/emulated/0/Android/data/com.example.storagevolumes/files" 1 = {File@9510} "/storage/14E4-120B/Android/data/com.example.storagevolumes/files"}
(注:根据文档,此调用返回被视为稳定设备的内容,例如SD卡。这不会返回已连接的USB驱动器。)
识别存储卷:对于上面返回的每个目录,使用StorageManager#getStorageVolume(File)来识别包含该目录的存储卷。我们不需要识别顶层目录以获取存储卷,只需要从存储卷中获取一个文件即可,因此这些目录就可以了。
计算总空间和已用空间:确定存储卷上的空间。主卷与SD卡的处理方式不同。

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

这里有个诀窍:如果您想要“Files by Google”报告的数字,请使用103作为千字节,106作为兆字节和109作为千兆字节。对于其他文件管理器,210、220和230是有效的。(如下所示)有关这些单位的更多信息,请参见this

要获取可用的字节,请使用StorageStatsManager#getFreeBytes(uuid)。已使用的字节是总字节和可用字节之差。
对于非主要卷,非常简单的空间计算:对于已使用的总空间File#getTotalSpace和可用空间File#getFreeSpace
以下是显示音量统计信息的几个屏幕截图。第一张图片显示了StorageVolumeStats应用程序的输出(包括在图像下面),以及“Files by Google”。顶部部分的切换按钮可以在使用1,000和1,024进行千字节转换之间切换。正如您所看到的,这些数字是一致的。(这是从运行Oreo的设备中截取的屏幕截图。我无法将“Files by Google”的测试版加载到Android Q模拟器上。)

enter image description here

下图显示了顶部的StorageVolumeStats应用程序和底部的"EZ File Explorer"输出。这里使用1,024作为千字节,除了四舍五入外,两个应用程序在总可用空间和可用空间方面达成一致。

enter image description here

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(),以便我们可以立即确定空间利用率,但我们受到了限制。由于我们所做的调用取决于从卷列表生成的文件列表,因此我们可以确信所有卷都被我们的代码覆盖,并且我们可以获得所需的空间利用信息。


所以你做的就是写了很多关于我写的和我在代码中写的东西。甚至错误报告也是我写的... 你真的认为 Google 只使用了我发现的东西吗?他们使用 getExternalFilesDirs(我使用了 getExternalCacheDirs 作为备选方案,以防无法立即使用反射获取路径,但这是相同的想法)来获取统计信息吗? - android developer
我知道你写了错误报告,但我认为在这里没有必要让我做出那种联系。你问了两个问题 - 我回答了它们并得到了好的结果(在我看来)。没有反射或任何诡计或任何依赖于弃用方法。如果你想知道Google做了什么,你就得问他们。我认为你需要放弃向用户显示路径名的想法。它看起来不像“Files by Google”,但我无法运行最新的测试版,所以也许它是这样的。如果你想要路径名,你需要对实现做出一些假设。 - Cheticamp
我明白了。但这不仅仅是在Q上。该应用程序也适用于Android 8。 - android developer
@androiddeveloper 我会继续寻找更好的解决方案。 - Cheticamp
让我们在聊天中继续这个讨论 - Cheticamp
显示剩余7条评论

7
以下使用fstatvfs(FileDescriptor)来检索统计信息,而不使用反射或传统的文件系统方法。
为了检查程序输出的总空间、已用和可用空间是否合理,我在运行API 29的Android模拟器上运行了“df”命令。
adb shell中“df”命令报告以1K块为单位的输出:
“/ data”对应于StorageVolume#isPrimary为true时使用的“主”UUID。
“/ storage / 1D03-2E0E”对应于StorageVolume#uuid报告的“1D03-2E0E”UUID。
generic_x86:/ $ df
Filesystem              1K-blocks    Used Available Use% Mounted on
/dev/root                 2203316 2140872     46060  98% /
tmpfs                     1020140     592   1019548   1% /dev
tmpfs                     1020140       0   1020140   0% /mnt
tmpfs                     1020140       0   1020140   0% /apex
/dev/block/vde1            132168   75936     53412  59% /vendor

/dev/block/vdc             793488  647652    129452  84% /data

/dev/block/loop0              232      36       192  16% /apex/com.android.apex.cts.shim@1
/data/media                793488  647652    129452  84% /storage/emulated

/mnt/media_rw/1D03-2E0E    522228      90    522138   1% /storage/1D03-2E0E

使用报告应用程序(以1K块为单位):

对于/tree/primary:/document/primary: 总计=793,488,已使用空间=647,652,可用空间=129,452

对于/tree/1D03-2E0E:/document/1D03-2E0E: 总计=522,228,已使用空间=90,可用空间=522,138

这些总数是匹配的。

描述fstatvfs的信息可以在此处找到。

有关fstatvfs返回内容的详细信息可以在此处找到。

以下简单的应用程序显示了可访问卷的已用、可用和总字节数。

enter image description here

MainActivity.kt

class MainActivity : AppCompatActivity() {
    private lateinit var mStorageManager: StorageManager
    private val mVolumeStats = HashMap<Uri, StructStatVfs>()
    private val mStorageVolumePathsWeHaveAccessTo = HashSet<String>()
    private lateinit var mStorageVolumes: List<StorageVolume>
    private var mHaveAccessToPrimary = false

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        mStorageManager = getSystemService(Context.STORAGE_SERVICE) as StorageManager
        mStorageVolumes = mStorageManager.storageVolumes

        requestAccessButton.setOnClickListener {
            val primaryVolume = mStorageManager.primaryStorageVolume
            val intent = primaryVolume.createOpenDocumentTreeIntent()
            startActivityForResult(intent, 1)
        }

        releaseAccessButton.setOnClickListener {
            val takeFlags =
                Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
            val uri = buildVolumeUriFromUuid(PRIMARY_UUID)

            contentResolver.releasePersistableUriPermission(uri, takeFlags)
            val toast = Toast.makeText(
                this,
                "Primary volume permission released was released.",
                Toast.LENGTH_SHORT
            )
            toast.setGravity(Gravity.BOTTOM, 0, releaseAccessButton.height)
            toast.show()
            getVolumeStats()
            showVolumeStats()
        }
        getVolumeStats()
        showVolumeStats()

    }

    private fun getVolumeStats() {
        val persistedUriPermissions = contentResolver.persistedUriPermissions
        mStorageVolumePathsWeHaveAccessTo.clear()
        persistedUriPermissions.forEach {
            mStorageVolumePathsWeHaveAccessTo.add(it.uri.toString())
        }
        mVolumeStats.clear()
        mHaveAccessToPrimary = false
        for (storageVolume in mStorageVolumes) {
            val uuid = if (storageVolume.isPrimary) {
                // Primary storage doesn't get a UUID here.
                PRIMARY_UUID
            } else {
                storageVolume.uuid
            }

            val volumeUri = uuid?.let { buildVolumeUriFromUuid(it) }

            when {
                uuid == null ->
                    Log.d(TAG, "UUID is null for ${storageVolume.getDescription(this)}!")
                mStorageVolumePathsWeHaveAccessTo.contains(volumeUri.toString()) -> {
                    Log.d(TAG, "Have access to $uuid")
                    if (uuid == PRIMARY_UUID) {
                        mHaveAccessToPrimary = true
                    }
                    val uri = buildVolumeUriFromUuid(uuid)
                    val docTreeUri = DocumentsContract.buildDocumentUriUsingTree(
                        uri,
                        DocumentsContract.getTreeDocumentId(uri)
                    )
                    mVolumeStats[docTreeUri] = getFileStats(docTreeUri)
                }
                else -> Log.d(TAG, "Don't have access to $uuid")
            }
        }
    }

    private fun showVolumeStats() {
        val sb = StringBuilder()
        if (mVolumeStats.size == 0) {
            sb.appendln("Nothing to see here...")
        } else {
            sb.appendln("All figures are in 1K blocks.")
            sb.appendln()
        }
        mVolumeStats.forEach {
            val lastSeg = it.key.lastPathSegment
            sb.appendln("Volume: $lastSeg")
            val stats = it.value
            val blockSize = stats.f_bsize
            val totalSpace = stats.f_blocks * blockSize / 1024L
            val freeSpace = stats.f_bfree * blockSize / 1024L
            val usedSpace = totalSpace - freeSpace
            sb.appendln(" Used space: ${usedSpace.nice()}")
            sb.appendln(" Free space: ${freeSpace.nice()}")
            sb.appendln("Total space: ${totalSpace.nice()}")
            sb.appendln("----------------")
        }
        volumeStats.text = sb.toString()
        if (mHaveAccessToPrimary) {
            releaseAccessButton.visibility = View.VISIBLE
            requestAccessButton.visibility = View.GONE
        } else {
            releaseAccessButton.visibility = View.GONE
            requestAccessButton.visibility = View.VISIBLE
        }
    }

    private fun buildVolumeUriFromUuid(uuid: String): Uri {
        return DocumentsContract.buildTreeDocumentUri(
            EXTERNAL_STORAGE_AUTHORITY,
            "$uuid:"
        )
    }

    private fun getFileStats(docTreeUri: Uri): StructStatVfs {
        val pfd = contentResolver.openFileDescriptor(docTreeUri, "r")!!
        return fstatvfs(pfd.fileDescriptor)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        Log.d(TAG, "resultCode:$resultCode")
        val uri = data?.data ?: return
        val takeFlags =
            Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
        contentResolver.takePersistableUriPermission(uri, takeFlags)
        Log.d(TAG, "granted uri: ${uri.path}")
        getVolumeStats()
        showVolumeStats()
    }

    companion object {
        fun Long.nice(fieldLength: Int = 12): String = String.format(Locale.US, "%,${fieldLength}d", this)

        const val EXTERNAL_STORAGE_AUTHORITY = "com.android.externalstorage.documents"
        const val PRIMARY_UUID = "primary"
        const val TAG = "AppLog"
    }
}

activity_main.xml

<LinearLayout 
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        tools:context=".MainActivity">

    <TextView
            android:id="@+id/volumeStats"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_marginBottom="16dp"
            android:layout_weight="1"
            android:fontFamily="monospace"
            android:padding="16dp" />

    <Button
            android:id="@+id/requestAccessButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:layout_marginBottom="16dp"
            android:visibility="gone"
            android:text="Request Access to Primary" />

    <Button
            android:id="@+id/releaseAccessButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:layout_marginBottom="16dp"
            android:text="Release Access to Primary" />
</LinearLayout>   

你如何从 fstatvfs 获取总空间和可用空间?能否请更新一下? - android developer
2
演示程序保持在SAF的限制范围内。将来,所有传统的文件级访问(目录路径等)是否会关闭应用程序沙盒之外?与此同时,我会尽可能使用SAF,并在SAF无法满足请求时回退到目录路径。至于空间,我的经验是一些空间总是通过低级格式化、引导分区、隐藏分区、VM堆等途径泄漏出去。当在shell中运行“df”命令时,演示程序与其报告相符。 - Cheticamp
1
@androiddeveloper 存储功能需要路径(虽然现在不鼓励使用 SAF,但目前仍可用)或文件描述符(除非获得权限,否则无法通过 SAF 使用)。除非我不知道某种方法(这总是有可能的),否则我认为这是我们能做到的最好的。随着 Q 的推进,文档的改进,错误的修复等等,可能会有新的发现。 - Cheticamp
这种技术在Android 12上需要很长时间。有什么想法为什么?或者有任何解决方法吗?https://dev59.com/-cLra4cB1Zd3GeqPRcga - Gavin Wright
@GavinWright 我已经很久没有看过这个了,我真的无法说。我认为Android已经将文件访问这个已解决的问题提升到了一个新的水平。 - Cheticamp
显示剩余16条评论

5
发现一种解决方法,通过使用我在这里所写的内容,并按照我在这里所写的方式将每个StorageVolume映射到一个真实的文件。遗憾的是,这种方法可能在未来无效,因为它使用了很多“技巧”。
        for (storageVolume in storageVolumes) {
            val volumePath = FileUtilEx.getVolumePath(storageVolume)
            if (volumePath == null) {
                Log.d("AppLog", "storageVolume \"${storageVolume.getDescription(this)}\" - failed to get volumePath")
            } else {
                val statFs = StatFs(volumePath)
                val availableSizeInBytes = statFs.availableBytes
                val totalBytes = statFs.totalBytes
                val formattedResult = "availableSizeInBytes:${android.text.format.Formatter.formatShortFileSize(this, availableSizeInBytes)} totalBytes:${android.text.format.Formatter.formatShortFileSize(this, totalBytes)}"
                Log.d("AppLog", "storageVolume \"${storageVolume.getDescription(this)}\" - volumePath:$volumePath - $formattedResult")
            }
        }

似乎在模拟器(具有主存储和SD卡)和真实设备(Pixel 2)上都可以工作,均在Android Q beta 4上。

一个更好的解决方案是不使用反射,在ContextCompat.getExternalCacheDirs中的每个路径上放一个唯一的文件,然后通过每个StorageVolume实例尝试查找它们。这很棘手,因为你不知道何时开始搜索,所以你将需要检查各种路径,直到达到目的地。不仅如此,正如我在这里所写的那样,我认为没有官方方法可以获得每个StorageVolume的Uri或DocumentFile或File或文件路径。

不管怎样,奇怪的事情是总空间比实际空间低。可能是因为它是用户实际可用的最大空间的一个分区。

我想知道为什么各种应用程序(如文件管理器应用程序,例如Total Commander)会获取实际的总设备存储空间。


编辑:好了,又找到了另一个解决方法,这可能更可靠,基于storageManager.getStorageVolume(File)函数。

因此,以下是两个解决方法的合并:

fun getStorageVolumePath(context: Context, storageVolumeToGetItsPath: StorageVolume): String? {
    //first, try to use reflection
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP)
        return null
    try {
        val storageVolumeClazz = StorageVolume::class.java
        val getPathMethod = storageVolumeClazz.getMethod("getPath")
        val result = getPathMethod.invoke(storageVolumeToGetItsPath) as String?
         if (!result.isNullOrBlank())
            return result
    } catch (e: Exception) {
        e.printStackTrace()
    }
    //failed to use reflection, so try mapping with app's folders
    val storageVolumeUuidStr = storageVolumeToGetItsPath.uuid
    val externalCacheDirs = ContextCompat.getExternalCacheDirs(context)
    val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
    for (externalCacheDir in externalCacheDirs) {
        val storageVolume = storageManager.getStorageVolume(externalCacheDir) ?: continue
        val uuidStr = storageVolume.uuid
        if (uuidStr == storageVolumeUuidStr) {
            //found storageVolume<->File match
            var resultFile = externalCacheDir
            while (true) {
                val parentFile = resultFile.parentFile ?: return resultFile.absolutePath
                val parentFileStorageVolume = storageManager.getStorageVolume(parentFile)
                        ?: return resultFile.absolutePath
                if (parentFileStorageVolume.uuid != uuidStr)
                    return resultFile.absolutePath
                resultFile = parentFile
            }
        }
    }
    return null
}

要显示可用空间和总空间,我们像以前一样使用StatFs:

for (storageVolume in storageVolumes) {
    val storageVolumePath = getStorageVolumePath(this@MainActivity, storageVolume) ?: continue
    val statFs = StatFs(storageVolumePath)
    val availableSizeInBytes = statFs.availableBytes
    val totalBytes = statFs.totalBytes
    val formattedResult = "availableSizeInBytes:${android.text.format.Formatter.formatShortFileSize(this, availableSizeInBytes)} totalBytes:${android.text.format.Formatter.formatShortFileSize(this, totalBytes)}"
    Log.d("AppLog", "storageVolume \"${storageVolume.getDescription(this)}\" - storageVolumePath:$storageVolumePath - $formattedResult")
}

编辑:简化版本,不使用存储卷的实际文件路径:

fun getStatFsForStorageVolume(context: Context, storageVolumeToGetItsPath: StorageVolume): StatFs? {
    //first, try to use reflection
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
        return null
    try {
        val storageVolumeClazz = StorageVolume::class.java
        val getPathMethod = storageVolumeClazz.getMethod("getPath")
        val resultPath = getPathMethod.invoke(storageVolumeToGetItsPath) as String?
        if (!resultPath.isNullOrBlank())
            return StatFs(resultPath)
    } catch (e: Exception) {
        e.printStackTrace()
    }
    //failed to use reflection, so try mapping with app's folders
    val storageVolumeUuidStr = storageVolumeToGetItsPath.uuid
    val externalCacheDirs = ContextCompat.getExternalCacheDirs(context)
    val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
    for (externalCacheDir in externalCacheDirs) {
        val storageVolume = storageManager.getStorageVolume(externalCacheDir) ?: continue
        val uuidStr = storageVolume.uuid
        if (uuidStr == storageVolumeUuidStr) {
            //found storageVolume<->File match
            return StatFs(externalCacheDir.absolutePath)
        }
    }
    return null
}

使用方法:

        for (storageVolume in storageVolumes) {
            val statFs = getStatFsForStorageVolume(this@MainActivity, storageVolume)
                    ?: continue
            val availableSizeInBytes = statFs.availableBytes
            val totalBytes = statFs.totalBytes
            val formattedResult = "availableSizeInBytes:${android.text.format.Formatter.formatShortFileSize(this, availableSizeInBytes)} totalBytes:${android.text.format.Formatter.formatShortFileSize(this, totalBytes)}"
            Log.d("AppLog", "storageVolume \"${storageVolume.getDescription(this)}\" - $formattedResult")
        }

请注意,此解决方案不需要任何类型的权限。
--
编辑:实际上我发现过去曾经尝试过,但是由于某种原因,在模拟器的SD卡存储卷上它崩溃了。
        val storageStatsManager = getSystemService(Context.STORAGE_STATS_SERVICE) as StorageStatsManager
        for (storageVolume in storageVolumes) {
            val uuidStr = storageVolume.uuid
            val uuid = if (uuidStr == null) StorageManager.UUID_DEFAULT else UUID.fromString(uuidStr)
            val availableSizeInBytes = storageStatsManager.getFreeBytes(uuid)
            val totalBytes = storageStatsManager.getTotalBytes(uuid)
            val formattedResult = "availableSizeInBytes:${android.text.format.Formatter.formatShortFileSize(this, availableSizeInBytes)} totalBytes:${android.text.format.Formatter.formatShortFileSize(this, totalBytes)}"
            Log.d("AppLog", "storageVolume \"${storageVolume.getDescription(this)}\" - $formattedResult")
        }

好消息是,对于主存储卷,您可以获得其真实的总空间。

在真实设备上,SD卡会导致应用程序崩溃,但主存储卷不会。


因此,以下是最新解决方案,汇集了以上内容:

        for (storageVolume in storageVolumes) {
            val availableSizeInBytes: Long
            val totalBytes: Long
            if (storageVolume.isPrimary) {
                val storageStatsManager = getSystemService(Context.STORAGE_STATS_SERVICE) as StorageStatsManager
                val uuidStr = storageVolume.uuid
                val uuid = if (uuidStr == null) StorageManager.UUID_DEFAULT else UUID.fromString(uuidStr)
                availableSizeInBytes = storageStatsManager.getFreeBytes(uuid)
                totalBytes = storageStatsManager.getTotalBytes(uuid)
            } else {
                val statFs = getStatFsForStorageVolume(this@MainActivity, storageVolume)
                        ?: continue
                availableSizeInBytes = statFs.availableBytes
                totalBytes = statFs.totalBytes
            }
            val formattedResult = "availableSizeInBytes:${android.text.format.Formatter.formatShortFileSize(this, availableSizeInBytes)} totalBytes:${android.text.format.Formatter.formatShortFileSize(this, totalBytes)}"
            Log.d("AppLog", "storageVolume \"${storageVolume.getDescription(this)}\" - $formattedResult")
        }

针对Android R的更新答案:

        fun getStorageVolumesAccessState(context: Context) {
            val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
            val storageVolumes = storageManager.storageVolumes
            val storageStatsManager = context.getSystemService(Context.STORAGE_STATS_SERVICE) as StorageStatsManager
            for (storageVolume in storageVolumes) {
                var freeSpace: Long = 0L
                var totalSpace: Long = 0L
                val path = getPath(context, storageVolume)
                if (storageVolume.isPrimary) {
                    totalSpace = storageStatsManager.getTotalBytes(StorageManager.UUID_DEFAULT)
                    freeSpace = storageStatsManager.getFreeBytes(StorageManager.UUID_DEFAULT)
                } else if (path != null) {
                    val file = File(path)
                    freeSpace = file.freeSpace
                    totalSpace = file.totalSpace
                }
                val usedSpace = totalSpace - freeSpace
                val freeSpaceStr = Formatter.formatFileSize(context, freeSpace)
                val totalSpaceStr = Formatter.formatFileSize(context, totalSpace)
                val usedSpaceStr = Formatter.formatFileSize(context, usedSpace)
                Log.d("AppLog", "${storageVolume.getDescription(context)} - path:$path total:$totalSpaceStr used:$usedSpaceStr free:$freeSpaceStr")
            }
        }

        fun getPath(context: Context, storageVolume: StorageVolume): String? {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
                storageVolume.directory?.absolutePath?.let { return it }
            try {
                return storageVolume.javaClass.getMethod("getPath").invoke(storageVolume) as String
            } catch (e: Exception) {
            }
            try {
                return (storageVolume.javaClass.getMethod("getPathFile").invoke(storageVolume) as File).absolutePath
            } catch (e: Exception) {
            }
            val extDirs = context.getExternalFilesDirs(null)
            for (extDir in extDirs) {
                val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
                val fileStorageVolume: StorageVolume = storageManager.getStorageVolume(extDir)
                        ?: continue
                if (fileStorageVolume == storageVolume) {
                    var file = extDir
                    while (true) {
                        val parent = file.parentFile ?: return file.absolutePath
                        val parentStorageVolume = storageManager.getStorageVolume(parent)
                                ?: return file.absolutePath
                        if (parentStorageVolume != storageVolume)
                            return file.absolutePath
                        file = parent
                    }
                }
            }
            try {
                val parcel = Parcel.obtain()
                storageVolume.writeToParcel(parcel, 0)
                parcel.setDataPosition(0)
                parcel.readString()
                return parcel.readString()
            } catch (e: Exception) {
            }
            return null
        }

1
我已经测试了您发布的最新版本,针对Android R和外部USB驱动器,在Pixel 2设备上的Android 11中,总大小和可用大小始终为0。您是否碰巧找到了解决这些问题的方法? - joaomgcd
@joaomgcd 外部 USB 驱动器?不知道。有没有办法在没有它的情况下测试它?特别是在模拟器上?模拟器显示了其存储卷的大小...连接另一部智能手机是否被视为外部 USB 驱动器?一个智能手机可以访问另一个智能手机的存储吗? - android developer
@joaomgcd 我有另一部智能手机和一根 USB-C 到 USB-C 的数据线。这样可以吗?一个设备能否通过这种方式查看另一个设备的存储空间?我想知道是否可能(并且是否有意义)以这种方式检查可用存储空间。 - android developer
@joaomgcd 我认为这可能是不可能的。由于某种原因,我发现只有在内置的“文件”应用程序中才能访问(带有一些信息大小数字)。在其他文件管理器应用程序中,我未能到达其他设备的路径(除非使用SAW)。因此,因为我认为这是不可能的,所以在这里提出了请求:https://issuetracker.google.com/issues/185527171。请考虑点赞。 - android developer
@joaomgcd 请在那里创建一个新的请求。我可以给它点赞。 - android developer
显示剩余7条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接