Android R 9月上线,你如果还不知道Scoped Storage,后果很严重!!!

2,721 阅读15分钟

背景

为了让用户更好地控制自己的文件,并限制文件混乱情况,Android Q 更改了App访问设备存储空间的方式。

Android R Q规定了App有两种存储空间模式视图:Legacy View、Filtered View。

  • Legacy View(兼容模式)

兼容模式跟Android Q以前,App访问Sdcard一样,拥有完整的访问权限。

  • Filtered View(沙箱模式 分区存储 R必须的)

App只能直接访问App-specific目录文件,没有权限访问App-specific外的文件。访问其他目录,只能通过MediaStore、SAF、或者其他App提供ContentProvider访问。

Scoped Storage将存储空间分为两部分:

  • 公共目录:Downloads、Documents、Pictures 、DCIM、Movies、Music、Ringtones

    公共目录的文件在App卸载后,不会删除

    可以通过SAF、MediaStore接口访问

  • App-specific目录

    对于Filtered View App,App-specific目录只能自己直接访问

    App卸载,数据会清除。

兼容影响

对于App访问存储方式、App数据存放以及App间数据共享,都产生很大影响。android R系统将在9月份发布正式版,不再有兼容模式支持。如果没有做兼容支持的app可能将无法正常工作。

内部存储

外部存储私有目录

路径位于:/storage/emulated/0/Android/data/<package>中,同样拥有files文件夹和cache文件夹

getExternalFilesDir()可以获取到/storage/emulated/0/Android/data/<package>/files目录
getExternalCacheDir()获取/storage/emulated/0/Android/data/<package>/cache目录

外部存储的公有目录

除了私有目录以外的目录,都是公有目录,这次的兼容影响主要是对外部存储公有目录的影响

运行视图

系统通过下列确定App运行模式

  • App TargetSDK >= Q,默认Filtered View

  • App TargetSDK < Q,声明了READ_EXTERNAL_STORAGE或者WRITE_EXTERNAL_STORAGE权限,默认Legacy View

  • 应用可以通过AndroidManifest.xml,设置requestLegacyExternalStorage,选择对应的方式:

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:requestLegacyExternalStorage="true"
        android:theme="@style/AppTheme">

声明了READ_EXTERNAL_STORAGE、WRITE_EXTERNAL_STORAGE权限(没有声明则忽略):

​ true表示Legacy View

​ false表示Filtered View

  • 系统应用可以申请android.permission.WRITE_MEDIA_STORAGE系统权限,同样拥有完整存储空间权限,可以访问所有文件

    但是这个在CTS测试中,会进行测试,只有没有用户交互、可见的App,才能申请。

    具体参考《Android Bootcamp 2019 - Privacy Overview.pdf》。

  • App在下列条件都成立时

    1.声明INSTALL_PACKAGES、或者动态申请INSTALL_PACKAGES权限

    2.拥有WRITE_EXTERNAL_STORAGE权限

    App拥有外置存储空间Read、Write权限。但是通过Environment.isExternalStorageLegacy接口判断,返回不一定是Legacy View。

判断当前App运行模式

Environment.isExternalStorageLegacy();

读写公共目录

App启动Filtered View后,只能直接访问自身App-specific目录,所以Android Q,提供了两种访问公共目录的方法:

1.通过MediaStore定义的Uri

MediaStore提供了下列几种类型的访问Uri,通过查找对应Uri数据,达到访问的目的。

下列每种类型又分为三种Uri,Internal、External、可移动存储:

  • Audio

Internal: MediaStore.Audio.Media.INTERNAL_CONTENT_URI

content://media/internal/audio/media。

External: MediaStore.Audio.Media.EXTERNAL_CONTENT_URI

content://media/external/audio/media。

可移动存储: MediaStore.Audio.Media.getContentUri

content://media//audio/media。

  • Video

Internal: MediaStore.Video.Media.INTERNAL_CONTENT_URI

content://media/internal/video/media。

External: MediaStore.Video.Media.EXTERNAL_CONTENT_URI

content://media/external/video/media。

可移动存储: MediaStore.Video.Media.getContentUri

