最近刚刚做了Android 14的适配(即targetSdkVersion 升级到 34 ),通过此博客整理下相关注意点。
前台服务类型
当targetSdkVersion >= 34 ,应用内的前台服务(Foreground Service)需要指定至少一种前台服务类型。
在Android系统中,前台服务(Foreground Service)是一种特殊的服务,它可以在后台执行操作,同时向用户显示一个通知,这表示该服务正在执行任务。像音乐播放器、健身程序、定位程序,通过前台服务显示状态栏通知,让用户知道APP执行任务并且正在消耗系统资源。。Android10 引入了前台服务类型。(android:foregroundServiceType 属性)。
Android 8 (targetSdkVersion 28) 以后要用startForegroundService来启动"前台服务"。
private fun startLocationService() {
val intent = Intent(this, LocationService::class.java)
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
Android 9及以后前台服务要在manifest中配置以下权限
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
前台应用启动服务后,要在service里面通过startForeground方法向通知栏发送一个通知:
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = NotificationCompat.Builder(this, "channel_id").setContentTitle("前台服务").setContentText("前台服务正在运行中......").setSmallIcon(
R.drawable.ic_launcher_background).build()
startForeground(1, notification)
return super.onStartCommand(intent, flags, startId)
}
<service
android:name=".service.LocationService"
android:foregroundServiceType="camera"
android:enabled="true"
android:exported="true"></service>
常用的前台服务类型有:
camera
connectedDevice
dataSync
health
location
mediaPlayback
mediaProjection
microphone
phoneCall
remoteMessaging
shortService
specialUse
systemExempted
注意:如果您调用 startForeground() 但未声明适当的前台服务类型权限,系统会抛出 SecurityException。
安全相关
限制隐式Intent和PendingIntent
当targetSdk为34时,通过隐式Intent或隐式Intent创建的PendingIntent只能打开设置了android:exported="true"的组件,如果android:exported属性值为false,系统会抛出异常。(即应用必须使用明确的intent来打开(android:exported="false")的组件)通过上述限制可防止恶意应用拦截只供给用内部组件使用的隐式 intent。
如果应用创建一个PendingIntent ,但 intent 未指定组件或包,系统现在会抛出异常。
<activity
android:name=".NormalIntentMainActivity"
android:exported="false">
<intent-filter>
<action android:name="com.transsion.targetsdk34android.APP_ACTION"/>
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
错误写法:(注意上面Activity的exported属性是false)
startActivity(Intent("com.transsion.targetsdk34android.APP_ACTION"))
报错信息如下:
E FATAL EXCEPTION: main
Process: com.transsion.targetsdk34android, PID: 6361
android.content.ActivityNotFoundException: No Activity found to handle Intent { act=com.transsion.targetsdk34android.APP_ACTION }
正确写法:(注意上面Activity的exported属性是false)
val intent = Intent(MainActivity2@ this, NormalIntentMainActivity::class.java)
startActivity(intent)
运行时动态注册广播接收器必须指定导出行为
在 Android 14 上,运行时通过 Context的registerReceiver() 动态注册广播接收器,需要设置标记 RECEIVER_EXPORTED 或 RECEIVER_NOT_EXPORTED ,标识是否导出该广播,避免应用程序出现安全漏洞,如果注册的是系统广播,则不需要指定标记。(如果应用仅通过 Context的registerReceiver 方法为系统广播注册接收器时,那么它可以不在注册接收器时指定标志。)
选择广播接收器是否应导出并对设备上的其他应用可见。如果此接收器正在监听从系统或其他应用(甚至是您拥有的其他应用)发送的广播,请使用 RECEIVER_EXPORTED 标志。如果此接收器仅监听应用发送的广播,请使用 RECEIVER_NOT_EXPORTED 标志。
一些系统广播来自具有较高特权 这类应用(例如蓝牙和电话应用), 但不在系统的唯一进程 ID (UID) 下运行。接收者 接收所有系统广播,包括来自具有较高特权的广播 应用,请使用 RECEIVER_EXPORTED 标记您的接收器。
如果您使用 RECEIVER_NOT_EXPORTED 标记接收器, 接收器可以接收来自您的服务器 应用,但不从具有较高特权的应用进行广播。
如果您的应用要监听多个广播,但只应监听部分广播 已将RECEIVER_NOT_EXPORTED和部分标记为 RECEIVER_EXPORTED,将广播划分到不同的 广播接收器。
val listenToBroadcastsFromOtherApps = false
val receiverFlags = if (listenToBroadcastsFromOtherApps) {
ContextCompat.RECEIVER_EXPORTED
} else {
ContextCompat.RECEIVER_NOT_EXPORTED
}
注意 :如果导出广播接收器,其他应用可能会向您的应用发送不受保护的广播。
调用 registerReceiver() 注册接收器:
ContextCompat.registerReceiver(context, br, filter, receiverFlags)
安全的动态代码加载
如果应用以 Android 14 为目标平台并使用动态代码加载 (DCL),则所有动态加载的文件都必须标记为只读,否则,系统会抛出异常。
我们建议应用尽可能避免动态加载代码,因为这样做会大大增加应用因代码注入或代码篡改而受到危害的风险。
如果必须动态加载代码,请使用以下方法将动态加载的文件(例如 DEX、JAR 或 APK 文件)在文件打开后和写入任何内容之前立即设置为只读:
val jar = File("DYNAMICALLY_LOADED_FILE.jar")
val os = FileOutputStream(jar)
os.use {
// Set the file to read-only first to prevent race conditions
jar.setReadOnly()
// Then write the actual file content
}
val cl = PathClassLoader(jar, parentClassLoader)
处理已存在的动态加载文件
为防止系统对现有动态加载的文件抛出异常,我们建议您先删除并重新创建文件,然后再尝试在应用中重新动态加载这些文件。重新创建文件时,请按照上述指南在写入时将文件标记为只读。或者,您可以将现有文件重新标记为只读,但在这种情况下,我们强烈建议您先验证文件的完整性(例如,对照可信值检查文件的签名)以保护应用免遭恶意操作的影响。
针对从后台启动 activity 的其他限制
对于以 Android 14(API 级别 34)或更高版本为目标平台的应用,系统会进一步限制允许应用何时从后台启动 activity:
- 当应用使用 PendingIntent#send() 或类似方法发送
PendingIntent
时,如果它想要授予自己的后台 activity 启动待处理 intent 的启动特权,则必须选择启用。如需选择启用,应用应通过 setPendingIntentBackgroundActivityStartMode(MODE_BACKGROUND_ACTIVITY_START_ALLOWED) 传递ActivityOptions
软件包。(使用PendingIntent从后台打开Activity时,必须设置setPendingIntentBackgroundActivityStartMode(MODE_BACKGROUND_ACTIVITY_START_ALLOWED)。)
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
private fun startPendingActivity() {
val options = ActivityOptions.makeBasic().apply {
pendingIntentBackgroundActivityStartMode =
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
}
PendingIntent.getActivity(
this,
0,
Intent(this, NormalIntentMainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
options.toBundle()
)
}
- 当一个可见应用使用 bindService() 绑定另一个在后台运行的应用的服务时,如果该可见应用想要将其自己的后台 activity 启动权限授予绑定服务,则它现在必须选择加入 BIND_ALLOW_ACTIVITY_STARTS 标志。(即后台服务想要启动前台Activity)
private fun bindService() {
bindService(intent, serviceConnection, Context.BIND_ALLOW_ACTIVITY_STARTS)
}
这些更改扩展了现有的一组限制 ,通过防止恶意应用滥用 API 从后台启动破坏性活动来保护用户。
压缩路径遍历
对于以 Android 14(API 级别 34)或更高版本为目标平台的应用,Android 会通过以下方式防止 Zip 路径遍历漏洞:如果 zip 文件条目名称包含“..”或以“/”开头,则 ZipInputStream.getNextEntry() 会抛出 ZipException。ZipFile(String)
应用可以通过调用 dalvik.system.ZipPathValidator.clearCallback() 选择停用此验证。
OpenJDK 17
Android 14 将继续更新 Android 的核心库,以与最新 OpenJDK LTS 版本中的功能保持一致,包括适合应用和平台开发者的库更新和 Java 17 语言支持。
以下变更可能会影响应用兼容性:
- 对正则表达式的更改:现在,为了更严格地遵循 OpenJDK 的语义,不允许无效的组引用。您可能会看到 java.util.regex.Matcher 类抛出
IllegalArgumentException
的新情况,因此请务必测试应用中使用正则表达式的情形。如需在测试期间启用或停用此变更,请使用兼容性框架工具切换DISALLOW_INVALID_GROUP_REFERENCE
标志。 - UUID 处理:现在,验证输入参数时,java.util.UUID.fromString() 方法会执行更严格的检查,因此您可能会在反序列化期间看到
IllegalArgumentException
。如需在测试期间启用或停用此变更,请使用兼容性框架工具切换ENABLE_STRICT_VALIDATION
标志。 - ProGuard 问题:有时,在您尝试使用 ProGuard 缩减、混淆和优化应用时,添加 java.lang.ClassValue 类会导致问题。问题源自 Kotlin 库,该库会根据
Class.forName("java.lang.ClassValue")
是否会返回类更改运行时行为。如果您的应用是根据没有java.lang.ClassValue
类的旧版运行时开发的,则这些优化可能会将computeValue
方法从派生自java.lang.ClassValue
的类中移除。
应用只能杀死自己的后台进程
从 Anroid 14 开始,调用 ActivityManager的killBackgroundProcesses()
方法时,只能终止自己的应用程序,如果传入其他应用程序的包名,不会对其他应用程序产生影响。
public void killBackgroundProcesses (String packageName)
在运行Android 14或更高版本的设备上,第三方应用只能使用此API来终止自己的进程。
最低可安装目标 API 级别
从 Android 14 开始,targetSdkVersion 低于 23 的应用无法安装。要求应用满足这些最低目标 API 级别要求有助于提高用户的安全性和隐私性。
恶意软件通常会以较旧的 API 级别为目标平台,以绕过在较新版本 Android 中引入的安全和隐私保护机制。例如,有些恶意软件应用使用 targetSdkVersion
22,以避免受到 Android 6.0 Marshmallow(API 级别 23)在 2015 年引入的运行时权限模型的约束。这项 Android 14 变更使恶意软件更难以规避安全和隐私权方面的改进限制。尝试安装以较低 API 级别为目标平台的应用将导致安装失败,并且 Logcat 中会显示以下消息:
INSTALL_FAILED_DEPRECATED_SDK_VERSION: App package must target at least SDK version 23, but found 7
授予对照片和视频的部分访问权限 (从荣耀开发者平台搬运)
Android 14 引入了所选照片访问权限,可让用户授予应用对其库中特定图片和视频的访问权限,而不是授予对给定类型的所有媒体内容的访问权限。在 Android 13(targetSDK 33)中引入了图片或视频文件的访问权限时,在 Android 14 设备上与应用交互的用户现在可以授予对其照片或视频文件的部分访问权限,即用户可选择只授予应用访问本地媒体文件中的部分图片和视频,具体授予哪些文件由用户自己指定 ,其他文件则不允许应用访问,此变更有利于保护用户的隐私数据。
请求权限
首先,根据操作系统版本在 Android 清单中请求正确的存储权限:
<!-- Devices running Android 12L (API level 32) or lower -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<!-- Devices running Android 13 (API level 33) or higher -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!-- To handle the reselection within the app on devices running Android 14
or higher if your app targets Android 14 (API level 34) or higher. -->
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
U 版本(Android 14)在原有图片和视频的权限弹框 允许 和 不允许 的基础上,增加 选择照片和视频 的按钮,用户点击这个按钮后可以授予 App 访问部分图片和视频的权限。
新的授权对话框会展示如下的权限选项:
- 选择照片和视频:Android 14中的新特性,用户选择他们想要提供给应用的特定的照片和视频。
- 全部允许:用户授予对设备上所有照片和视频的完整访问权限。
- 不允许:用户拒绝所有访问。
如果用户选择了 选择照片和视频 选项,并且应用程序稍后再次请求 READ_MEDIA_IMAGES 或 READ_MEDIA_VIDEO 权限,系统将显示一个不同的对话框,含有 选择更多 的选项,让用户有机会访问其他照片和视频。
为了帮助应用程序支持新的更改,系统引入了新的权限:READ_MEDIA_VISUAL_USER_SELECTED。根据应用程序是否使用新权限,系统的行为会有所不同。
注意:
如果应用已使用了系统 照片选择器,则不需要再做适配修改。否则,可以考虑使用 照片选择器 。
兼容性影响
该特性只会对 targetSdk>=33 ,且声明使用 READ_MEDIA_VIDEO 和 READ_MEDIA_IMAGES 读取图片和视频文件的应用产生影响。
-
targetSdk>=33 并且在 Manifest 中声明使用 U 版本定义的 READ_MEDIA_VISUAL_USER_SELECTED 权限的应用表示App已主动适配该特性,此时应用应该已经按照 “适配指导” 部分的操作完成了特性的适配。
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
如果应用声明 READ_MEDIA_VISUAL_USER_SELECTED 权限,并且用户在系统权限对话框中选择 选择照片和视频 ,则会发生以下行为:
-
READ_MEDIA_IMAGES 和 READ_MEDIA_VIDEO 两个权限都会被系统拒绝。
-
应用会被授予 READ_MEDIA_VISUAL_USER_SELECTED 权限,向应用提供部分的、临时的访问用户照片和视频的权限。
-
如果应用需要访问其他照片或者视频(用户授权外的),必须再一次手动请求 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限(或者两个都申请)。
注意:
READ_MEDIA_IMAGES 和 READ_MEDIA_VIDEO 是访问用户的照片和视频照片库所需的唯一其他权限。声明 READ_MEDIA_VISUAL_USER_SELECTED 是让权限控制器知道您的应用程序支持手动重新请求选择更多照片和视频。
-
targetSdk>=33 未适配授予对照片和视频的部分访问权限的 App,U 版本会使用兼容性方案让用户也能在系统提供的 UX 中选择部分照片和视频。这时候应用的功能可能受到影响,如用户授权 App 读取部分照片和视频的权限后, 在 APP 中发送照片时可能会因为在 App 的照片选择器找不到需要的照片而产生疑惑(因为此时用户只给 App 授予了部分照片和视频的访问权限)。
如果应用不声明 READ_MEDIA_VISUAL_USER_SELECTED 权限,并且用户在系统权限对话框中选择 选择照片和视频 ,则会发生以下行为:
-
在应用程序会话期间授予 READ_MEDIA_IMAGES 和 READ_MEDIA_VIDEO 权限,提供临时授权,应用可以临时访问用户选择的照片和视频。当应用移动到后台,或者用户主动关闭应用时,系统最终会拒绝这些权限,此行为与其他一次性权限一样。
-
如果应用程序稍后需要访问其他照片和视频,则必须再次手动请求 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限,流程与最初的权限申请流程一样。
-
U 版本新增应用授予对照片和视频的部分访问权限的逻辑梳理如下:
适配指导
照片和视频的部分访问权限既可以用作读取图片权限(READ_MEDIA_IMAGES),也可以用作读取视频权限(READ_MEDIA_VIDEO)。下面以读取图片(READ_MEDIA_IMAGES)为示例进行说明。
注意:
以下内容只针对 targetSdk>=33 并且在 Manifest 中声明使用U版本定义的 READ_MEDIA_VISUAL_USER_SELECTED 权限,主动适配该特性的操作步骤。
应用第一次读取图片
用户交互旅程的关键适配代码:
-
首次读取图片前申请 READ_MEDIA_IMAGES 权限。
final int PERMISSION_REQUEST_CODE = 100;
......
context.requestPermissions(new String[]{"android.permission.READ_MEDIA_IMAGES"}, PERMISSION_REQUEST_CODE);
2.在 App 的图片选择器中判断当前获取的是所有文件权限还是读取部分图片和视频的权限。
if (context.checkSelfPermission("android.permission.READ_MEDIA_VISUAL_USER_SELECTED") == PackageManager.PERMISSION_GRANTED)
{
// 表示用户授予了App读取部分图片和视频的权限
// App从系统MediaStore或者公共存储中读取到的图片和视频是不全的,App只能看到用户在系统UX中授予的部分图片和视频
......
// App需要适配新流程
......
} else if (context.checkSelfPermission("android.permission.READ_MEDIA_IMAGES") == PackageManager.PERMISSION_GRANTED) {
// 表示用户授予了App读取所有图片和视频的权限
// App从系统MediaStore或者公共存储中读取到的所有图片和视频文件
// 这时候应用的处理逻辑和基础版本保持不变
......
} else {
// 表示用户拒绝了App读取所有图片和视频的权限
// 这时候应用的处理逻辑和基础版本保持不变
......
}
3.App 在自己的图片选择器中列出应用能够读取到的所有图片,供用户选择。
应用第二次读取图片
用户交互旅程的关键适配代码:
-
判断当前用户是否只授予了 App 读取部分图片和视频的权限。
if (context.checkSelfPermission("android.permission.READ_MEDIA_VISUAL_USER_SELECTED") == PackageManager.PERMISSION_GRANTED
&& context.checkSelfPermission("android.permission.READ_MEDIA_IMAGES") != PackageManager.PERMISSION_GRANTED ) {
// 表示用户授予了App读取部分图片和视频的权限
// App从系统MediaStore或者公共存储中读取到的图片和视频是不全的,App只能看到用户在系统UX中授予的部分图片和视频
// App需要适配新流程
......
}
2.在 App 的图片选择器中列出所有能够选择的图片(用户提供 App 的部分图片),并提供 添加可访问的照片 的能力,方便用户在需要的时候申请 App 读取更多图片的权限。
3.在 App 的 UX 上,用户选择 添加可访问的照片 时,请求 READ_MEDIA_IMAGES 权限,通过系统 UX 添加更多的提供 App 访问的图片。
final int PERMISSION_REQUEST_CODE = 100;
......
context.requestPermissions(new String[]{"android.permission.READ_MEDIA_IMAGES"}, PERMISSION_REQUEST_CODE);
4.App 在自己的图片选择器中列出 App 能够读取到的最新的所有图片,供用户选择。
应用第2+N次读取图片
在 U 版本上,如果用户在第一次交互和第二次交互中都只提供 App 读取部分图片权限,系统认为用户这个决定以后不再更改。此时 App 申请 READ_MEDIA_IMAGES 权限时的弹框就可以省略,以减少用户交互的层次。
-
判断当前用户只授予了 App 读取部分图片和视频的权限。
if (context.checkSelfPermission("android.permission.READ_MEDIA_VISUAL_USER_SELECTED") == PackageManager.PERMISSION_GRANTED && context.checkSelfPermission("android.permission.READ_MEDIA_IMAGES") != PackageManager.PERMISSION_GRANTED ) {
// 表示用户授予了App读取部分图片和视频的权限
// App从系统MediaStore或者公共存储中读取到的图片和视频是不全的,App只能看到用户在系统UX中授予的部分图片和视频
// App需要适配新流程
......
}
2.在 App 的图片选择器中列出所有能够选择的图片(用户提供 App 的部分图片),并提供 添加可访问的照片 的能力,方便用户在需要的时候申请 App 读取更多图片的权限。
3.在 App 的 UX 上,用户选择 添加可访问的照片 时,请求 READ_MEDIA_IMAGES 权限,通过系统 UX 添加更多的提供 App 访问的图片。
final int PERMISSION_REQUEST_CODE = 100;
......
context.requestPermissions(new String[]{"android.permission.READ_MEDIA_IMAGES"}, PERMISSION_REQUEST_CODE);
4.App 在自己的图片选择器中列出 App 能够读取到的最新的所有图片,供用户选择。
最佳实践
在官方文档中,Google 推荐了使用 READ_MEDIA_VISUAL_USER_SELECTED 权限的几个最佳实践。
-
后台媒体处理实质上需要获得新权限
如果应用进行媒体处理(例如在后台压缩或上传媒体),请注意,READ_MEDIA_IMAGES 和 READ_MEDIA_VIDEO 权限最终会再次被拒绝。我们强烈建议您添加对 READ_MEDIA_VISUAL_USER_SELECTED 的支持。或者您的应用应打开 InputStream 或使用 ContentResolver 进行查询,检查自己是否有权访问特定照片或视频。
-
请勿永久存储权限状态
请勿永久存储权限状态(包括 SharedPreferences 或 DataStore )。存储的状态可能与实际状态不同步。权限状态可能会在以下情况下发生更改:权限重新设置、应用程序休眠、用户在应用设置中发起更改,或者应用进入后台。请应用使用 ContextCompat.checkSelfPermission() 检查存储权限。
-
不要假装拥有对照片和视频的完整访问权限
根据 Android 14 中引入的更改,您的应用可能只对设备的照片库进行部分访问权限。如果应用在使用 ContentResolver 进行查询时存储了 MediaStore 数据,则存储可能不是最新的。
- 应始终使用 ContentResolver 查询 MediaStore,而不是依赖于存储的缓存。
- 当应用在前台运行时,将结果保存在内存中。
-
将 URI 访问权限视为临时访问权限
如果用户在系统权限对话框中选择 选择照片和视频 ,您的应用对所选择照片和视频的访问权限最终将过期。无论产生何种授权,应用应始终处理无法访问任何 URI 的情况。
参考:https://developer.android.com/develop/background-work/services/foreground-services?hl=zh-cn
照片和视频的部分访问权限 从荣耀开发者服务平台搬运,我感觉他们写的比较完整以及详细。
适配/设计规范-Google U版本(Android 14)应用兼容性适配指导 | 荣耀开发者服务平台 (honor.com)
官网: