Android实现Mtp访问浏览手机存储(一)访问Mtp目录

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文件直接弹出

猜你喜欢

转载自blog.csdn.net/CJohn1994/article/details/127157859