content://media//video/media。

  • Image

    Internal: MediaStore.Images.Media.INTERNAL_CONTENT_URI

    content://media/internal/images/media。

    External: MediaStore.Images.Media.EXTERNAL_CONTENT_URI

    content://media/external/images/media。

    可移动存储: MediaStore.Images.Media.getContentUri

    content://media//images/media。

  • File

    MediaStore. Files.Media.getContentUri

    content://media//file。

  • Downloads

    Internal: MediaStore.Downloads.INTERNAL_CONTENT_URI

    content://media/internal/downloads。

    External: MediaStore.Downloads.EXTERNAL_CONTENT_URI

    content://media/external/downloads。

    可移动存储: MediaStore.Downloads.getContentUri

    content://media//downloads

获取所有的Volume

对于前面描述的Uri中,getContentUri如何获取所有,可以通过下述方式:

 for(String volume : MediaStore.getExternalVolumeNames(this)){
      MediaStore.Audio.Media.getContentUri(volume);
      MediaStore.Video.Media.getContentUri(volume);
      图片,下载类似
}
Uri跟公共目录关系

MediaProvider对于App存放到公共目录文件,通过ContentResolver insert方法中Uri来确定,其中下表中<Uri路径>为相对路径,完整为:

content://media//<Uri路径>。

