USB 存储媒介

MediaStore

建议应用通过 MediaStore 浏览和访问 Android 设备上的媒体内容。MediaStore 会将所有可合并的卷编入索引,然后将其中的音频、视频和图片内容提供给应用。LocalMediaPlayer 是 MediaStore API 的一个示例用法。

可合并的存储设备

如果驱动器在不太可能经常断开连接的固定位置进行连接,则可以被视为“可合并”。您可以使用 encryptable=userdata 属性在 fstab 中将 USB 端口标记为“稳定”(请参阅设备配置)。

任何连接到可合并端口的驱动器都会被编入索引。除了需要花费时间进行处理之外,生成的索引还会被写入内部闪存。

Android 9 中的已知限制

在 Android 9 中,当驱动器未处于连接状态时,可合并驱动器的索引会被删除,也就是说,如果用户将某个驱动器移除后立即重新连接,则系统会从闪存中删除原始索引,然后在该驱动器重新编入索引时对其进行重写。

此外,Android 9 还在启动时引入了竞态条件,在这种情况下,驱动器可能会被误判断为已断开连接,然后系统会对其执行相同的删除后重新编入索引的流程。最糟糕的情况是,每次启动汽车时都会重新写入本地闪存。

Android 10 的新功能

Android 10 对 MediaStore 进行了多方面的优化。例如,解决了上述竞态条件问题。移除驱动器后,其索引会被保留一周(自最后一次检测到驱动器之日起)。如果在此期间重新连接该驱动器,其索引不会被重新写入闪存。该框架可为多个驱动器执行此操作。

重新连接驱动器时,如果其中的内容有所变化,系统会更新(而不是完全重新写入)索引。这样可最大限度地减少对闪存的影响。系统也会在启动时执行这项检查。如果用户在停车后移除了某个驱动器,在该驱动器中添加了一些新媒体,并在启动车辆之前重新连接该驱动器,那么系统就会更新驱动器的索引,以包含新添加的媒体。

其他要求

通过将端口设为“可合并”,任何连接到该端口的驱动器都可以合并到系统存储空间中。出现这种情况时,驱动器会被视为设备存储空间的扩展,且在首次将其转换回便携式媒体之前,不会将其移除。

连接驱动器后,该框架会触发一则通知,告知用户可以合并该驱动器。这将启动一个向导流程,以便用户格式化驱动器。在 Android 10 中,此流程未内置在汽车设置中。原始设备制造商 (OEM) 需负责确保在将端口标记为“可合并”时支持此功能。CtsAppSecurityHostTestCases 涵盖与可合并的端口相关的功能,并且必须处于运行状态。

检测媒体的变化

正在运行的进程(Activity 或服务)可以在 MediaStore.AUTHORITY_URI 上注册 ContentObserver,这样一来,观测程序就可以在所有可合并卷中的内容发生变化时收到通知。

ContentObserver mContentObserver = new ContentObserver(new Handler()) {
    @Override
    public void onChange(boolean selfChange, Uri uri) {
        super.onChange(selfChange, uri);
        if (isResumed()) {
            // take action here
        }
    }
};

context.getContentResolver().registerContentObserver(
MediaStore.AUTHORITY_URI, true, mContentObserver);

通过将 notifyForDescendants 设为 true,观测程序会在相应 URI 下的可用媒体发生任何变化时收到通知,而不是明确地针对相应 URI 的变化收到通知。因此,在将新连接的驱动器编入索引时,系统会使用每个新添加的 URI 触发该观测程序。

在 Android 10 中,您可以使用 JobScheduler 监控内容发生的变化,而无需运行进程。但是,仅当设备有可用资源后,系统才会执行该作业。

列出卷

在 Android 10 中,对于已编入索引或正在编入索引的所有可合并卷的名称,可以使用新的 Media Store API 进行检索:

List<String> volumeNames = MediaStore.getExternalVolumeNames(context);

浏览卷内容

通过可合并卷的名称,进程可以获取该卷内特定类型的媒体(音频、视频、图片或文件)的 URI,还可以查询该 URI 下所有已编入索引的可用文件:

import android.provider.MediaStore.Audio.Media;

Uri volumeAudioUri = Media.getContentUri(volumeName);
String[] projection = {Media._ID, Media.ARTIST, Media.TITLE};

Cursor cursor = getContext().getContentResolver().query(volumeAudioUri, projection, null, null);

浏览所有卷

MediaStore 下的每类内容都有一个 EXTERNAL_CONTENT_URIINTERNAL_CONTENT_URI,可用于获取外部和内部卷中所有内容的列表。如需查看有关如何结合使用这两种 URI 以查看所有可用媒体的示例,请参阅 packages/apps/Car/LocalMediaPlayer

所需权限

设法从外部存储设备读取的任何应用都必须请求 READ_EXTERNAL_STORAGE 权限。

播放媒体

系统可针对特定光标项生成 URI:

Long mediaId = cursor.getLong(cursor.getColumnIndex(Media._ID));
Uri mediaUri = ContentUris.withAppendedId(volumeAudioUri, mediaId);

接下来,应用可以请求音频焦点并播放内容。如需详细了解构建媒体应用的最佳做法,请参阅音频和视频概览

MediaStore 的替代方案

建议使用 MediaStore 和 ContentResolver 访问可合并的驱动器上的内容。但对于不可合并的 USB 端口,您可以通过其他方法检测变化和访问内容。

检测已连接卷的变化

系统应用可以监听已连接的卷发生的变化。每当卷的状态发生变化时(包括正在检查或移除卷以及装载或卸载卷时),StorageEventListener 都会收到通知。

StorageEventListener mStorageEventListener = new StorageEventListener() {
    @Override
    public void onVolumeStateChanged(VolumeInfo vol, int oldState, int newState) {
        if (isResumed()) {
            // take action here
        }
    }
};

StorageManager mStorageManager = context.getSystemService(StorageManager.class);
mStorageManager.registerListener(mStorageEventListener);

此方法只能在正在运行的进程中使用。

通过存储访问框架访问

如需访问可合并的驱动器上的非媒体文件或不可合并的驱动器上的任何文件,应用必须使用存储访问框架 (SAF)。触发 ACTIONS_OPEN_DOCUMENT intent 后,系统会向用户显示一个系统界面,供其从所有卷中选择一个或多个文件。针对此 intent 发起调用的应用必须指定 MIME 类型,以对用户可以选择的文件进行过滤,仅显示应用希望对其执行操作的文件。

注意:如果用户没有收到提示,也没有明确选择文件,则应用无法使用 SAF 访问这些文件。Android 会提供一个通用文件界面访问 APK,您可以对该 APK 进行扩展以设置其样式,也可以将其替换为原始设备制造商 (OEM) 提供的 APK。