安卓存储权限原理

上篇博客介绍了FileProvider是如何跨应用访问文件的。这篇博客我们来讲讲安卓是如何控制文件的访问权限的。

内部储存

由于安卓基于Linux,所以最简单的文件访问权限控制方法就是使用Linux的文件权限机制.例如应用的私有目录就是这么实现的。

安卓系统为每个安卓的应用都分配了一个用户和用户组,我们可以通过ps命令查看运行中的应用对应的用户:

USER           PID  PPID     VSZ    RSS WCHAN            ADDR S NAME
...
u0_a66        2685  1085 3914640  70688 SyS_epoll_wait      0 S me.linw.demo
...

这里的u0_a66指的是应用的user name,它表示该应用是user 0(这里指的是安卓多用户模式下的主用户,和前面讲的Linux用户不是同一个概念)下面的应用id是66.由于通应用程序的user id都是从10000开始,所以这个应用的user id是10066.可以从/data/system/packages.list文件中确认:

me.linw.demo 10066 1 /data/user/0/me.linw.demo default:targetSdkVersion=30 3003

应用的私有目录为/data/data/${包名}/,可以看到安卓系统给应用创建了一个权限为700的目录,文件的owner和group都只属于这个应用,这样就保证了每个应用的私有目录只有自己可以访问:

# ls -l /data/data/ | grep me.linw.demo
drwx------ 5 u0_a66 u0_a66 4096 2023-03-07 19:32 me.linw.demo

SharedUserId

当然也可以在AndroidManifest.xml里面配置android:sharedUserId让他们是用同一个User:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="me.linw.demo2"
    android:sharedUserId="test.same.user">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="me.linw.demo"
    android:sharedUserId="test.same.user">

这样的话两个应用的user就是一样的,就能相互访问私有目录了:

drwx------ 4 u0_a66 u0_a66 4096 2023-03-10 17:07 me.linw.demo2
drwx------ 5 u0_a66 u0_a66 4096 2023-03-10 16:53 me.linw.demo

外部存储

外部存储的文件系统几经变更。从早期的FUSE到Android 8改为性能更优的SDCardFS,再到Android 11上为了更细的管理文件权限又换回FUSE。各个安卓版本的实现细节也稍有差异,过于老旧的版本也没有学习的必要,这里只拿比较有代表性的Android 8和Android 11进行源码分析。

Android 11以前

安卓11以前的外部存储权限控制做的比较粗糙。应用申请了WREAD_EXTERNAL_STORAGE、WRITE_EXTERNAL_STORAGE就可以对外部存储进行读写。

这个外部存储一般指的是/storage/emulated/目录,它为每个用户分配了一个子目录。例如0子目录就是user 0(主用户)的外部存储目录.

这里我们用一个shellExec在进程里面执行命令协助我们理解外部存储的管理原理:

public void shellExec(String shell) throws IOException {
    InputStream is = Runtime.getRuntime().exec(shell).getInputStream();
    BufferedReader reader = new BufferedReader(new InputStreamReader(is));
    StringBuilder sb = new StringBuilder();
    char[] buff = new char[1024];
    int ch;
    while ((ch = reader.read(buff)) != -1) {
        sb.append(buff, 0, ch);
    }
    reader.close();
    Log.d("ExecShell", shell);
    Log.d("ExecShell", sb.toString());
}

申请READ_EXTERNAL_STORAGE权限之后执行ls -l /storage/emulated/0/就可以看到熟悉的外部存储目录结构:

shellExec("ls -l /storage/emulated/0/");


03-11 17:02:26.861  3411  3411 D ExecShell: ls -l /storage/emulated/0/
03-11 17:02:26.861  3411  3411 D ExecShell: total 40
03-11 17:02:26.861  3411  3411 D ExecShell: drwxr-x--- 2 root everybody 4096 2022-04-24 20:25 Alarms
03-11 17:02:26.861  3411  3411 D ExecShell: drwxr-x--- 3 root everybody 4096 2023-03-08 14:13 Android
03-11 17:02:26.861  3411  3411 D ExecShell: drwxr-x--- 2 root everybody 4096 2022-04-24 20:25 DCIM
03-11 17:02:26.861  3411  3411 D ExecShell: drwxr-x--- 2 root everybody 4096 2023-03-07 19:49 Download
03-11 17:02:26.861  3411  3411 D ExecShell: drwxr-x--- 2 root everybody 4096 2022-04-24 20:25 Movies
03-11 17:02:26.861  3411  3411 D ExecShell: drwxr-x--- 2 root everybody 4096 2022-04-24 20:25 Music
03-11 17:02:26.861  3411  3411 D ExecShell: drwxr-x--- 2 root everybody 4096 2022-04-24 20:25 Notifications
03-11 17:02:26.861  3411  3411 D ExecShell: drwxr-x--- 2 root everybody 4096 2023-03-07 19:46 Pictures
03-11 17:02:26.861  3411  3411 D ExecShell: drwxr-x--- 2 root everybody 4096 2022-04-24 20:25 Podcasts
03-11 17:02:26.861  3411  3411 D ExecShell: drwxr-x--- 2 root everybody 4096 2022-04-24 20:25 Ringtones

