AndroidQ兼容性适配指南
Posted 静默加载
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了AndroidQ兼容性适配指南相关的知识,希望对你有一定的参考价值。
androidQ
隐私权变更 | 受影响的应用 | 缓解策略 | |
---|---|---|---|
✅ | 分区存储 针对外部存储的过滤视图,可提供对特定于应用的文件和媒体集合的访问权限 | 访问和共享外部存储中的文件的应用 | 使用特定于应用的目录和媒体集合目录 了解详情 |
✅ | 增强了用户对位置权限的控制力 仅限前台权限,可让用户更好地控制应用对设备位置信息的访问权限 | 在后台时请求访问用户位置信息的应用 | 确保在没有后台位置信息更新的情况下优雅降级 使用 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 和序列号)。
受影响的方法包括:
-
Build
-
TelephonyManager
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_LOCATION
或ACCESS_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_STORAGE
和WRITE_EXTERNAL_STORAGE
作为面向用户的存储相关运行时权限,但现在即使获取了这些权限,访问外部存储也受到了限制。
APP需要这些运行时权限的情景发生了变化,且各种情况下外部存储对APP的可见性也发生了变化。
在Scoped Storage
新特性中,外部存储空间被分为两部分:
● 公共目录:Downloads
、Documents
、Pictures
、DCIM
、Movies
、Music
、Ringtones
等
公共目录下的文件在APP卸载后,不会删除。
APP可以通过SAF(System Access Framework)
、MediaStore
接口访问其中的文件。
● App-specific
目录:存储应用私有数据,外部存储应用私有目录对应 Android/data/packagename,内部存储应用私有目录对应 data/data/packagename;
APP卸载后,数据会清除。
APP的私密目录,APP访问自己的App-specific
目录时无需任何权限。
存储空间视图模式
Android Q规定了APP有两种外部存储空间视图模式:Legacy View
、Filtered View
。
● Filtered View
:App可以直接访问App-specific
目录,但不能直接访问App-specific
外的文件。访问公共目录或其他APP的App-specific
目录,只能通过MediaStore
、SAF
、或者其他APP 提供的ContentProvider
、FileProvider
等访问。
● 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路径访问受影响接口
FileOutputStream
和FileInputStream
在分区存储模型下,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
生成的Bitmap
为null
。
适配指导
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) |
---|---|---|
Media | getExternalMediaDirs() | NA |
Obb | getObbDirs() | getObbDir() |
Cache | getExternalCacheDirs() | getExternalCacheDir() |
Data | getExternalFilesDirs(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兼容性适配指南的主要内容,如果未能解决你的问题,请参考以下文章