AndroidQ兼容性适配指南

Posted 静默加载

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了AndroidQ兼容性适配指南相关的知识,希望对你有一定的参考价值。

androidQ

Android 10 中的隐私权变更

隐私权变更受影响的应用缓解策略
分区存储 针对外部存储的过滤视图,可提供对特定于应用的文件和媒体集合的访问权限访问和共享外部存储中的文件的应用使用特定于应用的目录和媒体集合目录 了解详情
增强了用户对位置权限的控制力 仅限前台权限,可让用户更好地控制应用对设备位置信息的访问权限在后台时请求访问用户位置信息的应用确保在没有后台位置信息更新的情况下优雅降级 使用 Android 10 中引入的权限在后台获取位置信息 了解详情
系统执行后台 Activity 针对从后台启动 Activity 实施了限制不需要用户互动就启动 Activity 的应用使用通知触发的 Activity 了解详情
不可重置的硬件标识符 针对访问设备序列号和 IMEI 实施了限制访问设备序列号或 IMEI 的应用使用用户可以重置的标识符 了解详情
无线扫描权限 访问某些 WLAN、WLAN 感知和蓝牙扫描方法需要获得精确位置权限使用 WLAN API 和蓝牙 API 的应用针对相关使用场景请求 ACCESS_FINE_LOCATION 权限 了解详情

上面是官网的AndroidQ的隐私权变更链接,本文章只针对部分重大隐私权限变更做出解释说明。

从后台启动 Activity 的限制

创建高优先级通知

Android10中, 当App无前台显示的Activity时,其启动Activity会被系统拦截, 导致启动无效。
对此官方给予的折中方案是使用全屏Intent(full-screen intent), 既创建通知栏通知时, 加入full-screen intent 设置, 示例代码如下(基于官方文档修改):

		Intent fullScreenIntent = new Intent(this, CallActivity.class);
    PendingIntent fullScreenPendingIntent = PendingIntent.getActivity(this, 0,
            fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT);

    NotificationCompat.Builder notificationBuilder =
            new NotificationCompat.Builder(this, CHANNEL_ID)
        .setSmallIcon(R.drawable.notification_icon)
        .setContentTitle("Incoming call")
        .setContentText("(919) 555-1234")
        .setPriority(NotificationCompat.PRIORITY_HIGH)
        .setCategory(NotificationCompat.CATEGORY_CALL)

        // Use a full-screen intent only for the highest-priority alerts where you
        // have an associated activity that you would like to launch after the user
        // interacts with the notification. Also, if your app targets Android 10
        // or higher, you need to request the USE_FULL_SCREEN_INTENT permission in
        // order for the platform to invoke this notification.
        .setFullScreenIntent(fullScreenPendingIntent, true);

    Notification incomingCallNotification = notificationBuilder.build();

注意:在Target SDk为29及以上时,需要在AndroidManifest上增加USE_FULL_SCREEN_INTENT申明

//AndroidManifest中
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />

当手机处于亮屏状态时, 会显示一个通知栏, 当手机处于锁屏或者灭屏状态时,会亮屏并直接进入到CallActivity中。

不可重置的设备标识符实施了限制

从 Android 10 开始,应用必须具有 READ_PRIVILEGED_PHONE_STATE 特许权限才能访问设备的不可重置标识符(包含 IMEI 和序列号)。

受影响的方法包括:

ANDROID_ID 生成规则:签名+设备信息+设备用户
ANDROID_ID 重置规则:设备恢复出厂设置时,ANDROID_ID 将被重置

当前获取设备唯一ID的方式为使用ANDROID_ID, 若获取为空的话则使用UUID.randomUUID().toString()获得一个随机ID并存储起来, 该ID保证唯一, 但App卸载重装之后就会改变。

String id = android.provider.Settings.Secure.getString(context.getContentResolver(), android.provider.Settings.Secure.ANDROID_ID);

限制了对剪贴板数据的访问权限

除非您的应用是默认输入法 (IME) 或是目前处于焦点的应用,否则它无法访问 Android 10 或更高版本平台上的剪贴板数据。

因为都是应用处于前台的时候进行剪贴板数据的获取,对于大部分业务不受影响。

定位权限

Android Q引入了新的位置权限ACCESS_BACKGROUND_LOCATION,该权限仅会影响应用在后台运行时对位置信息的访问权。如果应用targetSDK<=P,请求了ACCESS_FINE_LOCATIONACCESS_COARSE_LOCATION权限,AndroidQ设备会自动帮你申请ACCESS_BACKGROUND_LOCATION权限。
如果应用以 Android 10或更高版本为目标平台,则您必须在应用的清单文件中声明ACCESS_BACKGROUND_LOCATION权限并接收用户权限,才能在应用位于后台时接收定期位置信息更新。
以下代码段展示了如何在应用中请求在后台访问位置信息:

    <manifest ... >
        <!--允许获得精确的GPS定位-->
        <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
        <!--允许获得粗略的基站网络定位-->
        <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
        <!-- 兼容10.0系统,允许App在后台获得位置信息 -->
        <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
    </manifest>

以下代码段中显示了定位权限检查逻辑的示例:

    boolean permissionAccessCoarseLocationApproved =
        ActivityCompat.checkSelfPermission(this, permission.ACCESS_COARSE_LOCATION)
            == PackageManager.PERMISSION_GRANTED;
 
    if (permissionAccessCoarseLocationApproved) 
       boolean backgroundLocationPermissionApproved =
               ActivityCompat.checkSelfPermission(this,
                   permission.ACCESS_BACKGROUND_LOCATION)
                   == PackageManager.PERMISSION_GRANTED;
 
       if (backgroundLocationPermissionApproved) 
           // App can access location both in the foreground and in the background.
           // Start your service that doesn't have a foreground service type
           // defined.
        else 
           // App can only access location in the foreground. Display a dialog
           // warning the user that your app must have all-the-time access to
           // location in order to function properly. Then, request background
           // location.
           ActivityCompat.requestPermissions(this, new String[] 
               Manifest.permission.ACCESS_BACKGROUND_LOCATION,
               your-permission-request-code);
       
     else 
       // App doesn't have access to the device's location at all. Make full request
       // for permission.
       ActivityCompat.requestPermissions(this, new String[] 
            Manifest.permission.ACCESS_COARSE_LOCATION,
            Manifest.permission.ACCESS_BACKGROUND_LOCATION
            ,
            your-permission-request-code);
    

如果您的应用通常需要在被置于后台后(如当用户按设备上的主屏幕按钮或关闭设备的显示屏时)访问设备的位置信息。

要在这种特定类型的用例中保留对设备位置信息的访问权,请启动您已在应用的清单中声明前台服务类型为 “location” 的前台服务:

    <service
        android:name="MyNavigationService"
        android:foregroundServiceType="location" ... >
        ...
    </service>

在启动该前台服务之前,请确保您的应用仍可访问设备的位置信息:

    boolean permissionAccessCoarseLocationApproved =
        ActivityCompat.checkSelfPermission(this,
            permission.ACCESS_COARSE_LOCATION) ==
            PackageManager.PERMISSION_GRANTED;
    if (permissionAccessCoarseLocationApproved) 
        // App has permission to access location in the foreground. Start your
        // foreground service that has a foreground service type of "location".
     else 
       // Make a request for foreground-only location access.
       ActivityCompat.requestPermissions(this, new String[] 
            Manifest.permission.ACCESS_COARSE_LOCATION,
           your-permission-request-code);
    

分区存储

为了让用户更好地控制自己的文件,并限制文件混乱的情况,Android Q修改了APP访问外部存储中文件的方法。外部存储的新特性被称为Scoped Storage

Android Q仍然使用READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE作为面向用户的存储相关运行时权限,但现在即使获取了这些权限,访问外部存储也受到了限制。

APP需要这些运行时权限的情景发生了变化,且各种情况下外部存储对APP的可见性也发生了变化。

Scoped Storage新特性中,外部存储空间被分为两部分:

● 公共目录:DownloadsDocumentsPicturesDCIMMoviesMusicRingtones

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

APP可以通过SAF(System Access Framework)MediaStore接口访问其中的文件。

App-specific目录:存储应用私有数据,外部存储应用私有目录对应 Android/data/packagename,内部存储应用私有目录对应 data/data/packagename;

APP卸载后,数据会清除。

APP的私密目录,APP访问自己的App-specific目录时无需任何权限。

存储空间视图模式

Android Q规定了APP有两种外部存储空间视图模式:Legacy ViewFiltered View

Filtered View:App可以直接访问App-specific目录,但不能直接访问App-specific外的文件。访问公共目录或其他APP的App-specific目录,只能通过MediaStoreSAF、或者其他APP 提供的ContentProviderFileProvider等访问。

Legacy View: 兼容模式。与Android Q以前一样,申请权限后App可访问外部存储,拥有完整的访问权限

requestLegacyExternalStorage和preserveLegacyExternalStorage

requestLegacyExternalStorage 是Anroid10引入的,如果你进行适配Android 10之后,应用通过升级安装,那么还会使用以前的储存模式Legacy View,只有通过首次安装或是卸载重新安装才能启用新模式Filtered View

android:requestLegacyExternalStorage="true"让适配了Android10的app新安装在Android 10系统上也继续访问旧的存储模型。

Environment.isExternalStorageLegacy();//存储是否为兼容模式

在适配Android11的时候requestLegacyExternalStorage 标签会在Android11以上的设备上被忽略,preserveLegacyExternalStorage只是让覆盖安装的app能继续使用旧的存储模型,如果之前是旧的存储模型的话。

  • Android10适配的时候可以通过requestLegacyExternalStoragec使用兼容模式;
  • Android11适配可以通过preserveLegacyExternalStorage让Android10及一下的设备使用兼容模式,但Android11及以上的设备无论是覆盖安装还是重新安装都无法使用兼容模式;

可以通过调用 Environment.getExternalStorageState() 查询该卷的状态。如果返回的状态为 MEDIA_MOUNTED,那么您就可以在外部存储空间中读取和写入应用专属文件。如果返回的状态为 MEDIA_MOUNTED_READ_ONLY,您只能读取这些文件。

分区存储的影响

图片位置信息

一些图片会包含位置信息,因为位置对于用户属于敏感信息, Android 10 应用在分区存储模式下图片位置信息默认获取不到,应用通过以下两项设置可以获取图片位置信息:

  • manifest 中申请 ACCESS_MEDIA_LOCATION;
  • 调用 MediaStore.setRequireOriginal(Uri uri)接口更新图片 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)
    

访问数据

私有目录:

应用私有目录文件访问方式与之前 Android 版本一致,可以通过 File path 获取资源。

共享目录:

共享目录文件需要通过 MediaStore API 或者 Storage Access Framework 方式访问。
MediaStore API 在共享目录指定目录下创建文件或者访问应用自己创建文件,不需要申请存储权限
MediaStore API 访问其他应用在共享目录创建的媒体文件(图片、音频、视频), 需要申请存储权限,未申请存储权限,通过 ContentResolver 查询不到文件 Uri,即使通过其他方式获取到文件 Uri,读取或创建文件会抛出异常;
MediaStore API 不能够访问其他应用创建的非媒体文件(pdf、office、doc、txt 等), 只能够通过 Storage Access Framework 方式访问;

File路径访问受影响接口

FileOutputStreamFileInputStream

在分区存储模型下,SD卡的公共目录是不让访问的,除了共享媒体的那几个文件夹。所以,用一个公共目录的路径实例化FileOutputStream或者FileInputStream报FileNotFoundException异常。

W/System.err: java.io.FileNotFoundException: /storage/emulated/0/Log01-28-18-10.txt: open failed: EACCES (Permission denied)
W/System.err:     at libcore.io.IoBridge.open(IoBridge.java:496)
W/System.err:     at java.io.FileInputStream.<init>(FileInputStream.java:159)

File.createNewFile

W/System.err: java.io.IOException: Permission denied
W/System.err:     at java.io.UnixFileSystem.createFileExclusively0(Native Method)
W/System.err:     at java.io.UnixFileSystem.createFileExclusively(UnixFileSystem.java:317)
W/System.err:     at java.io.File.createNewFile(File.java:1008)
  • File.renameTo
  • File.delete
  • File.renameTo
  • File.mkdir
  • File.mkdirs

以上File的方法都返回false

BitmapFactory.decodeFile生成的Bitmapnull

适配指导

Android Q Scoped Storage新特性谷歌官方适配文档:https://developer.android.google.cn/preview/privacy/scoped-storage

适配指导如下,分为:访问APP自身App-specific目录文件、使用MediaStore访问公共目录、使用SAF 访问指定文件和目录、分享App-specific目录下文件和其他细节适配。

访问App-specific目录文件

无需任何权限,可以直接通过File的方式操作App-specific目录下的文件。

App-specific目录接口(所有存储设备)接口(Primary External Storage)
MediagetExternalMediaDirs()NA
ObbgetObbDirs()getObbDir()
CachegetExternalCacheDirs()getExternalCacheDir()
DatagetExternalFilesDirs(String type)getExternalFilesDir(String type)
/**
 * 在App-Specific目录下创建文件
 * 文件目录:/Android/data/包名/files/Documents/
 */