Mine Type Uri路径 一级目录
audio/* images/media,
images/media/#
Environment.DIRECTORY_ALARMS
Environment.DIRECTORY_MUSIC
Environment.DIRECTORY_NOTIFICATIONS
Environment.DIRECTORY_PODCASTS
Environment.DIRECTORY_RINGTONES
audio/* audio/playlists
audio/playlists/#
Environment.DIRECTORY_MUSIC
video/* video/media
video/media/#
Environment.DIRECTORY_DCIM
Environment.DIRECTORY_MOVIES
image/* images/media
images/media/#
Environment.DIRECTORY_DCIM
Environment.DIRECTORY_PICTURES
image/* video/thumbnails
video/thumbnails/#
Environment.DIRECTORY_MOVIES
image/* images/thumbnails
images/thumbnails/#
Environment.DIRECTORY_PICTURES
/ downloads
downloads/#
Environment.DIRECTORY_DOWNLOADS
/ filefile/# Environment.DIRECTORY_DOWNLOADS
Environment.DIRECTORY_DOCUMENTS
权限

MediaStore通过不同Uri,为用户提供了增、删(如果通过File Uri无法删除文件,需要通过SAF接口)、改。

App对应的权限如下:

Audio Image Video File Downloads
WRITE_EXTERNAL_STORAGE 能修改所有App新建的文件,前提是要要授权
READ_EXTERNAL_STORAGE 能读取所有App新建的文件,不能修改其他App新建文件
只能读取、修改自己新建的文件
新建文件

如果需要新建文件存放到公共目录,需要通过ContentResolver insert接口,使用不同的Uri,选择存储到不同的目录。

查询文件

通过ContentResolver,根据不同的Uri查询不同的内容:

private void getAllPdf() {//查询sd卡所有pdf文件
    ContentResolver cr = getContentResolver();
    Uri uri = MediaStore.Files.getContentUri("external");
    String[] projection = null;
    String sortOrder = null; // unordered
    // only pdf
    String selectionMimeType = MediaStore.Files.FileColumns.MIME_TYPE + "=?";
    String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension("pdf");
    String[] selectionArgsPdf = new String[]{mimeType};
    Cursor cursor = cr.query(uri, projection, selectionMimeType, selectionArgsPdf, sortOrder);
    while (cursor.moveToNext()) {
        int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);
        String filePath = cursor.getString(column_index);//所有pdf文件路径
        Log.d("MainActivity", "getAllPdf() filePath=" + filePath);
    }
}
读取文件

通过ContentResolver query接口,查找出来文件后如何读取,可以通过下面的方式:

  • 通过ContentResolver openFileDescriptor接口,选择对应的打开方式

    例如”r”表示读,”w”表示写,返回ParcelFileDescriptor类型FD。

  • 访问Thumbnail,通过ContentResolver loadThumbnail接口

    通过传递大小,MediaProvider返回指定大小的Thumbnail。

  • Native代码访问文件

    如果Native代码需要访问文件,可以参考下面方式:

    • 通过openFileDescriptor返回ParcelFileDescriptor
    • 通过ParcelFileDescriptor.detachFd()读取FD
    • 将FD传递给Native层代码
    • App需要负责通过close接口关闭FD
修改文件

通过ContentResolver query接口查找出来对应文件的Uri。

如果不是自己新建的文件,需要注意申请WRITE_EXTERNAL_STORAGE权限

通过下列接口,获取需要修改文件的FD或者OutputStream:

  • getContentResolver().openOutputStream(contentUri)

    获取对应文件的OutputStream。

  • getContentResolver().openFile或者getContentResolver().openFileDescriptor

    通过openFile或者openFileDescriptor打开文件,需要选择Mode为”w”,表示写权限。这些接口返回一个ParcelFileDescriptor。

    getContentResolver().openFileDescriptor(contentUri,"w");

    getContentResolver().openFile(contentUri,"w",null);

删除文件

通过ContentResolver接口删除文件,Uri为query出来的Uri:

getContentResolver().delete(contentUri,null,null);

2.通过SAF接口

SAF,即Storage Access Framework,通过选择不同的DocumentsProvider,提供给用户打开、浏览文件。

Android默认提供了下列DocumentsProvider:

MediaDocumentsProvider、ExternalStorageProvider、 DownloadStorageProvider。

他们之间差异是:

MediaDocumentsProvider ExternalStorageProvider DownloadStorageProvider
只能读取视频、音频、图片 全部内置、外置存储 读取Download目录
删除 可以删除
修改 无法修改 可以修改

下面简单介绍一下各个类的作用: DocumentsContacts:协议类,规范了客户端app和DocumentProvider之间的交互,其子类Root和Document就代表了我们之前介绍的文件结构中的根和文档。该类同时定义了文档的操作,例如删除,新建,重命名等。

DocumentFile : 辅助操作类,直接使用DocumentsContact类比较麻烦,也不符合大家的操作习惯。因此google推出了DocumentFile类来帮助大家进行文档操作,该类的api和File类较为接近。其三个子类,TreeDocumentFile代表了一个文档树,而SingleDocumentFile仅仅代表单个文档。RawDocumentFile比较特殊,它代表的是一个普通的文件,而非SAF框架的Document uri。

DocumentProvider : 文档提供者,它的各个子类真正提供了文档的内容,例如我们访问外置sd卡,就是其子类ExternalStorageProvider提供的内容。它是真正的数据处理者,我们通过DocumentsContacts发出的各个文件操作,都将由它来实际完成。

PickActivity,OpenExternalDirectoryActivity : DocumentUi提供的页面,可以显示文档树,以及文档操作授权页面。

打开文件
 Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
 //文档需要是可以打开的
 intent.addCategory(Intent.CATEGORY_OPENABLE);
 //指定文档的minitype为text类型
 intent.setType("text/*");
 //是否支持多选,默认不支持
 intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE,false);
 startActivityForResult(intent, OPEN_DOCUMENT_CODE);
 
处理打开文件:
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (resultCode == Activity.RESULT_OK) {
        switch (requestCode){
            case OPEN_DOCUMENT_CODE:
            	//根据request_code处理打开文档的结果
                handleOpenDocumentAction(data);
                break;
        }
    }
}

private void handleOpenDocumentAction(Intent data){
       if (data == null) {  return;  }
       //获取文档指向的uri,注意这里是指单个文件。
       Uri uri = data.getData();
       //根据该Uri可以获取该Document的信息,其数据列的名称和解释可以在DocumentsContact类的内部类Document中找到
 		//我们在此查询的信息仅仅只是演示作用
       Cursor cursor = getContentResolver().query(uri,null,
               null,null,null,null);
       if(cursor!=null && cursor.moveToFirst()){
           String documentId = cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID));
           String name = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
           int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE);
           String size = null;
           if (!cursor.isNull(sizeIndex)) {
               // Technically the column stores an int, but cursor.getString()
               // will do the conversion automatically.
               size = cursor.getString(sizeIndex);
           } else {
               size = "Unknown";
           }
       }
	//以下为直接从该uri中获取InputSteam,并读取出文本的内容的操作,这个是纯粹的java流操作,大家应该已经很熟悉了
	//我就不多解释了。另外这里也可以直接使用OutputSteam,向文档中写入数据。
       BufferedReader br = null;
       try {
           InputStream is = getContentResolver().openInputStream(uri);
           br = new BufferedReader(new InputStreamReader(is));
           String line;
           sb.append("\r\n content : ");
           while((line = br.readLine())!=null){
               sb.append(line);
           }
           showToast(sb.toString());
       } catch (IOException e) {
           e.printStackTrace();
       }finally {
           closeSafe(br);
       }
   }
//选择目录
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);        startActivityForResult(intent, OPEN_TREE_CODE);
新建文件
private void createDocument(){
    Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
    //设置创建的文件是可打开的
    intent.addCategory(Intent.CATEGORY_OPENABLE);
    //设置创建的文件的minitype为文本类型
    intent.setType("text/*");
    //设置创建文件的名称,注意SAF中使用minitype而不是文件的后缀名来判断文件类型。
    intent.putExtra(Intent.EXTRA_TITLE, "123.txt");
    startActivityForResult(intent,CREATE_DOCUMENT_CODE);
}
删除文件

DocumentsContract.deleteDocument(getContentResolver(),uri);

修改文件

打开文件拿到数据流,就可以修改文件内容了

重命名

该操作主要是使用了DocumentsContract类的rename方法来完成操作,因为DocumentFile类的delete方法不支持删除单个文件。需要注意的点如下: 1,这里的uri需要是SAF返回给我们的单个文件的uri 2,重命名的文件和原文件必须要在同一个文件夹下,重命名的文件名称指定路径是无效的。

  DocumentsContract.renameDocument(getContentResolver(),uri,"renamefile");

重命名文件夹:

 DocumentFile dPath = DocumentFile.fromTreeUri(this,path);       
 boolean res = dPath.renameTo(strPath);
遍历文件夹下所有文件
DocumentFile root = DocumentFile.fromTreeUri(this,uri);
DocumentFile[] files = root.listFiles();
授予权限

通过ACTION_OPEN_DOCUMENT以及ACTION_CREATE_DOCUMENT拿到的单个文件是有读写权限的;而通过ACTION_OPEN_TREE拿到的整个文件夹也是有读写权限的。但是都需要用户选择,用户选的不一定是我们想控制的,如果我们要获取sd卡根目录权限,方法竟然不在SAF框架相关中,而是存在StorageManager相关中

private void sdcardAuth(){
		//获取存储管理服务
        StorageManager sm = (StorageManager) getSystemService(Context.STORAGE_SERVICE);
        //获取存储器
        List<StorageVolume> list = sm.getStorageVolumes();
        for(StorageVolume sv : list){
        	//遍历所有存储器,当它是Removable(包含外置sd卡,usb等)且已经装载时
            if(sv.isRemovable() && TextUtils.equals(sv.getState(),Environment.MEDIA_MOUNTED)) {
            	//调用StorageVolume的createAccessIntent方法,参数为空表示对整个目录进行授权
                Intent i = sv.createAccessIntent(null);
                startActivityForResult(i, SDCARD_AUTH_CODE);
                return;
            }
        }
        showToast(" can not find sdcard ");
}

后继的处理,也是直接获取sd卡根目录的Uri,之后赋予它永久性的访问权限。然后我们就可以用之前介绍的文件操作来对它进行我们任意操作了,为了方便,我们可以把该uri保存下来。

private void handleSdCardAuth(Intent data){
        if (data == null) {
            return;
        }
		//这里获取外置sd卡根目录的Uri,我们可以将它保存下来,方便以后使用
        Uri treeUri = data.getData();
		//赋予它永久性的读写权限
           final int takeFlags = intent.getFlags()
        & (Intent.FLAG_GRANT_READ_URI_PERMISSION
        | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
        getContentResolver().takePersistableUriPermission(uri, takeFlags);
        showToast(" sdcard auth succeed,uri "+treeUri);
}
其他说明

1,SAF框架不仅可以操作外置sd卡,也可以操作其他存储空间。 2,使用SAF框架操作时,不需要额外的权限,例如使用它操作external storage时,并不需要我们申请WRITE_EXTERNAL_STORAGE权限。

具体Demo参考:github.com/android/sto…

访问App-specific目录

分为两种情况,第一是访问App自身App-specific目录,第二是访问其他App目录文件

App自身App-specific目录

Android Q,App如果启动了Filtered View,那么只能直接访问自己目录的文件:

  • Environment.getExternalStorageDirectory、getExternalStoragePublicDirectory

    这些接口在Android Q上废弃,App是Filtered View,无法直接访问这个目录。

  • 通过File(“/sdcard/”)访问

    App是Filtered View,无法直接访问这个目录。

  • 获取App-specific目录

    • 获取Media接口:getExternalMediaDirs
    • 获取Cache接口:getExternalCacheDirs
    • 获取Obb接口:getObbDirs
    • 获取Data接口:getExternalFilesDirs

App-specific目录内部多媒体文件

  • App自身访问,跟访问自身App-specific一样

  • 其他App访问

    • 默认情况下Media Scanner不会扫描App-specific里面的多媒体文件,如果需要扫描需要通过MediaScannerConnection.scanFile添加到MediaProvider数据库中

      访问方式跟读写公共目录一样。

    • App通过ContentProvider共享出去

其他App目录文件

App是Filtered View,其他App无法直接访问当前App私有目录,需要通过下面方法:

通过SAF文件

  • 共享App自定义DocumentsProvider

    a) 指定DocumentsProvider

    ​ b) DocumentsProvider实现基本接口:

public class MyDocumentsProvider extends DocumentsProvider {
    /**
     * 默认root需要查询的项
     */
    private final static String[] DEFAULT_ROOT_PROJECTION = new String[]{Root.COLUMN_ROOT_ID, Root.COLUMN_SUMMARY, 
    Root.COLUMN_FLAGS, Root.COLUMN_TITLE, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_ICON,
            Root.COLUMN_AVAILABLE_BYTES};

