一年一度的 googleIO 开完以后,不出意外的 Android10.0 系统(AndroidQ)出来了

隐私配置又㕛叒叕更新了..

连接地址: https://developer.android.com/preview/privacy 这个地址可能在来年就变成 android 11 的了, 所以仅保证在2019 年 05 月 20 日以及之后的一段时间内有效

主要包含以下五大项目

20190520100845.png

对于大部分应用来说,储存方式的更新会有所影响, 其他的可能都是 SDK 要做的事情,和普通开发者关系不大

在适配新的系统前就和从前一样,暂时不升级 targetVersion,把 targetVersion 设置为 28 以下就不会影响旧程序了,就如同以前运行时权限真的是坑到爆炸,但是暂时不适配也是可以的

但是未来无论如何都需要适配新系统,所以先来看看

本篇只说储存的方式和权限问题,其他暂时略过不表

目录

检查迁移情况

https://developer.android.com/preview/privacy/checklist 有一个表格提供了如何检查和迁移的方案

20190520101927.png

开发环境

说一下开发环境

MacOS
Android Studio 3.4.0
android 9.0 设备一台
androidQ 虚拟机一台(官方的 Emulator)

更新 sdk

更新 sdk, 下载 androidQ 相关的 sdk/sdktools/模拟器

androidQ build.gradle

当前想尝试 androidQ 的话, 编译版本和目标版本号需要按如下方式设置, 今后的话可能是 2930 之类的数字

targetSdkVersion = 'Q'
compileSdkVersion = 'android-Q'

外部储存

androidQ 下读写文件

权限和以前一样

    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

Activity 文件

package top.kikt.camerax.scopedstorage

import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import java.io.File

class MainActivity : AppCompatActivity() {

    companion object {
        private const val TAG = "MainActivity"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val path = externalCacheDir?.absoluteFile?.path

        Log.d(TAG, "cache dir = $path")

        val file = File(path, "abc.txt")

        file.writeText("我要往里写数据")

        val text = file.readText()

        Log.d(TAG, "数据是: $text")
    }
}

2019-05-20 11:41:28.938 7435-7435/top.kikt.camerax.scopedstorage D/MainActivity: cache dir = /storage/emulated/0/Android/data/top.kikt.camerax.scopedstorage/cache
2019-05-20 11:41:28.957 7435-7435/top.kikt.camerax.scopedstorage D/MainActivity: 数据是: 我要往里写数据

这里可以看出, 在正常情况下, 将文件存入外部储存比从前方便了, 不再需要动态权限申请

并且,我这里的小米手机没有弹出敏感权限的那个对话框

外部储存的”沙箱”

从前只要用户允许 app 访问外置储存后, 就可以通过 MediaStore 的 api 拿到图片和相册的完整数据

而 androidQ 中对于这部分权限进行了重新处理

按照文档的说法, 目前外部储存中是沙箱的模式, 除非你的目标文件属于以下三类, 否则其他应用将无法看到你的图片

Photos, which are stored in MediaStore.Images.
Videos, which are stored in MediaStore.Video.
Music files, which are stored in MediaStore.Audio.

20190520130327.png

想要访问非自己 app 的特定的文件夹, 比如 downloads, 你需要使用Storage Access Framework, 这是一个 android 4.4 加入的 api, 官方有中文说明, 网上应该也有很多示例代码, 这里不展开了

有一点需要注意: 当 app 被卸载后, 位于外部储存中的 app 数据会被清除, 如果你需要保留数据, 必须保存到 MediaStore 中

因为是按顺序浏览文档说明, 我看到了如下的说明:

20190520131322.png

访问自己 app 位于外部储存中的内容不需要访问权限, 所以这里我注释掉清单文件中的权限, 发现表现和之前一样, 可以写入和读取文件, 这也就解释了为什么不会弹出敏感权限申请, 因为在 androidQ 中, 访问自己 app 的文件不再是敏感权限

相册图片

完整的代码如下, 使用了 RxJava+RxPermission 做权限的申请

package top.kikt.camerax.scopedstorage

import android.Manifest
import android.database.Cursor
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.util.Log
import android.util.Size
import androidx.appcompat.app.AppCompatActivity
import androidx.core.database.getIntOrNull
import com.tbruyelle.rxpermissions2.RxPermissions
import kotlinx.android.synthetic.main.activity_media_scan.*

class MediaScanActivity : AppCompatActivity() {

    private val rxPermissions = RxPermissions(this)

    private val TAG = "MediaScanActivity"

    private val storeImageKeys = arrayOf(
        MediaStore.Images.Media.DISPLAY_NAME, // 显示的名字
        MediaStore.Images.Media.DATA, // 数据
        MediaStore.Images.Media.LONGITUDE, // 经度
        MediaStore.Images.Media._ID, // id
        MediaStore.Images.Media.MINI_THUMB_MAGIC, // id
        MediaStore.Images.Media.TITLE, // id
        MediaStore.Images.Media.BUCKET_ID, // dir id 目录
        MediaStore.Images.Media.BUCKET_DISPLAY_NAME, // dir name 目录名字
//        MediaStore.Images.Media.EXTERNAL_CONTENT_URI, // dir name 目录名字
        MediaStore.Images.Media.WIDTH, // 宽
        MediaStore.Images.Media.HEIGHT, // 高
        MediaStore.Images.Media.DATE_TAKEN //日期
    )

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_media_scan)

        bt_scan.setOnClickListener {
            Log.d(TAG, "准备申请权限")
            rxPermissions.request(
                Manifest.permission.WRITE_EXTERNAL_STORAGE,
                Manifest.permission.READ_EXTERNAL_STORAGE
            )
                .subscribe {
                    if (it) {
                        Log.d(TAG, "申请权限成功")
                        scan()
                    } else {
                        Log.d(TAG, "申请失败")
                    }
                }
        }
    }

    private fun scan() {
        val cursor = contentResolver.query(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            storeImageKeys,
            null,
            null,
            MediaStore.Images.Media.DATE_TAKEN
        )

        cursor?.apply {
            val count = this.count
            Log.d(TAG, "scan count is $count")
            while (this.moveToNext()) {
                val date = this.getString(MediaStore.Images.Media.DATA)
                Log.d(TAG, "path : $date")
//                contentResolver.loadThumbnail()
                val width = this.getInteger(MediaStore.Images.Media.WIDTH) ?: 1024
                val height = this.getInteger(MediaStore.Images.Media.HEIGHT) ?: 1024
                Log.d(TAG, "width : $width")
                Log.d(TAG, "height : $height")

                var photoUri = Uri.withAppendedPath(
                    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                    cursor.getString(MediaStore.Images.Media._ID)
                )

                Log.d(TAG, "version int = ${Build.VERSION.SDK_INT}")

//                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    photoUri = MediaStore.setRequireOriginal(photoUri)
//                    val stream = contentResolver.openInputStream(photoUri)
                    val bitmap = contentResolver.loadThumbnail(photoUri, Size(width, height), null)
                    iv_preview.setImageBitmap(bitmap)
//                }
            }
        }

        cursor?.close()
    }

    private fun Cursor.getString(columnName: String): String? {
        val columnIndex = getColumnIndex(columnName)
        if (columnIndex == -1) {
            return null
        }
        return this.getString(columnIndex)
    }

    private fun Cursor.getInteger(columnName: String): Int? {
        val columnIndex = getColumnIndex(columnName)
        if (columnIndex == -1) {
            return null
        }
        return this.getIntOrNull(columnIndex)
    }

    data class ImageEntity(val width: Int, val height: Int, val bitmap: Bitmap) {
        fun dispose() {
            bitmap.recycle()
        }
    }
}