这里可以看到虽然这些目录的user是root,但是所属的group是everybody,即所有人对这些目录都有r-x的权限可读可进入文件夹。

而如果申请了WRITE_EXTERNAL_STORAGE权限之后再执行ls -l /storage/emulated/0/就会看见group的权限变成了rwx可读可写可进入文件夹。

03-11 17:10:44.146  3646  3646 D ExecShell: ls -l /storage/emulated/0/
03-11 17:10:44.146  3646  3646 D ExecShell: total 40
03-11 17:10:44.146  3646  3646 D ExecShell: drwxrwx--- 2 root everybody 4096 2022-04-24 20:25 Alarms
03-11 17:10:44.146  3646  3646 D ExecShell: drwxrwx--- 3 root everybody 4096 2023-03-08 14:13 Android
...

也就是说不同的权限下应用看到/storage/emulated/0/的文件权限是不一样的,这一点又是怎么做的的呢?

/mnt/runtime目录

这里先介绍/mnt/runtime下的三个目录:

mount | grep /mnt/runtime
/data/media on /mnt/runtime/default/emulated type sdcardfs (rw,nosuid,nodev,noexec,noatime,fsuid=1023,fsgid=1023,gid=1015,multiuser,mask=6,derive_gid)
/data/media on /mnt/runtime/read/emulated type sdcardfs (rw,nosuid,nodev,noexec,noatime,fsuid=1023,fsgid=1023,gid=9997,multiuser,mask=23,derive_gid)
/data/media on /mnt/runtime/write/emulated type sdcardfs (rw,nosuid,nodev,noexec,noatime,fsuid=1023,fsgid=1023,gid=9997,multiuser,mask=7,derive_gid)

可以看到/mnt/runtime/default/emulated/mnt/runtime/read/emulated/mnt/runtime/write/emulated都挂载了/data/media。只不过他们的gid、和mask不尽相同。

group

其实这三个目录都是通过bind mount机制(普通的mount只能挂载设备,但是bind mount可以挂载目录)挂载的/data/media目录,gid指的是挂载之后修改文件系统下文件的group:

# ls -l /data/media
total 8
drwxrwx--- 12 media_rw media_rw 4096 2023-03-11 16:51 0
drwxrwxr-x  2 media_rw media_rw 4096 1970-01-01 08:00 obb

# ls -l /mnt/runtime/default/emulated
total 8
drwxrwx--x 12 root sdcard_rw 4096 2023-03-11 16:51 0
drwxrwx--x  2 root sdcard_rw 4096 1970-01-01 08:00 obb

# ls -l /mnt/runtime/read/emulated
total 8
drwxr-x--- 12 root everybody 4096 2023-03-11 16:51 0
drwxr-x---  2 root everybody 4096 1970-01-01 08:00 obb

# ls -l /mnt/runtime/write/emulated
total 8
drwxrwx--- 12 root everybody 4096 2023-03-11 16:51 0
drwxrwx---  2 root everybody 4096 1970-01-01 08:00 obb

可以看到原本/data/media下的文件group是media_rw(id=1023),但挂载之后/mnt/runtime/default/emulated的group是sdcard_rw(id=1015),/mnt/runtime/read/emulated/mnt/runtime/write/emulated的group是everybody(id=9997)。

这些group的id可以在android_filesystem_config.h看到:

// http://androidxref.com/8.0.0_r4/xref/system/core/include/private/android_filesystem_config.h

...
#define AID_SDCARD_RW 1015       /* external storage write access */
...
#define AID_MEDIA_RW 1023        /* internal media storage write access */
...
#define AID_EVERYBODY 9997 /* shared between all apps in the same profile */
...

mask

