MTP,全称是 Media Transfer Protocol(媒体传输协议),它是微软的一个为计算机和便携式设备之间传输图像、音乐等所定制的协议。MTP 的应用分两种角色,一个是作为 Initiator ,另一个作为 Responder 。基于Android的存储访问框架SAF(Storage Access Framework),提供应用存储的访问接口。
下面介绍Android设备如平板作为 Initiator 端的方式。
权限
需要声明MANAGE_DOCUMENTS 权限,此为系统签名保护的权限。
<uses-permission android:name="android.permission.MANAGE_DOCUMENTS" />
监听Mtp设备插入
通过监听Mtp设备的ContentProvider的回调,根据 获取的Mtp设备数量来判断是否有设备插入或拔出。
public static final String AUTHORITY = "com.android.mtp.documents";
private final Uri mMtpUri = DocumentsContract.buildRootsUri(AUTHORITY);
mContext.getContentResolver().registerContentObserver(mMtpUri, false, mMtpDeviceUriObserver);
private final ContentObserver mMtpDeviceUriObserver = new ContentObserver(new Handler(mContext.getMainLooper())) {
@Override
public void onChange(boolean b, Uri uri) {
if (uri != null && uri.equals(mMtpUri) && mOnMtpDeviceChangeListener != null)
mOnMtpDeviceChangeListener.OnMtpDeviceChange(getMtpDeviceInfoList());
}
};
public interface OnMtpDeviceChangeListener {
void OnMtpDeviceChange(List<MtpDeviceInfo> newMtpDevices);
}
获取Mtp设备列表
获取Mtp设备列表,DocumentsContract.Root 代表根目录:
public List<MtpDeviceInfo> getMtpDeviceInfoList() {
List<MtpDeviceInfo> list = new ArrayList<>();
ContentProviderClient providerClient = mContext.getContentResolver().acquireUnstableContentProviderClient(mMtpUri);
if (providerClient != null) {
Cursor cursor = null;
try {
cursor = providerClient.query(mMtpUri, null, null, null, null);
if (cursor != null) {
while (cursor.moveToNext()) {
MtpDeviceInfo deviceInfo = new MtpDeviceInfo();
int flags = CursorUtils.getCursorInt(cursor, DocumentsContract.Root.COLUMN_FLAGS);
int icon = CursorUtils.getCursorInt(cursor, DocumentsContract.Root.COLUMN_ICON);
String title = CursorUtils.getCursorString(cursor, DocumentsContract.Root.COLUMN_TITLE);
String summary = CursorUtils.getCursorString(cursor, DocumentsContract.Root.COLUMN_SUMMARY);
String documentId = CursorUtils.getCursorString(cursor, DocumentsContract.Root.COLUMN_DOCUMENT_ID);
long availableBytes = CursorUtils.getCursorLong(cursor, DocumentsContract.Root.COLUMN_AVAILABLE_BYTES);
long capacityBytes = CursorUtils.getCursorLong(cursor, DocumentsContract.Root.COLUMN_CAPACITY_BYTES);
deviceInfo.setTitle(title);
deviceInfo.setSummary(summary);
deviceInfo.setDocumentId(documentId);
deviceInfo.setAvailableBytes(availableBytes);
deviceInfo.setCapacityBytes(capacityBytes);
deviceInfo.setIcon(icon);
// deviceInfo.setUri(DocumentsContract.buildChildDocumentsUri(AUTHORITY, documentId));
// 判断是否有数据(非仅充电模式),由于无法直接当前mtp的模式,只能通过获取的大小和标志进行判断
if (availableBytes != -1) {
list.add(deviceInfo);
}
}
}
} catch (RemoteException e) {
e.printStackTrace();
} finally {
if (cursor != null) {
cursor.close();
}
providerClient.close();
}
}
return list;
}
MtpDeviceInfo 为自定义的Mtp设备信息类,保存Mtp设备名称、概要、DocumentId(用以访问根目录的顶层目录,可构建DocumentsUri,遍历得到子目录)、可用大小、总容量(可能获取不到)、图标,可以按具体需要定义。具体代码如下:
public class MtpDeviceInfo {
private String title;
private String summary;
private String documentId;
private long availableBytes;
private long capacityBytes;
private int icon;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getSummary() {
return summary;
}
public void setSummary(String summary) {
this.summary = summary;
}
public String getDocumentId() {
return documentId;
}
public void setDocumentId(String documentId) {
this.documentId = documentId;
}
public long getAvailableBytes() {
return availableBytes;
}
public void setAvailableBytes(long availableBytes) {
this.availableBytes = availableBytes;
}
public long getCapacityBytes() {
return capacityBytes;
}
public void setCapacityBytes(long capacityBytes) {
this.capacityBytes = capacityBytes;
}
public int getIcon() {
return icon;
}
public void setIcon(int icon) {
this.icon = icon;
}
}
下面为Android系统的文档/文件浏览器 DocumentsUI 界面,显示通过数据线连接的手机储存:
访问Mtp设备目录
浏览Mtp手机存储设备,需要通过 Root根目录的 documentId 即上述获取的根目录的 **documentId **
public static final String MTP_AUTHORITY = "com.android.mtp.documents";
Uri childDocumentsUri = DocumentsContract.buildChildDocumentsUri(MTP_AUTHORITY, rootDocumentId);
之后根据成员变量 childDocumentsUri 得到对应目录的 Cursor :
ContentProviderClient client = context.getContentResolver().acquireUnstableContentProviderClient(childDocumentsUri);
Cursor cursor = null;
if (client != null) {
try {
cursor = client.query(dirChildUri, null, null, null, null);
if (cursor != null) {
cursor = new SortingCursorWrapper(cursor, ComparatorUtils.getIns().getSortWay());
updateDocumentData(cursor);
}
} catch (RemoteException e) {
e.printStackTrace();
} finally {
client.close();
}
}
根据 Cursor 遍历得到各个文件的文件名称、mimeType文件类型、documentId、上次修改事件、 文件大小等:
String fileName = getCursorString(cursor, DocumentsContract.Document.COLUMN_DISPLAY_NAME);
String mimeType = getCursorString(cursor, DocumentsContract.Document.COLUMN_MIME_TYPE);
String documentId = getCursorString(cursor, DocumentsContract.Document.COLUMN_DOCUMENT_ID);
long lastModified = getCursorLong(cursor, DocumentsContract.Document.COLUMN_LAST_MODIFIED);
long size = getCursorLong(cursor, DocumentsContract.Document.COLUMN_SIZE);
public static long getCursorLong(Cursor cursor, String columnName) {
final int index = cursor.getColumnIndex(columnName);
if (index == -1) return -1;
final String value = cursor.getString(index);
if (value == null) return -1;
try {
return Long.parseLong(value);
} catch (NumberFormatException e) {
return -1;
}
}
public static String getCursorString(Cursor cursor, String columnName) {
final int index = cursor.getColumnIndex(columnName);
return (index != -1) ? cursor.getString(index) : null;
}
其中如果 mimeType文件类型 为DocumentsContract.Document.MIME_TYPE_DIR 则代表为文件夹,代表可以继续遍历浏览。接下来构建文件夹的 DocumentsUri 进行遍历:
Uri contentUri = DocumentsContract.buildChildDocumentsUri(MtpDeviceManager.AUTHORITY, documentId);
得到子目录文件夹的 DocumentsUri 可以继续按上面获取 Cursor 的步骤遍历文件夹。
可以访问手机存储的文件目录:
与上述遍历文件目录DocumentsContract.buildChildDocumentsUri()不同,要想创建文件、删除文件、修改文件需要通过DocumentsContract.buildDocumentUri()方式构建文件Uri才能进行文件操作。
public static final String MTP_AUTHORITY = "com.android.mtp.documents";
Uri mtpFileUri = DocumentsContract.buildDocumentUri(MTP_AUTHORITY, documentId);
注意:buildChildDocumentsUri用于构建文件夹的Uri来遍历文件夹目录,buildDocumentUri用于构建文件的Uri来进行文件操作。
mimeType 介绍
mimeType文件类型 为DocumentsContract.Document.MIME_TYPE_DIR代表文件夹,但文件种类很多,以"audio/"开头为音频类型、以"image/"开头为图片类型、以"video/"开头为视频类型,还有如下很多类型:
public static String getFileType(String mimeType) {
if (mimeType.startsWith("audio/")) {
return TYPE_AUDIO;
} else if (mimeType.startsWith("image/")) {
return TYPE_IMAGE;
} else if (mimeType.startsWith("video/")) {
return TYPE_VIDEO;
} else if (mFileTypeMap.containsKey(mimeType)) {
return mFileTypeMap.get(mimeType);
}
return TYPE_UNKNOW;
}
static {
// Compress file types 压缩文件类型
mFileTypeMap.put("application/rar", TYPE_COMPRESS);
mFileTypeMap.put("application/zip", TYPE_COMPRESS);
mFileTypeMap.put("application/x-tar", TYPE_COMPRESS);
mFileTypeMap.put("application/gzip", TYPE_COMPRESS);
mFileTypeMap.put("application/x-7z-compressed", TYPE_COMPRESS);
mFileTypeMap.put("application/x-rar-compressed", TYPE_COMPRESS);
// Common file types 文本类型
mFileTypeMap.put("text/plain", TYPE_DOCUMENT);
mFileTypeMap.put("text/html", TYPE_DOCUMENT);
mFileTypeMap.put("application/xhtml+xml", TYPE_DOCUMENT);
mFileTypeMap.put("application/pdf", TYPE_DOCUMENT);
//Microsoft typ, TYPE_DOCUMENT); office文档类型
mFileTypeMap.put("application/msword", TYPE_DOCUMENT);
mFileTypeMap.put("application/vnd.openxmlformats-officedocument.wordprocessingml.document", TYPE_DOCUMENT);
mFileTypeMap.put("application/vnd.ms-powerpoint", TYPE_DOCUMENT);
mFileTypeMap.put("application/vnd.openxmlformats-officedocument.presentationml.presentation", TYPE_DOCUMENT);
mFileTypeMap.put("application/vnd.ms-excel", TYPE_DOCUMENT);
mFileTypeMap.put("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", TYPE_DOCUMENT);
// Google doc typ, TYPE_DOCUMENT); Google文档类型
mFileTypeMap.put("application/vnd.google-apps.document", TYPE_DOCUMENT);
mFileTypeMap.put("application/vnd.google-apps.spreadsheet", TYPE_DOCUMENT);
mFileTypeMap.put("application/vnd.google-apps.presentation", TYPE_DOCUMENT);
mFileTypeMap.put("application/vnd.google-apps.drawing", TYPE_DOCUMENT);
mFileTypeMap.put("application/vnd.google-apps.fusiontable", TYPE_DOCUMENT);
mFileTypeMap.put("application/vnd.google-apps.form", TYPE_DOCUMENT);
mFileTypeMap.put("application/vnd.google-apps.map", TYPE_DOCUMENT);
mFileTypeMap.put("application/vnd.google-apps.sites", TYPE_DOCUMENT);
// 文件夹类型
mFileTypeMap.put("vnd.android.document/directory", TYPE_DIR);
// Apk类型
mFileTypeMap.put("application/vnd.android.package-archive", TYPE_APK);
// Special media mime types 特殊媒体类型
mFileTypeMap.put("application/ogg", TYPE_AUDIO);
mFileTypeMap.put("application/x-flac", TYPE_AUDIO);
}
可以以下面的方式请求打开不同 mimeType 的文件的打开方式:
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setDataAndType(uri, mimeType);
Intent chooserIntent = Intent.createChooser(intent, null);
intent.putExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, false);
startActivity(chooserIntent);
其中uri以 DocumentsContract.buildDocumentUri(authority, documentId) 的形式构建。
Mtp文件的操作(创建、删除、拷贝)
创建文件
private void createDirectory(String name) {
ContentResolver resolver = mContext.getContentResolver();
ContentProviderClient client = resolver.acquireUnstableContentProviderClient(MTP_AUTHORITY);
if (client != null) {
try {
Uri childUri = DocumentsContract.createDocument(
resolver, mContentUri, DocumentsContract.Document.MIME_TYPE_DIR, name);
if (mDirectoryListener != null) {
mDirectoryListener.onCreate(childUri);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
client.close();
}
}
}
删除文件
public static void deleteDocument(DocumentInfo doc, DocumentInfo parent) {
try {
Context context = AppUtils.getApplicationContext();
if (parent != null && doc.isRemoveSupported()) {
DocumentsContract.removeDocument(context.getContentResolver(), doc.getUri(), parent.getUri());
} else if (doc.isDeleteSupported()) {
DocumentsContract.deleteDocument(context.getContentResolver(), doc.getUri());
}
} catch (FileNotFoundException | RuntimeException e) {
e.printStackTrace();
}
}
复制文件
在同一 Mtp 文件或者 File 文件 的 Provider 进行复制时,尝试使用下面的方式优化复制。目前 Mtp 文件目录移动拷贝到 File 文件目录或是 File 文件移动拷贝到Mtp 文件目录 需要进行逐字节复制移动,具体参考下一小节:移动本地 File 文件和 Mtp 文件之间的拷贝移动。
if ((src.flags & Document.FLAG_SUPPORTS_COPY) != 0) {
try {
if (DocumentsContract.copyDocument(getClient(src), src.derivedUri,
dstDirInfo.derivedUri) != null) {
Metrics.logFileOperated(
appContext, operationType, Metrics.OPMODE_PROVIDER);
return;
}
} catch (RemoteException | RuntimeException e) {
e.printStackTrace();
}
}
// 如果不能做一个优化复制,则进行字节副本的复制。
byteCopyDocument(src, dstDirInfo);
本地 File 文件和 Mtp 文件之间的拷贝移动
需要将本地的 File 文件路径 转换为能创建新文件的 DocumentUri ,获取的方式如下:
private static Uri getDocumentUri(String path) {
final ContentProviderClient storageClient = AppUtils.getApplicationContext().getContentResolver()
.acquireContentProviderClient(DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY);
Bundle bundle = null;
try {
bundle = storageClient.call("getDocIdForFileCreateNewDir", path, null);
} catch (RemoteException e) {
e.printStackTrace();
} finally {
storageClient.close();
}
final String docId = bundle == null ? null : bundle.getString("DOC_ID");
return DocumentsContract.buildDocumentUri(DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY, docId);
}
文件的复制粘贴需要通过 DocumentInfo 的方式进行,下面方式得到 目标目录的 DocumentInfo :
Uri destPathUri = getDocumentUri(destPath);
DocumentInfo destFileInfo = null;
try {
destFileInfo = DocumentInfo.fromUri(AppUtils.getApplicationContext().getContentResolver(), destPathUri);
} catch (FileNotFoundException e) {
e.printStackTrace();
}
首先需要创建目标文件,根据其 Uri 获取创建的新文件的 DocumentInfo:
Uri dstUri = null;
try {
dstUri = DocumentsContract.createDocument(mResolver, destFileInfo.getUri(), destFileMimeType, destFileName);
} catch (FileNotFoundException | RuntimeException e) {
return false;
}
DocumentInfo dstInfo = null;
try {
dstInfo = DocumentInfo.fromUri(mResolver, dstUri);
} catch (FileNotFoundException | RuntimeException e) {
return false;
}
拷贝文件还需要考虑文件 mimeType 类型是否为文件夹,如果为文件夹则还需要遍历子目录进行拷贝,如果为单个文件类型则进行单独的文件拷贝。
单独拷贝文件方式如下:
private void copyFile(DocumentInfo src, DocumentInfo dest, String mimeType) throws ResourceException {
AssetFileDescriptor srcFileAsAsset = null;
ParcelFileDescriptor srcFile = null;
ParcelFileDescriptor dstFile = null;
InputStream in = null;
ParcelFileDescriptor.AutoCloseOutputStream out = null;
boolean success = false;
try {
if (src.isVirtual()) {
try {
srcFileAsAsset = mResolver.acquireContentProviderClient(src.getUri().getAuthority()).openTypedAssetFileDescriptor(
src.getUri(), mimeType, null, mSignal);
} catch (FileNotFoundException | RemoteException | RuntimeException e) {
throw new ResourceException("Failed to open a file as asset for %s due to an "
+ "exception.", src.getUri(), e);
}
if (srcFileAsAsset != null) {
srcFile = srcFileAsAsset.getParcelFileDescriptor();
}
try {
in = new AssetFileDescriptor.AutoCloseInputStream(srcFileAsAsset);
} catch (IOException e) {
throw new ResourceException("Failed to open a file input stream for %s due "
+ "an exception.", src.getUri(), e);
}
} else {
try {
srcFile = mResolver.acquireContentProviderClient(src.getUri().getAuthority()).openFile(src.getUri(), "r", mSignal);
} catch (FileNotFoundException | RemoteException | RuntimeException e) {
throw new ResourceException(
"Failed to open a file for %s due to an exception.", src.getUri(), e);
}
in = new ParcelFileDescriptor.AutoCloseInputStream(srcFile);
}
try {
dstFile = mResolver.acquireContentProviderClient(dest.getUri().getAuthority()).openFile(dest.getUri(), "w", mSignal);
} catch (FileNotFoundException | RemoteException | RuntimeException e) {
throw new ResourceException("Failed to open the destination file %s for writing "
+ "due to an exception.", dest.getUri(), e);
}
out = new ParcelFileDescriptor.AutoCloseOutputStream(dstFile);
try {
// If we know the source size, and the destination supports disk
// space allocation, then allocate the space we'll need. This
// uses fallocate() under the hood to optimize on-disk layout
// and prevent us from running out of space during large copies.
final StorageManager sm = AppUtils.getApplicationContext().getSystemService(StorageManager.class);
final long srcSize = srcFile.getStatSize();
final FileDescriptor dstFd = dstFile.getFileDescriptor();
if (srcSize > 0 && sm.isAllocationSupported(dstFd)) {
sm.allocateBytes(dstFd, srcSize);
}
try {
final Int64Ref last = new Int64Ref(0);
FileUtils.copy(in, out, new FileUtils.ProgressListener() {
@Override
public void onProgress(long progress) {
if (isCancelled()) {
mSignal.cancel();
}
final long delta = progress - last.value;
last.value = progress;
publishProgress(null, String.valueOf(delta));
}
}, mSignal);
} catch (OperationCanceledException e) {
return;
}
// Need to invoke Os#fsync to ensure the file is written to the storage device.
try {
Os.fsync(dstFile.getFileDescriptor());
} catch (ErrnoException error) {
// fsync will fail with fd of pipes and return EROFS or EINVAL.
if (error.errno != OsConstants.EROFS && error.errno != OsConstants.EINVAL) {
throw new SyncFailedException(
"Failed to sync bytes after copying a file.");
}
}
// Need to invoke IoUtils.close explicitly to avoid from ignoring errors at flush.
dstFile.close();
srcFile.checkError();
} catch (IOException e) {
throw new ResourceException(
"Failed to copy bytes from %s to %s due to an IO exception.",
src.getUri(), dest.getUri(), e);
}
success = true;
} finally {
if (!success) {
if (dstFile != null) {
try {
dstFile.closeWithError("Error copying bytes.");
} catch (IOException closeError) {
closeError.printStackTrace();
}
}
mSignal.cancel();
deleteDocument(dest.getUri(), null);
}
try {
if (in != null) {
in.close();
}
if (out != null) {
out.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
如果进行移动操作,则需要增加删除操作。具体粘贴的细节可参考 DocumentsUI 源码中 CopyJob.java 部分。
相关系统源码目录
Identifier端
frameworks/base/core/java/android/provider/DocumentsContract.java
framework/base/packages/MtpDucumentsProvider.java
framework/base/media/java/android/mtp/
frameworks/base/core/java/com/android/internal/content/FileSystemProvider.java
Responser端
packages/providers/MediaProvider/
/frameworks/base/packages/ExternalStorageProvider/ 外部存储的provider
frameworks/base/services/usb/java/com/android/server/usb/MtpNotificationManager.java 负责android的通知显示 frameworks/base/services/usb/java/com/android/server/usb/UsbProfileGroupSettingsManager.java
frameworks/base/packages/SystemUI/src/com/android/systemui/usb/UsbResolverActivity.java 显示USB弹框
总结
以上是 Initiator 端实现Mtp访问浏览手机存储的方式,一般PC、平板或者手机都可作为Initiator 端访问另一台Android设备。
Android设备作为 Responder 端的相关介绍参考博客:Android之 MTP框架和流程分析。
想要实现插入USB时响应系统启动文件管理器,参考下篇博客: Android实现Mtp访问浏览手机存储(二) 禁止DocumentsUI文件直接弹出。