private fun createAppSpecificFile() 
    binding.createAppSpecificFileBtn.setOnClickListener 
        val documents = getExternalFilesDirs(Environment.DIRECTORY_DOCUMENTS)
        if (documents.isNotEmpty()) 
            val dir = documents[0]
            var os: FileOutputStream? = null
            try 
                val newFile = File(dir.absolutePath, "MyDocument")
                os = FileOutputStream(newFile)
                os.write("create a file".toByteArray(Charsets.UTF_8))
                os.flush()
                Log.d(TAG, "创建成功")
                dir.listFiles()?.forEach  file: File? ->
                    if (file != null) 
                        Log.d(TAG, "Documents 目录下的文件名:" + file.name)
                    
                
             catch (e: IOException) 
                e.printStackTrace()
                Log.d(TAG, "创建失败")

             finally 
                closeIO(os)
            

        
    

/**
 * 在App-Specific目录下创建文件夹
 * 文件目录:/Android/data/包名/files/
 */
private fun createAppSpecificFolder() 
    binding.createAppSpecificFolderBtn.setOnClickListener 
        getExternalFilesDir("apk")?.let 
            if (it.exists()) 
                Log.d(TAG, "创建成功")
             else 
                Log.d(TAG, "创建失败")
            
        
    

使用MediaStore访问公共目录

MediaStore Uri和路径对应表

MediaStore提供下列Uri,可以用MediaProvider查询对应的Uri数据。在AndroidQ上,所有的外部存储设备都会被命令,即Volume Name。MediaStore可以通过Volume Name 获取对应的Uri。

MediaStore.getExternalVolumeNames(this).forEach  volumeName ->
  Log.d(TAG, "uri:$MediaStore.Images.Media.getContentUri(volumeName)")

Uri路径格式: content:// media/<volumeName>/<Uri路径>

使用MediaStore创建文件

通过ContentResolver的insert方法,将多媒体文件保存在公共集合目录,不同的Uri对应不同的公共目录,详见3.2.1;其中RELATIVE_PATH的一级目录必须是Uri对应的一级目录,二级目录或者二级以上的目录,可以随意的创建和指定。

private lateinit var createBitmapForActivityResult: ActivityResultLauncher<String>
//注册ActivityResultLauncher
createBitmapForActivityResult =
    registerForActivityResult(ActivityResultContracts.RequestPermission()) 
        createBitmap()
    
binding.createFileByMediaStoreBtn.setOnClickListener 
    createBitmapForActivityResult.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)

private fun createBitmap() 
    val values = ContentValues()
    val displayName = "NewImage.png"
    values.put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
    values.put(MediaStore.Images.Media.DESCRIPTION, "This is an image")
    values.put(MediaStore.Images.Media.MIME_TYPE, "image/png")
    values.put(MediaStore.Images.Media.TITLE, "Image.png")
    //适配AndroidQ及一下
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) 
        values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/sl")
     else 
        values.put(
            MediaStore.MediaColumns.DATA,
            "$Environment.getExternalStorageDirectory().path/$Environment.DIRECTORY_DCIM/$displayName"
        )
    
    //requires android.permission.WRITE_EXTERNAL_STORAGE, or grantUriPermission()
    val external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
    //java.lang.UnsupportedOperationException: Writing to internal storage is not supported.
    //val external = MediaStore.Images.Media.INTERNAL_CONTENT_URI
    val insertUri = contentResolver.insert(external, values)
    var os: OutputStream? = null
    try 
        if (insertUri != null) 
            os = contentResolver.openOutputStream(insertUri)
        
        if (os != null) 
            val bitmap = Bitmap.createBitmap(400, 400, Bitmap.Config.ARGB_8888)
            //创建了一个红色的图片
            val canvas = Canvas(bitmap)
            canvas.drawColor(Color.RED)
            bitmap.compress(Bitmap.CompressFormat.PNG, 90, os)
            Log.d(TAG, "创建Bitmap成功")
            if (insertUri != null) 
                values.clear()
                //适配AndroidQ及一下
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) 
                    values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/sl2")
                 else 
                    values.put(
                        MediaStore.MediaColumns.DATA,
                        "$Environment.getExternalStorageDirectory().path/$Environment.DIRECTORY_DCIM/$displayName"
                    )
                
                contentResolver.update以上是关于AndroidQ兼容性适配指南的主要内容,如果未能解决你的问题,请参考以下文章

AndroidQ兼容性适配指南

Android安卓Q适配指南-相册

AndroidQ R的适配-分区存储的适配

AndroidQ R的适配-分区存储的适配

AndroidQ及以上的适配-分区存储的适配

AndroidR兼容性适配指南