而mask则是用来重新定义文件的rwx权限的,挂载后文件的权限通过0775 & ~mask计算得到(注意这里的0775指定是8进制的775,即十进制的509):

// https://android.googlesource.com/kernel/common.git/+/experimental/android-4.9/fs/sdcardfs/sdcardfs.h

static inline int get_mode(struct vfsmount *mnt, struct sdcardfs_inode_info *info) {
    ...
    int visible_mode = 0775 & ~opts->mask;
    ...
}

所以:

/mnt/runtime/default/emulated的权限为0775 & ~6:

0775 =  111111101 = 111111101
~6   = ~000000110 = 111111001 
------------------------------
                    111111001 = rwxrwx--x

/mnt/runtime/read/emulated的权限为0775 & ~23:

0775 =  111111101 = 111111101
~23  = ~000010111 = 111101000
------------------------------
                    111101000 = rwxr-x---

/mnt/runtime/default/emulated的权限为0775 & ~7:

0775 =  111111101 = 111111101
~7   = ~000000111 = 111111000
------------------------------
                    111111000 = rwxrwx---

综上所述:

  • /mnt/runtime/default/emulated : 普通应用由于不在media_rw组,只有进入子目录的权限,并不能读写。
  • /mnt/runtime/read/emulated : 普通应用属于everybody组,有r-x权限
  • /mnt/runtime/default/emulated : 普通应用属于everybody组,有rwx权限

外部存储读写权限原理

实际上外部存储路径/storage/emulated是通过挂载前面所说的三个目录去实现不同的访问权限的。

在Zygote进程fork应用进程的时候会通过Linux的bind mount机制为应用在私有挂载空间挂载/storage目录:

// https://cs.android.com/android/platform/superproject/+/android-8.0.0_r1:frameworks/base/core/jni/com_android_internal_os_Zygote.cpp

// Create a private mount namespace and bind mount appropriate emulated
// storage for the given user.
static bool MountEmulatedStorage(uid_t uid, jint mount_mode,
        bool force_mount_namespace) {
    // See storage config details at http://source.android.com/tech/storage/

    String8 storageSource;
    if (mount_mode == MOUNT_EXTERNAL_DEFAULT) {
        storageSource = "/mnt/runtime/default";
    } else if (mount_mode == MOUNT_EXTERNAL_READ) {
        storageSource = "/mnt/runtime/read";
    } else if (mount_mode == MOUNT_EXTERNAL_WRITE) {
        storageSource = "/mnt/runtime/write";
    } else if (!force_mount_namespace) {
        // Sane default of no storage visible
        return true;
    }

    // Create a second private mount namespace for our process
    if (unshare(CLONE_NEWNS) == -1) {
        ALOGW("Failed to unshare(): %s", strerror(errno));
        return false;
    }

    ...

    if (TEMP_FAILURE_RETRY(mount(storageSource.string(), "/storage",
            NULL, MS_BIND | MS_REC | MS_SLAVE, NULL)) == -1) {
        ALOGW("Failed to mount %s to /storage: %s", storageSource.string(), strerror(errno));
        return false;
    }

    ...
}

系统根据应用的外部存储权限传入不同的mount_mode:

  • 没有权限挂载/mnt/runtime/default
  • 有READ_EXTERNAL_STORAGE权限挂载/mnt/runtime/read
  • 有WRITE_EXTERNAL_STORAGE权限挂载/mnt/runtime/write

由于使用了unshare所以挂载的/storage实际是在应用的私有挂载空间,即每个应用挂载的/storage是仅自己可见其他应用不可见的。

而这里使用了MS_REC参数,所以会递归挂载子目录,即:/mnt/runtime/default挂载到/storage的同时/mnt/runtime/default/emulated也会挂载到/storage/emulated

间接挂载

不过通过mount命令可以看到/storage/emulated实际上也是挂载了/data/media,而不是前面说的三个目录:

03-11 17:13:36.495  3778  3778 D ExecShell: mount
...
03-11 17:13:36.495  3778  3778 D ExecShell: /data/media on /storage/emulated type sdcardfs (rw,nosuid,nodev,noexec,noatime,fsuid=1023,fsgid=1023,gid=9997,multiuser,mask=7,derive_gid)
...

这是由于bind mount的特性,并不能看到间接挂载的过程。例如我们可以将/mnt/runtime/default/emulated通过bind mount挂载到/data/test/,然后用mount命令可以看到/data/test也是挂载了/data/media:

# mount --bind /mnt/runtime/default/emulated  /data/test
# mount | grep /data/test
/data/media on /data/test type sdcardfs (rw,nosuid,nodev,noexec,noatime,fsuid=1023,fsgid=1023,gid=1015,multiuser,mask=6,derive_gid)

运行时权限

Android 6之后导入了运行时权限,READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE需要在运行时申请.

所以应用在第一次启动的时候还没有外部存储的权限,挂载的是/mnt/runtime/default.

当运行时权限申请成功之后就会触发StorageManagerInternalImpl.onExternalStoragePolicyChanged然后去给这个应用重新挂载/storage/emulated:

// https://cs.android.com/android/platform/superproject/+/android-8.0.0_r1:frameworks/base/services/core/java/com/android/server/StorageManagerService.java
private final class StorageManagerInternalImpl extends StorageManagerInternal {
    ...
    @Override
    public void onExternalStoragePolicyChanged(int uid, String packageName) {
        final int mountMode = getExternalStorageMountMode(uid, packageName);
        remountUidExternalStorage(uid, mountMode);
    }
    ...
}

private void remountUidExternalStorage(int uid, int mode) {
    waitForReady();

    String modeName = "none";
    switch (mode) {
        case Zygote.MOUNT_EXTERNAL_DEFAULT: {
            modeName = "default";
        } break;

        case Zygote.MOUNT_EXTERNAL_READ: {
            modeName = "read";
        } break;

        case Zygote.MOUNT_EXTERNAL_WRITE: {
            modeName = "write";
        } break;
    }

    try {
        mConnector.execute("volume", "remount_uid", uid, modeName);
    } catch (NativeDaemonConnectorException e) {
        Slog.w(TAG, "Failed to remount UID " + uid + " as " + modeName + ": " + e);
    }
}

最终会调用到VolumeManager::remountUid从proc查找应用进程对应的私有挂载空间,重新根据权限挂载/storage:

// https://cs.android.com/android/platform/superproject/+/android-8.0.0_r1:system/vold/VolumeManager.cpp