@Override
	public Cursor queryRoots(final String[] projection) throws FileNotFoundException {
        //创建一个查询cursor, 来设置需要查询的项, 如果"projection"为空, 那么使用默认项
        final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_ROOT_PROJECTION);
        // 添加home路径,最好做个sd卡判断
        File homeDir = Environment.getExternalStorageDirectory();         
        MatrixCursor.RowBuilder row = result.newRow();
        row.add(Root.COLUMN_ROOT_ID, homeDir.getAbsolutePath());
        row.add(Root.COLUMN_DOCUMENT_ID, homeDir.getAbsolutePath());
        row.add(Root.COLUMN_TITLE, getContext().getString(R.string.home));
        row.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_IS_CHILD);
        row.add(Root.COLUMN_ICON, R.mipmap.ic_launcher);
        row.add(Root.COLUMN_SUMMARY, sdCard.getAbsolutePath());
        row.add(Root.COLUMN_AVAILABLE_BYTES, new StatFs(homeDir.getAbsolutePath()).getAvailableBytes());
        return result;
    }

    @Override
    public boolean isChildDocument(final String parentDocumentId, final String documentId) {
        return documentId.startsWith(parentDocumentId);
    }    
    
    @Override
    public Cursor queryDocument(final String documentId, final String[] projection) throws FileNotFoundException {
        // 创建一个查询cursor, 来设置需要查询的项, 如果"projection"为空, 那么使用默认项
        final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
        includeFile(result, new File(documentId));
        return result;
    }

    @Override
    public Cursor queryChildDocuments(final String parentDocumentId, final String[] projection, final String sortOrder) throws FileNotFoundException {
        // 判断是否缺少权限
        if (LocalStorageProvider.isMissingPermission(getContext())) {
            return null;
        }
        // 创建一个查询cursor, 来设置需要查询的项, 如果"projection"为空, 那么使用默认项
        final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
        final File parent = new File(parentDocumentId);
        for (File file : parent.listFiles()) {
            // 不显示隐藏的文件或文件夹
            if (!file.getName().startsWith(".")) {
                // 添加文件的名字, 类型, 大小等属性
                includeFile(result, file);
            }
        }
        return result;
    }

    private void includeFile(final MatrixCursor result, final File file) throws FileNotFoundException {
        final MatrixCursor.RowBuilder row = result.newRow();
        row.add(Document.COLUMN_DOCUMENT_ID, file.getAbsolutePath());
        row.add(Document.COLUMN_DISPLAY_NAME, file.getName());
        String mimeType = getDocumentType(file.getAbsolutePath());
        row.add(Document.COLUMN_MIME_TYPE, mimeType);
        int flags = file.canWrite()
                ? Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_WRITE | Document.FLAG_SUPPORTS_RENAME
                | (mimeType.equals(Document.MIME_TYPE_DIR) ? Document.FLAG_DIR_SUPPORTS_CREATE : 0) : 0;
        if (mimeType.startsWith("image/"))
            flags |= Document.FLAG_SUPPORTS_THUMBNAIL;
        row.add(Document.COLUMN_FLAGS, flags);
        row.add(Document.COLUMN_SIZE, file.length());
        row.add(Document.COLUMN_LAST_MODIFIED, file.lastModified());
    }

    @Override
    public String getDocumentType(final String documentId) throws FileNotFoundException {
        if (LocalStorageProvider.isMissingPermission(getContext())) {
            return null;
        }
        File file = new File(documentId);
        if (file.isDirectory())
            return Document.MIME_TYPE_DIR;
        final int lastDot = file.getName().lastIndexOf('.');
        if (lastDot >= 0) {
            final String extension = file.getName().substring(lastDot + 1);
            final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
            if (mime != null) {
                return mime;
            }
        }
        return "application/octet-stream";
    }

    @Override
    public boolean onCreate() {
        return true;  // 这里需要返回true
    }
}
  • 访问App通过ACTION_OPEN_DOCUMENT,启动浏览

    Intent intent=new Intent(Intent.ACTION_OPEN_DOCUMENT);//ACTION_OPEN_DOCUMENT  
    intent.addCategory(Intent.CATEGORY_OPENABLE);  
    intent.setType("image/jpeg");//"*/*"
    startActivityForResult(intent, 5);
    