布局在这里

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MediaScanActivity">

    <Button
            android:text="扫描"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" tools:layout_editor_absoluteY="16dp"
            tools:layout_editor_absoluteX="16dp" android:id="@+id/bt_scan"/>
    <ImageView
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:id="@+id/iv_preview" app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="8dp"
            app:layout_constraintBottom_toBottomOf="parent" android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/bt_scan" android:layout_marginEnd="8dp"
            android:layout_marginBottom="8dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>

点击扫描后能看见这个图片 20190520155614.png

相册里只有这一张图

20190520155645.png

核心代码如下:

    val cursor = contentResolver.query(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        storeImageKeys,
        null,
        null,
        MediaStore.Images.Media.DATE_TAKEN
    )

    cursor?.apply {
        val count = this.count
        Log.d(TAG, "scan count is $count")
        while (this.moveToNext()) {
            val date = this.getString(MediaStore.Images.Media.DATA)
            Log.d(TAG, "path : $date")
//                contentResolver.loadThumbnail()
            val width = this.getInteger(MediaStore.Images.Media.WIDTH) ?: 1024
            val height = this.getInteger(MediaStore.Images.Media.HEIGHT) ?: 1024
            Log.d(TAG, "width : $width")
            Log.d(TAG, "height : $height")

            var photoUri = Uri.withAppendedPath(
                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                cursor.getString(MediaStore.Images.Media._ID)
            )

            Log.d(TAG, "version int = ${Build.VERSION.SDK_INT}")

//                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // 这里注释的原因是因为模拟器中获取的是28, 而SDK中定义是10000, 这样写这个代码跑不起来
                photoUri = MediaStore.setRequireOriginal(photoUri)
//                    val stream = contentResolver.openInputStream(photoUri)
                val bitmap = contentResolver.loadThumbnail(photoUri, Size(width, height), null)
                iv_preview.setImageBitmap(bitmap)
//                }
        }
    }

    cursor?.close()

以前版本直接用 Data 就可以拿到图片对应的 File path

而 android-Q 以后,不能再这样做了,需要通过 id 来”组装”出一个 Uri, 然后通过 contentResolver.loadThumbnail()来获取缩略图

    var photoUri = Uri.withAppendedPath(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        cursor.getString(MediaStore.Images.Media._ID)
    )

    Log.d(TAG, "version int = ${Build.VERSION.SDK_INT}")

//                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    photoUri = MediaStore.setRequireOriginal(photoUri)
//                    val stream = contentResolver.openInputStream(photoUri)
    val bitmap = contentResolver.loadThumbnail(photoUri, Size(width, height), null)
    iv_preview.setImageBitmap(bitmap)

需要用 jni 处理图片

有的时候,你需要使用 ndk/jni 来处理文件,这个时候,可以用 openFileDescriptor 来完成这个事情

val fileOpenMode = "r"
val parcelFd = resolver.openFileDescriptor(photoUri, fileOpenMode)
val fd = parcelFd?.detachFd()

修改其他项目创建的文件

需要捕获RecoverableSecurityException异常,并请求用户允许修改

官方原文如下:

Update other apps’ media files Note: Expect the following behavior to take effect in a future beta release of Android Q. To modify a given media file that another app originally saved to an external storage device, catch the RecoverableSecurityException that the platform throws. You can then request that the user grant your app write access to that specific item.

照片的位置信息

一些照片在 Exif 中包含位置的敏感信息, 以前的话可以直接通过 MediaStore 的 query 方法从数据库中查到, 现在就不再可以了

如果需要这些元数据,需要请求用户同意, 需要ACCESS_MEDIA_LOCATION权限, 这个是一个 android-Q 的新权限

并调用setRequireOriginal()方法来获取对应的 Uri

官方给的示例如下:

// Get location data from the ExifInterface class.
val photoUri = MediaStore.setRequireOriginal(photoUri)
contentResolver.openInputStream(photoUri).use { stream ->
    ExifInterface(stream).run {
        // If lat/long is null, fall back to the coordinates (0, 0).
        val latLong = ?: doubleArrayOf(0.0, 0.0)
    }
}

可是我这里有报错的地方,我修改如下:

contentResolver.openInputStream(photoUri).use { stream ->
    ExifInterface(stream).run {
        val floatArrayOf = floatArrayOf(0f, 0f)
        val latLongResult = this.getLatLong(floatArrayOf)
        Log.d(TAG, "latLng request $latLongResult latlng = ${floatArrayOf.toList()}")
    }
}

在加入权限的请求后我能够得到一个日志, 告诉我照片的经纬度

2019-05-20 17:02:01.436 19236-19236/top.kikt.camerax.scopedstorage D/MediaScanActivity: latLng request true latlng = [39.841603, 116.317417],

sdcard 目录结构

系统自带的 files 无法完整的看到目录结构, 我考虑使用 adb shell 命令来查看

generic_x86:/sdcard $ ls
Alarms  DCIM     Movies Notifications Podcasts
Android Download Music  Pictures      Ringtones
generic_x86:/sdcard $ cd DCIM
generic_x86:/sdcard/DCIM $ ls
Camera
generic_x86:/sdcard/DCIM $ ls Camera/
IMG_20190518_232111.jpg IMG_20190520_140142.jpg
generic_x86:/sdcard/DCIM $
cd /sdcard/Android # 这一步应该是常规的数据
ls data/
com.android.calllogbackup         com.android.phone                    com.android.service.ims.presence      com.google.android.packageinstaller
com.android.camera2               com.android.printspooler             com.android.smspush                   com.google.android.partnersetup
com.android.carrierconfig         com.android.providers.blockednumber  com.android.stk                       com.google.android.sdksetup
com.android.cellbroadcastreceiver com.android.providers.calendar       com.android.vending                   com.google.android.setupwizard
com.android.chrome                com.android.providers.contacts       com.google.android.apps.docs          com.google.android.storagemanager
com.android.managedprovisioning   com.android.providers.telephony      com.google.android.apps.maps          com.google.android.videos
com.android.mms.service           com.android.providers.userdictionary com.google.android.gms                com.google.android.webview
com.android.nfc                   com.android.se                       com.google.android.gsf                com.google.android.youtube
com.android.ons                   com.android.service.ims              com.google.android.onetimeinitializer top.kikt.camerax.scopedstorage
130|generic_x86:/sdcard/Android $ ls /sdcard/Android/data/top.kikt.camerax.scopedstorage/
cache
generic_x86:/sdcard/Android $ ls /sdcard/Android/data/top.kikt.camerax.scopedstorage/cache/
abc.txt # 这个是代码第一步创建的那个文件
generic_x86:/sdcard/Android $ ls /sdcard/Android/media
com.android.calllogbackup         com.android.phone                    com.android.service.ims          com.google.android.onetimeinitializer
com.android.carrierconfig         com.android.printspooler             com.android.service.ims.presence com.google.android.packageinstaller
com.android.cellbroadcastreceiver com.android.providers.blockednumber  com.android.smspush              com.google.android.partnersetup
com.android.chrome                com.android.providers.calendar       com.android.stk                  com.google.android.sdksetup
com.android.managedprovisioning   com.android.providers.contacts       com.android.vending              com.google.android.setupwizard
com.android.mms.service           com.android.providers.telephony      com.google.android.apps.maps     com.google.android.storagemanager
com.android.nfc                   com.android.providers.userdictionary com.google.android.gms           com.google.android.webview
com.android.ons                   com.android.se                       com.google.android.gsf           top.kikt.camerax.scopedstorage

这一步的话,应该是创建可以在卸载 app 后依然会保留的 media 部分

基本和前面说的沙箱,本应用的 media, 系统相册等进行了一一对应

后记

代码在这

以上