int VolumeManager::remountUid(uid_t uid, const std::string& mode) {
    ...

    if (!(dir = opendir("/proc"))) {
        PLOG(ERROR) << "Failed to opendir";
        return -1;
    }

    ...

    // 遍历/proc的子目录
    while ((de = readdir(dir))) {
        pidFd = -1;
        nsFd = -1;

        pidFd = openat(dirfd(dir), de->d_name, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
        if (pidFd < 0) {
            goto next;
        }
        if (fstat(pidFd, &sb) != 0) {
            PLOG(WARNING) << "Failed to stat " << de->d_name;
            goto next;
        }

        // 对比uid,查找uid对应的进程目录
        if (sb.st_uid != uid) {
            goto next;
        }

        ...

        // 读取私有挂载空间的id
        nsFd = openat(pidFd, "ns/mnt", O_RDONLY); // not O_CLOEXEC
        if (nsFd < 0) {
            PLOG(WARNING) << "Failed to open namespace for " << de->d_name;
            goto next;
        }

        // 开启子进程实现并发
        if (!(child = fork())) {
            // 进入应用进程的私有挂载空间
            if (setns(nsFd, CLONE_NEWNS) != 0) {
                PLOG(ERROR) << "Failed to setns for " << de->d_name;
                _exit(1);
            }

            // 解除/storage挂载
            unmount_tree("/storage");

            // 根据权限挂载对应目录
            std::string storageSource;
            if (mode == "default") {
                storageSource = "/mnt/runtime/default";
            } else if (mode == "read") {
                storageSource = "/mnt/runtime/read";
            } else if (mode == "write") {
                storageSource = "/mnt/runtime/write";
            } else {
                // Sane default of no storage visible
                _exit(0);
            }

            //重新挂载/storage
            if (TEMP_FAILURE_RETRY(mount(storageSource.c_str(), "/storage",
                    NULL, MS_BIND | MS_REC, NULL)) == -1) {
                PLOG(ERROR) << "Failed to mount " << storageSource << " for "
                        << de->d_name;
                _exit(1);
            }
            ...

            _exit(0);
        }

        if (child == -1) {
            PLOG(ERROR) << "Failed to fork";
            goto next;
        } else {
            TEMP_FAILURE_RETRY(waitpid(child, nullptr, 0));
        }

next:
        close(nsFd);
        close(pidFd);
    }
    closedir(dir);
    return 0;
}

缺点

这种权限管理的方式比较粗犷,一旦获取了读写的权限就能对外部存储的任意目录进行读写,例如应用的外部存储路径/storage/emulated/0/Android/data/${包名}/:

shellExec("ls -l /storage/emulated/0/Android/data");

03-12 18:48:12.809  2934  2934 D ExecShell: ls -l /storage/emulated/0/Android/data
03-12 18:48:12.809  2934  2934 D ExecShell: total 4
03-12 18:48:12.809  2934  2934 D ExecShell: drwxrwx--- 3 u0_a15 everybody 4096 2023-03-12 18:47 com.android.launcher3

获取到读取权限之后就能对其他应用的外部存储路径进行读写了。因此一些敏感的信息一般不会写入到下面方法获取出来的路径:

public File getExternalFilesDir(String type)
public File[] getExternalFilesDirs(String type) 
public File getExternalCacheDir() 
public File[] getExternalCacheDirs() 
public File[] getExternalMediaDirs()

Android 11以后

安卓11为了更好的管控外部存储的权限,废弃了READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE,使用分区存储(Scoped Storage)的去管理外部存储:

使用分区存储的应用可具有以下访问权限级别(实际访问权限因实现而异)。

- 对自己的文件拥有读取和写入访问权限(没有权限限制)
- 对其他应用的媒体文件拥有读取访问权限(需要具备 READ_EXTERNAL_STORAGE 权限)
- 只有在用户直接同意的情况下,才允许对其他应用的媒体文件拥有写入访问权限(系统图库以及符合“所有文件访问权限”获取条件的应用除外)
- 对其他应用的外部应用数据目录没有读取或写入访问权限

应用端具体的适配方法在网上有很多文章有提及,无非是通过MediaStore或者SAF去访问外部存储,我这边就不做介绍了。这篇博客主要介绍系统端是如何实现外部存储的权限管理的。

FUSE

为了实现分区存储,前面的bind mount机制是无法做到这么细致的管理的。所以在Android 11谷歌又废弃了Android 8导入的SDCardFS,回归FUSE机制。

FUSE是由Linux Kernel提供的一种文件系统。它的框架图如下:

1.png

Linux为了支持多种文件系统(如EXT4, NTFS, FAT等)抽象了一个虚拟文件系统层(VFS),FUSE就是其中的一种.

从上面的框架图可以看到,在用户空间会有一个FUSE daemon进程监听对FUSE文件系统的操作,然后对其进行转发给到其他的文件系统。

由于是在FUSE是kernel提供的机制,所以无论应用是通过java还是native方法去操作的文件,安卓都可以在FUSE daemon对文件的操作请求进行权限鉴别和拦截。

FUSE daemon

例如使用FileOutputStream在外部存储创建文件的时候会回调到FuseDaemon的pf_create:

// https://cs.android.com/android/platform/superproject/+/android-mainline-11.0.0_r1:packages/providers/MediaProvider/jni/FuseDaemon.cpp
static void pf_create(fuse_req_t req,
                      fuse_ino_t parent,
                      const char* name,
                      mode_t mode,
                      struct fuse_file_info* fi) {
      ...
      if (!is_app_accessible_path(fuse->mp, parent_path, req->ctx.uid)) {
          fuse_reply_err(req, ENOENT);
          return;
      }
  
      TRACE_NODE(parent_node, req);
  
      const string child_path = parent_path + "/" + name;
  
      int mp_return_code = fuse->mp->InsertFile(child_path.c_str(), req->ctx.uid);
      if (mp_return_code) {
          fuse_reply_err(req, mp_return_code);
          return;
      }

      ...
      // Let MediaProvider know we've created a new file
      fuse->mp->OnFileCreated(child_path);
      ...
}

从代码上我们看到首先它会调用is_app_accessible_path去判断应用的访问权限:

const std::regex PATTERN_OWNED_PATH(
    "^/storage/[^/]+/(?:[0-9]+/)?Android/(?:data|obb|sandbox)/([^/]+)(/?.*)?",
    std::regex_constants::icase);

static bool is_app_accessible_path(MediaProviderWrapper* mp, const string& path, uid_t uid) {
  // 系统权限的应用会被允许访问, FuseDaemon进程自己也允许访问
  if (uid < AID_APP_START || uid == MY_UID) {
      return true;
  }

  //应用不能直接访问/storage/emulated,只能访问它的子目录,例如/storage/emulated/0
  if (path == "/storage/emulated") {
      return false;
  }

  std::smatch match;
  if (std::regex_match(path, match, PATTERN_OWNED_PATH)) {
      const std::string& pkg = match[1];
      ...
      if (!mp->IsUidForPackage(pkg, uid)) {
          // /storage/emulated/0/Andrdoi/data/${包名} 这样的目录不允许其他应用访问
          PLOG(WARNING) << "Invalid other package file access from " << pkg << "(: " << path;
          return false;
      }
  }
  return true;
}

然后会调用fuse->mp->InsertFile去通过jni回调到java层的MediaProvider.insertFileIfNecessaryForFuse去插入文件:

// https://cs.android.com/android/platform/superproject/+/android-mainline-11.0.0_r1:packages/providers/MediaProvider/jni/MediaProviderWrapper.cpp
int MediaProviderWrapper::InsertFile(const string& path, uid_t uid) {
    ...
    return insertFileInternal(env, media_provider_object_, mid_insert_file_, path, uid);
}

int insertFileInternal(JNIEnv* env, jobject media_provider_object, jmethodID mid_insert_file,
                       const string& path, uid_t uid) {
    ScopedLocalRef<jstring> j_path(env, env->NewStringUTF(path.c_str()));
    int res = env->CallIntMethod(media_provider_object, mid_insert_file, j_path.get(), uid);
    ...
}

MediaProviderWrapper::MediaProviderWrapper(JNIEnv* env, jobject media_provider) {
    ...
    media_provider_class_ = env->FindClass("com/android/providers/media/MediaProvider");
    ...
    mid_insert_file_ = CacheMethod(env, "insertFileIfNecessary", "(Ljava/lang/String;I)I",
                               /*is_static*/ false);
    ...
}

jmethodID MediaProviderWrapper::CacheMethod(JNIEnv* env, const char method_name[],
                                            const char signature[], bool is_static) {
    jmethodID mid;
    string actual_method_name(method_name);
    actual_method_name.append("ForFuse");
    if (is_static) {
        mid = env->GetStaticMethodID(media_provider_class_, actual_method_name.c_str(), signature);
    } else {
        mid = env->GetMethodID(media_provider_class_, actual_method_name.c_str(), signature);
    }
    ...
}

目录隔离

insertFileIfNecessaryForFuse会通过文件的后缀解析出mimeType(例如.jpg就是图片类型,.mp4就是视频类型),然后创建contentUri调用insertFileForFuse:

// https://cs.android.com/android/platform/superproject/+/android-mainline-11.0.0_r1:packages/providers/MediaProvider/src/com/android/providers/media/MediaProvider.java
public int insertFileIfNecessaryForFuse(@NonNull String path, int uid) {
    ...
    final String mimeType = MimeUtils.resolveMimeType(new File(path));
    ...
    final Uri contentUri = getContentUriForFile(path, mimeType);
    final Uri item = insertFileForFuse(path, contentUri, mimeType, /*useData*/ false);
    if (item == null) {
        return OsConstants.EPERM;
    }
  ...
}

private Uri insertFileForFuse(@NonNull String path, @NonNull Uri uri, @NonNull String mimeType,
            boolean useData) {
    ContentValues values = new ContentValues();
    values.put(FileColumns.OWNER_PACKAGE_NAME, getCallingPackageOrSelf());
    values.put(MediaColumns.MIME_TYPE, mimeType);
    values.put(FileColumns.IS_PENDING, 1);

    if (useData) {
        values.put(FileColumns.DATA, path);
    } else {
        values.put(FileColumns.VOLUME_NAME, extractVolumeName(path));
        values.put(FileColumns.RELATIVE_PATH, extractRelativePath(path));
        values.put(FileColumns.DISPLAY_NAME, extractDisplayName(path));
    }
    return insert(uri, values, Bundle.EMPTY);
}

insert里面会对文件类型和存放的路径做校验,也就是说外部存储公共目录下只能存放特定类型的文件,例如Movies下只能放视频文件、Music下只能放音频文件、Pictures下只能放图片文件等。你不能将png的图片放到/storage/emulated/0/Movies下:

03-14 19:48:04.683  1774  2181 E MediaProvider: java.lang.IllegalArgumentException: MIME type image/png cannot be inserted into content://media
/external_primary/video/media; expected MIME type under video/*

这个校验是在insert里面调用ensureFileColumns方法去检查的:

// https://cs.android.com/android/platform/superproject/+/android-mainline-11.0.0_r1:packages/providers/MediaProvider/src/com/android/providers/media/MediaProvider.java
private void ensureFileColumns(int match, @NonNull Uri uri, @NonNull Bundle extras,
        @NonNull ContentValues values, boolean makeUnique, @Nullable String currentPath)
        throws VolumeArgumentException, VolumeNotFoundException {
  ...
  else if (defaultMediaType != actualMediaType) {
      final String[] split = defaultMimeType.split("/");
      throw new IllegalArgumentException(
              "MIME type " + mimeType + " cannot be inserted into " + uri
                      + "; expected MIME type under " + split[0] + "/*");
  }
  ...
}

而像/storage/emulated/0/Android/media/${包名}这样的外部媒体私有路径也会被拦截下来:

03-14 20:11:49.541  1774  2038 E MediaProvider: java.lang.IllegalArgumentException: Primary directory Android not allowed for content://media/external_primary/file; allowed directories are [Download, Documents]

它同样是在ensureFileColumns里面拦截的:

// https://cs.android.com/android/platform/superproject/+/android-mainline-11.0.0_r1:packages/providers/MediaProvider/src/com/android/providers/media/MediaProvider.java
private void ensureFileColumns(int match, @NonNull Uri uri, @NonNull Bundle extras,
        @NonNull ContentValues values, boolean makeUnique, @Nullable String currentPath)
        throws VolumeArgumentException, VolumeNotFoundException {
    ...
    // Consider allowing external media directory of calling package
    if (!validPath) {
        final String pathOwnerPackage = extractPathOwnerPackageName(res.getAbsolutePath());
        if (pathOwnerPackage != null) {
            validPath = isExternalMediaDirectory(res.getAbsolutePath()) &&
                  isCallingIdentitySharedPackageName(pathOwnerPackage);
        }
    }
    ...
    if (!validPath) {
        throw new IllegalArgumentException(
              "Primary directory " + primary + " not allowed for " + uri
                      + "; allowed directories are " + allowedPrimary);
    }
    ...
}

private boolean isExternalMediaDirectory(@NonNull String path) {
    final String relativePath = extractRelativePath(path);
    if (relativePath != null) {
        return relativePath.startsWith("Android/media");
    }
    return false;
}
// https://cs.android.com/android/platform/superproject/+/android-mainline-11.0.0_r1:packages/providers/MediaProvider/src/com/android/providers/media/util/FileUtils.java
public static final Pattern PATTERN_OWNED_PATH = Pattern.compile(
        "(?i)^/storage/[^/]+/(?:[0-9]+/)?"
        + PROP_CROSS_USER_ROOT_PATTERN
        + "Android/(?:data|media|obb)/([^/]+)(/?.*)?");

public static @Nullable String extractPathOwnerPackageName(@Nullable String path) {
    if (path == null) return null;
    final Matcher m = PATTERN_OWNED_PATH.matcher(path);
    if (m.matches()) {
        return m.group(1);
    }
    return null;
}

从上面的错误日志可以看出来Download, Documents是公共目录。实际上这两个目录不会检查文件类型,可以存放所有类型的文件。

媒体数据库

另外我们看到insertFileForFuse里面会创建ContentValues去调用insert,这里的代码其实和应用层使用MediaStore去访问外部存储基本一致了。

insert的意思实际上是插入到MediaProvider数据库,所以我们可以从MediaProvider数据库通过文件类型查找文件(例如音乐播放器可以通过MediaProvider查找到手机上的所有音频文件):

// https://cs.android.com/android/platform/superproject/+/android-mainline-11.0.0_r1:packages/providers/MediaProvider/src/com/android/providers/media/MediaProvider.java
public @Nullable Uri insert(@NonNull Uri uri, @Nullable ContentValues values,
        @Nullable Bundle extras) {
    ...
    return insertInternal(uri, values, extras);
    ...
}

private @Nullable Uri insertInternal(@NonNull Uri uri, @Nullable ContentValues initialValues,
            @Nullable Bundle extras) throws FallbackException {
    ...
    final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_INSERT, match, uri, extras, null);
    ...
    switch (match) {
        case IMAGES_MEDIA: {
            ..
            newUri = insertFile(qb, helper, match, uri, extras, initialValues,
                    FileColumns.MEDIA_TYPE_IMAGE);
            break;
        }

        case IMAGES_THUMBNAILS: {
            ...

            rowId = qb.insert(helper, initialValues);
            if (rowId > 0) {
                newUri = ContentUris.withAppendedId(Images.Thumbnails.
                        getContentUri(originalVolumeName), rowId);
            }
            break;
        }

        case VIDEO_THUMBNAILS: {
            ...

            rowId = qb.insert(helper, initialValues);
            if (rowId > 0) {
                newUri = ContentUris.withAppendedId(Video.Thumbnails.
                        getContentUri(originalVolumeName), rowId);
            }
            break;
        }

        case AUDIO_MEDIA: {
            ...
            newUri = insertFile(qb, helper, match, uri, extras, initialValues,
                    FileColumns.MEDIA_TYPE_AUDIO);
            break;
        }
        ...
    }
    ...
}

private Uri insertFile(@NonNull SQLiteQueryBuilder qb, @NonNull DatabaseHelper helper,
        int match, @NonNull Uri uri, @NonNull Bundle extras, @NonNull ContentValues values,
        int mediaType) throws VolumeArgumentException, VolumeNotFoundException {
    ...
    rowId = insertAllowingUpsert(qb, helper, values, path);
    ...
}


private long insertAllowingUpsert(@NonNull SQLiteQueryBuilder qb,
        @NonNull DatabaseHelper helper, @NonNull ContentValues values, String path)
        throws SQLiteConstraintException {
    return helper.runWithTransaction((db) -> {
        ...
        return qb.insert(helper, values);
        ...
    }
}

文件隔离

虽然前面讲到Download, Document是公共目录,谁都可以往里面写入文件。但是正常情况下普通应用只能读取自己写入的问题,没有权限读取其他应用写入的文件:

1774  2181 E MediaProvider: Permission to access file: //storage/emulated/0/Download/OtherAppFile.txt is denied

这是因为打开文件的时候会触发到FuseDaemon的pf_open:

static void pf_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) {
  ...
  std::unique_ptr<FileOpenResult> result = fuse->mp->OnFileOpen(
            build_path, io_path, ctx->uid, ctx->pid, node->GetTransformsReason(), for_write,
            !for_write /* redact */, true /* log_transforms_metrics */);
  ...
}

最终去到MediaProvider.onFileOpenForFuse在里面调用checkAccess检查访问权限:

public FileOpenResult onFileOpenForFuse(String path, String ioPath, int uid, int tid,
        int transformsReason, boolean forWrite, boolean redact, boolean logTransformsMetrics) {
  ...
  try {
    ...
    checkAccess(fileUri, Bundle.EMPTY, file, forWrite);
    ...
  } catch (IllegalStateException | SecurityException e) {
      Log.e(TAG, "Permission to access file: " + path + " is denied");
      return new FileOpenResult(OsConstants.EACCES /* status */, originalUid,
              mediaCapabilitiesUid, new long[0]);
  } 
  ...
}

checkAccess最终最一堆的权限检查,如果没有符合的就抛出SecurityException异常:

private void checkAccess(@NonNull Uri uri, @NonNull Bundle extras, @NonNull File file,
        boolean isWrite) throws FileNotFoundException {
    enforceCallingPermission(uri, extras, isWrite);
    ...
}

private void enforceCallingPermission(@NonNull Uri uri, @NonNull Bundle extras,
        boolean forWrite) {
    ...
    enforceCallingPermissionInternal(uri, extras, forWrite);
    ...
}

private void enforceCallingPermissionInternal(@NonNull Uri uri, @NonNull Bundle extras,
            boolean forWrite) {
    ...
    if (checkCallingPermissionGlobal(uri, forWrite)) {
        // Access allowed, yay!
        return;
    }
    ...
    throw new SecurityException(getCallingPackageOrSelf() + " has no access to " + uri);
}

其中checkCallingPermissionGlobal会检测android.permission.MANAGE_EXTERNAL_STORAGE权限,也就是文件管理器可以读取外部存储的所有公有文件的原理(例如Android/data/${包名}下的文件在前面的判断里面会跳出所以还是不能访问):

private boolean checkCallingPermissionGlobal(Uri uri, boolean forWrite) {
    ...
    // Apps that have permission to manage external storage can work with all files
    if (isCallingPackageManager()) {
        return true;
    }
    ...
}

开启文件管理器权限需要:

  1. 在AndroidManifest.xml声明android.permission.MANAGE_EXTERNAL_STORAGE权限
  2. 使用Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION启动设置页面让用户手动打开该应用的文件管理权限:
Intent intent = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
startActivity(intent);
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容