共享App实现FileProvider

大概步骤:

  • 指定App FileProvider

      <provider
            android:name="android.support.v4.content.FileProvider"
            android:authorities="<包名>.fileProvider"
            android:grantUriPermissions="true"
            android:exported="false">
          <meta-data
              android:name="android.support.FILE_PROVIDER_PATHS"
              android:resource="@xml/file_paths"/>
        </provider>
    
  • 指定文件路径,配置文件必须要放到res/xml中

    <?xml version="1.0" encoding="utf-8"?>
    <resources>
      <paths>
        <!-- Context.getFilesDir() + "/path/" -->
        <files-path
            name="my_files"
            path="mazaiting/"/>
        <!-- Context.getCacheDir() + "/path/" -->
        <cache-path
            name="my_cache"
            path="mazaiting/"/>
        <!-- Context.getExternalFilesDir(null) + "/path/" -->
        <external-files-path
            name="external-files-path"
            path="mazaiting/"/>
        <!-- Context.getExternalCacheDir() + "/path/" -->
        <external-cache-path 
             name="name" 
             path="mazaiting/" />
        <!-- Environment.getExternalStorageDirectory() + "/path/" -->
        <external-path
            name="my_external_path"
            path="mazaiting/"/>
        <!-- Environment.getExternalStorageDirectory() + "/path/" -->
        <external-path
            name="files_root"
            path="Android/data/<包名>/"/>
        <!-- path设置为'.'时代表整个存储卡 Environment.getExternalStorageDirectory() + "/path/"   -->
        <external-path
            name="external_storage_root"
            path="."/>
      </paths>
    </resources>
    
  • 获取分享Uri

     Uri contentUri = FileProvider.getUriForFile(context,
                  BuildConfig.APPLICATION_ID + ".fileProvider",
                  new File(path));
    
  • 设置权限,并且发送Uri

    Intent intent = new Intent(Intent.ACTION_SEND);
    File imagePath = new File(filePath);
    Uri imageUri;
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        imageUri = FileProvider.getUriForFile(activity, BuildConfig.APPLICATION_ID + ".fileProvider", imagePath);
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
    } else {
        imageUri = Uri.fromFile(imagePath);
    }
    intent.setDataAndType(imageUri, getContentResolver().getType(imageUri));
    activity.startActivityForResult(intent, requestCode);
    
  • 接收App,设置接受的inter-filter

    <intent-filter>
        <action android:name="android.intent.action.SEND" />
        <category android:name="android.intent.category.DEFAULT" />
        <data android:mimeType="image/*"/>
    </intent-filter>
    
  • 接收并处理Uri

     ParcelFileDescriptor pfd = getContentResolver().openFileDescriptor(intent.getData(),"r");
    

