煎饼

煎饼为你分享Android有关的技术文章
不断分享,点滴积累,共同提高

关注微信公众号[developers]
更快的了解新的技术动态

Android Lollipop 操作外置 SD 卡

大家都知道Google在Android KitKat上对外置sd卡的权限做了一定修改,以致不能直接对外置sd卡进行操作,这使得很多应用丧失了对外置sd卡文件的管理,尤其对文件管理类应用造成很大影响,不仅影响了开发者,而且给用户也带来诸多不便,当时可谓是骂声一片。在众多用户和开发者的呼声中,Google终于做了妥协在Android Lollipop中提供了另外的API来对外置sd卡操作,虽然这对第三方应用来说仍有点瑕疵,但它终究还是听取了用户的声音并做了妥协。下面就开始介绍如果在Android Lollipop上操作外置sd卡。

关于存储权限问题可以看之前的博文:Android 外部存储权限分析.

在开始介绍Android Lollipop上解决外置sd卡操作问题之前,我想还是先给出之前博文提到的解决Kitkat上问题的关键代码供大家思考、参考(这个方法经过测试并不能适配很多机型,最终并没有用到项目中)。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
public class MediaFile {

    private static final String NO_MEDIA = ".nomedia";
    private static final String ALBUM_ART_URI = "content://media/external/audio/albumart";
    private static final String[] ALBUM_PROJECTION = { BaseColumns._ID, MediaStore.Audio.AlbumColumns.ALBUM_ID, "media_type" };

    private static File getExternalFilesDir(Context context) {
        if (AndroidEnvironment.SDK < AndroidEnvironment.FROYO) {
            return null;
        }

        try {
            Method method = Context.class.getMethod("getExternalFilesDir", String.class);
            return (File) method.invoke(context, (String) null);
        } catch (SecurityException ex) {
            Log.d(Maui.LOG_TAG, "Unexpected reflection error.", ex);
            return null;
        } catch (NoSuchMethodException ex) {
            Log.d(Maui.LOG_TAG, "Unexpected reflection error.", ex);
            return null;
        } catch (IllegalArgumentException ex) {
            Log.d(Maui.LOG_TAG, "Unexpected reflection error.", ex);
            return null;
        } catch (IllegalAccessException ex) {
            Log.d(Maui.LOG_TAG, "Unexpected reflection error.", ex);
            return null;
        } catch (InvocationTargetException ex) {
            Log.d(Maui.LOG_TAG, "Unexpected reflection error.", ex);
            return null;
        }
    }

    public static boolean SUPPORTED = ContentUriUtil.FILES_URI != null;

    private final File file;
    private final Context context;
    private final ContentResolver contentResolver;

    public MediaFile(Context context, File file) {
        this.file = file;
        this.context = context;
        contentResolver = context.getContentResolver();
    }

    /**
     * Deletes the file. Returns true if the file has been successfully deleted or otherwise does not exist. This operation is not
     * recursive.
     */
    public boolean delete() throws IOException {
        if (!SUPPORTED) {
            throw new IOException("MediaFile API not supported by device.");
        }

        if (!file.exists()) {
            return true;
        }

        boolean directory = file.isDirectory();
        if (directory) {
            // Verify directory does not contain any files/directories within it.
            String[] files = file.list();
            if (files != null && files.length > 0) {
                return false;
            }
        }

        String where = MediaStore.MediaColumns.DATA + "=?";
        String[] selectionArgs = new String[] { file.getAbsolutePath() };

        // Delete the entry from the media database. This will actually delete media files (images, audio, and video).
        contentResolver.delete(ContentUriUtil.FILES_URI, where, selectionArgs);

        if (file.exists()) {
            // If the file is not a media file, create a new entry suggesting that this location is an image, even
            // though it is not.
            ContentValues values = new ContentValues();
            values.put(MediaStore.MediaColumns.DATA, file.getAbsolutePath());
            contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);

            // Delete the created entry, such that content provider will delete the file.
            contentResolver.delete(ContentUriUtil.FILES_URI, where, selectionArgs);
        }

        return !file.exists();
    }

    public File getFile() {
        return file;
    }

    private int getTemporaryAlbumId() {
        final File temporaryTrack;
        try {
            temporaryTrack = installTemporaryTrack();
        } catch (IOException ex) {
            return 0;
        }
        final String[] selectionArgs = { temporaryTrack.getAbsolutePath() };
        Cursor cursor = contentResolver.query(ContentUriUtil.FILES_URI, ALBUM_PROJECTION, MediaStore.MediaColumns.DATA + "=?", 
                selectionArgs, null);
        if (cursor == null || !cursor.moveToFirst()) {
            if (cursor != null) {
                cursor.close();
                cursor = null;
            }
            ContentValues values = new ContentValues();
            values.put(MediaStore.MediaColumns.DATA, temporaryTrack.getAbsolutePath());
            values.put(MediaStore.MediaColumns.TITLE, "{MediaWrite Workaround}");
            values.put(MediaStore.MediaColumns.SIZE, temporaryTrack.length());
            values.put(MediaStore.MediaColumns.MIME_TYPE, "audio/mpeg");
            values.put(MediaStore.Audio.AudioColumns.IS_MUSIC, true);
            contentResolver.insert(ContentUriUtil.FILES_URI, values);
        }
        cursor = contentResolver.query(ContentUriUtil.FILES_URI, ALBUM_PROJECTION, MediaStore.MediaColumns.DATA + "=?", 
                selectionArgs, null);
        if (cursor == null) {
            return 0;
        }
        if (!cursor.moveToFirst()) {
            cursor.close();
            return 0;
        }
        int id = cursor.getInt(0);
        int albumId = cursor.getInt(1);
        int mediaType = cursor.getInt(2);
        cursor.close();

        ContentValues values = new ContentValues();
        boolean updateRequired = false;
        if (albumId == 0) {
            values.put(MediaStore.Audio.AlbumColumns.ALBUM_ID, 13371337);
            updateRequired = true;
        }
        if (mediaType != 2) {
            values.put("media_type", 2);
            updateRequired = true;
        }
        if (updateRequired) {
            contentResolver.update(ContentUriUtil.FILES_URI, values, BaseColumns._ID + "=" + id, null);
        }
        cursor = contentResolver.query(ContentUriUtil.FILES_URI, ALBUM_PROJECTION, MediaStore.MediaColumns.DATA + "=?", 
                selectionArgs, null);
        if (cursor == null) {
            return 0;
        }
        try {
            if (!cursor.moveToFirst()) {
                return 0;
            }
            return cursor.getInt(1);
        } finally {
            cursor.close();
        }
    }    
    private File installTemporaryTrack() throws IOException {
        File externalFilesDir = getExternalFilesDir(context);
        if (externalFilesDir == null) {
            return null;
        }
        File temporaryTrack = new File(externalFilesDir, "temptrack.mp3");
        if (!temporaryTrack.exists()) {
            InputStream in = null;
            OutputStream out = null;
            try {
                in = context.getResources().openRawResource(R.raw.temptrack);
                out = new FileOutputStream(temporaryTrack);
                byte[] buffer = new byte[4096];
                int bytesRead;
                while ((bytesRead = in.read(buffer)) != -1) {
                    out.write(buffer, 0, bytesRead);
                }
            } finally {
                if (in != null) {
                    try {
                        in.close();
                    } catch (IOException ex) {
                        return null;
                    }
                }
                if (out != null) {
                    try {
                        out.close();
                    } catch (IOException ex) {
                        return null;
                    }
                }
            }
        }
        return temporaryTrack;
    }
    /**
     * Creates a new directory. Returns true if the directory was successfully created or exists.
     */
    public boolean mkdir() throws IOException {
        if (file.exists()) {
            return file.isDirectory();
        }
        File tmpFile = new File(file, ".MediaWriteTemp");
        int albumId = getTemporaryAlbumId();
        if (albumId == 0) {
            throw new IOException("Fail");
        }
        Uri albumUri = Uri.parse(ALBUM_ART_URI + '/' + albumId);
        ContentValues values = new ContentValues();
        values.put(MediaStore.MediaColumns.DATA, tmpFile.getAbsolutePath());
        if (contentResolver.update(albumUri, values, null, null) == 0) {
            values.put(MediaStore.Audio.AlbumColumns.ALBUM_ID, albumId);
            contentResolver.insert(Uri.parse(ALBUM_ART_URI), values);
        }
        try {
            ParcelFileDescriptor fd = contentResolver.openFileDescriptor(albumUri, "r");
            fd.close();
        } finally {
            MediaFile tmpMediaFile = new MediaFile(context, tmpFile);
            tmpMediaFile.delete();
        }
        return file.exists();
    }

    /**
     * Returns an OutputStream to write to the file. The file will be truncated immediately.
     */
    public OutputStream write(long size) throws IOException {
        if (!SUPPORTED) {
            throw new IOException("MediaFile API not supported by device.");
        }
        if (NO_MEDIA.equals(file.getName().trim())) {
            throw new IOException("Unable to create .nomedia file via media content provider API.");
        }
        if (file.exists() && file.isDirectory()) {
            throw new IOException("File exists and is a directory.");
        }
        // Delete any existing entry from the media database.
        // This may also delete the file (for media types), but that is irrelevant as it will be truncated momentarily in any case.
        String where = MediaStore.MediaColumns.DATA + "=?";
        String[] selectionArgs = new String[] { file.getAbsolutePath() };
        contentResolver.delete(ContentUriUtil.FILES_URI, where, selectionArgs);

        ContentValues values = new ContentValues();
        values.put(MediaStore.MediaColumns.DATA, file.getAbsolutePath());
        values.put(MediaStore.MediaColumns.SIZE, size);
        Uri uri = contentResolver.insert(ContentUriUtil.FILES_URI, values);
        if (uri == null) {
            // Should not occur.
            throw new IOException("Internal error.");
        }
        return contentResolver.openOutputStream(uri);
    }
}

在5.0上,Google在介绍这块时用的副标题是”Directory selection“看完之后会大概明白其意思,官方文档对这个新的部分的概括描述如下:

Android 5.0 extends the Storage Access Framework to let users select an entire directory subtree, giving apps read/write access to all contained documents without requiring user confirmation for each item.

意思很明确:Android 5.0继承了Storage Access Framework,可以让用户选择整个目录子树,赋予应用拥有读/写整个目录下所有文档的访问权限,而不是访问每个都要认证一次。也就是说我们在访问外置sd卡时,需要手动赋予应用这一权限,一旦获取这一权限并进行处理,之后再进行操作就不用再次获取相应权限。(权限:姑且先称之为权限(Permission))。

为了获取目录子树(Directory subtree),我们需要发送一个Intent事件:OPEN_DOCUMENT_TREE.

1
2
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
startActivityForResult(intent, REQUEST_CODE);

这个事件发送后,会调出系统DocumentUI应用界面:

我们在这里要选择sd卡的根目录,在底部点击“选择‘SD’卡”,然后就回到自己应用调用onActivityResult方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (requestCode == REQUEST_CODE && resultCode == RESULT_OK) {
        Uri uri = data.getData();
         //保存uri避免每次都调用DocumentUI          
        PreferenceManager.getDefaultSharedPreferences(this).edit().putString(PREF_DEFAULT_URI, uri.toString()).commit();
        // 使应用获得对uri的永久权限,避免重启后上面保存的uri失效
        final int takeFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
        getContentResolver().takePersistableUriPermission(uri, takeFlags)
        //获取跟目录文件,也可以使用下面注释掉的DocumentsContractl来处理
        DocumentFile documentFile = DocumentFile.fromTreeUri(this, uri);
        //Uri rootUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, DocumentsContract.getTreeDocumentId(uri));
        updateViews1(documentFile);
        //updateviews(rootUri);
        }
    }

为了获取目录文件,使用DocumentFile、DocumentsContract相关Api都可以做到,也可以自己重写DocumentProvider(这里暂时不作讲解),下面对比一下两种方法来自己看一下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
private void updateViews1(DocumentFile documentFile) {
    if (documentFile.isDirectory()) {
        list.clear();
        curFile = documentFile;
        DocumentFile[] documentFiles = documentFile.listFiles();
        for (DocumentFile file : documentFiles) {
            FileItem item = new FileItem();
            item.file = file;
            item.fileName = file.getName();
            item.lastModified = file.lastModified();
            item.type = file.getType();
            item.parentFile = file.getParentFile();
            item.uri = file.getUri();
            item.size = file.length();
            list.add(item);
        }
        adapter.setList(list);                         
        adapter.notifyDataSetChanged();
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
private void updateViews(Uri uri) {
    try {
        Cursor childCursor = getContentResolver().query(uri, null, null, null, null);
        list.clear();
        while (childCursor.moveToNext()) {
            FileItem item = new FileItem();
            item.fileName = childCursor.getString(childCursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME));
            item.lastModified = childCursor.getLong(childCursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED));
            item.type = childCursor.getString(childCursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE));
            item.size = childCursor.getLong(childCursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE));
            item.uri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, childCursor.getString(0));
            Log.d("Uri", "file Uri: " + item.uri + "");
            list.add(item);
            }
        childCursor.close();
        adapter.setList(list);
        adapter.notifyDataSetChanged();
    } catch (Exception e) {
        e.printStackTrace();
    } 
}

这样对比看起来使用DocumentFile会觉得方便一些,当然我也是这样认为的,因为它更接近我们之前处理文件的方式。但是再看官方文档的介绍:DocuentFile 是DocumentProvider或磁盘上原始文件(raw file)支持的一种文件。它是一个效仿File接口设计的工具类,提供了一个简化的文档树🌲视图,但它有相当大的开销。为了获得更佳的性能和丰富的功能,建议直接使用DocumentsContract的方法和常量。 至此你就会明白为什么google在介绍5.0新支持的Directory selection时没有提到DocumentFile了吧?

关于文件的操作,DocumentFile 和 DocumentsContract 都提供了create 、delete、rename操作,复制、剪切也可以通过ContentResolver的openInputStream 和 openOutputStream 方法来实现。至此就可以操作Android Lollipop 上的外置sd卡了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static boolean copyFile(Context context, FileItem srcFileItem, FileItem destFileItem) {
    if (srcFileItem.file.isFile()) {
        OutputStream out = null;
        InputStream in = null;
        ContentResolver resolver = context.getContentResolver();
        try {
            DocumentFile destfile = destFileItem.file.createFile(srcFileItem.file.getType(), srcFileItem.file.getName());
            in = resolver.openInputStream(srcFileItem.uri);
            out = resolver.openOutputStream(destfile.getUri());
            byte[] buf = new byte[64];
            int len;
            while ((len = in.read(buf)) > 0) {
                out.write(buf, 0, len);
            }
            in.close();
            out.close();
        } catch (IOException e) {                e.printStackTrace();
        }
        return true;
    } else {
        try {
            throw new Exception("item is not a file");
        } catch (Exception e) {                e.printStackTrace();
            return false;
        }
    }
}
2318

分享本文:

Gradle Tips 1