MediaStore_data字段

MediaStore中,DATA即(_data)字段,在Android Q中开始废弃。读写文件需要通过openFileDescriptor。

MediaStore文件Pending状态

Android Q上,MediaStore中添加了一个IS_PENDING Flag,用于标记当前文件时Pending状态。

其他App通过MediaStore查询文件,如果没有设置setIncludePending接口,查询不到设置为Pending状态的文件,这就给App专享访问此文件。在一些情况下使用,例如在下载的时候:下载中,文件是Pending状态à下载完成,文件Pending状态置为0。

MediaColumns.RELATIVE_PATH设置存储路径

Android Q上,通过MediaStore存储到公共目录的文件,除了上文Uri跟公共目录关系中规定的每一个存储空间的一级目录外,可以通过MediaColumns.RELATIVE_PATH来指定存储的次级目录,这个目录可以使多级,具体代码如下:

  • ContentResolver insert方法

    通过values.put(Media.RELATIVE_PATH,"Pictures/album/family ")指定存储目录。其中,Pictures是一级目录,album/family是子目录。

  • ContentResolver update方法

    通过values.put(Media.RELATIVE_PATH,"Pictures/album/family ")指定存储目录。通过update方法,可以移动存储地方。

访问图片Exif Metadata

Android Q上, App如果需要访问图片上的Exif Metadata,需要做下列事情:

  • 申请ACCESS_MEDIA_LOCATION权限
  • 通过MediaStore.setRequireOriginal返回新Uri

Demo Code如下:

AppFiltered View,访问权限总结

App访问不同目录的权限总结如下:

文件位置 需要权限 访问方式 卸载是否保存
App-specific目录 getExternalFilesDir() 不保留
Media文件(photos, videos, audio) 访问其他app文件,需要READ_EXTERNAL_STORAGE
修改其他app文件,需要
WRITE_EXTERNAL_STORAGE
MediaStore 保留
Downloads SAF 保留

应用卸载

如果App在AndroidManifest.xml中声明:android:hasFragileUserData="true"

卸载应用会有提示是否保留App数据:

App数据迁移

Android Q上,App TargetSDK>=Q默认是Filtered View。App如果是Filtered View,会涉及到数据的迁移,不然会导致旧数据无法使用。可以从下面几方面着手数据迁移:

  • App需要在Legacy View下才能拥有完整操作存储的权限

  • App存放在非公共区域的文件,可以通过SAF访问

    通过SAF选择目录文件,用户选择访问App文件。

  • App可以将需要保存的文件:

    Images、Video、Audio放到对应的公共目录,其他文件卸载后不删除文件可以放到Downloads下面。

MediaStoreQueries

在使用MediaStore进行query动作的时候,使用Projection时,Column Name要在MediaStore中定义好的。

WRITE_MEDIA_STORAGE权限

WRITE_MEDIA_STORAGE是一个很大强大的权限,能够允许App获取访问所有存储设备的权限。访问所有存储设备的权限,这个应当只赋予Media Stack。官方不推荐使用

更多学习和面试资料: github.com/zytc2009/Bi…

你要默默努力,然后悄悄拔尖。