Android 14(UPSIDE_DOWN_CAKE)在10月份正式发布了,又需要进行新一轮的适配了。
每一个新版本的变更中,适配都分为两种,一种是不论开发时是否将targetSdkVersion更改为为最新版,只要App运行在Android 14的手机上都得适配。另一种是开发时将targetSdkVersion更改为最新版本,才需要适配。本文主要介绍适配针对targetSdkVersion升级为34的应用的变更。
针对targetSdkVersion为34的应用
核心功能
必须设置前台服务类型
本节以定位前台服务为例。
1. 必须配置foregroundServiceType
。
所有的前台服务必须配置foregroundServiceType
,可以在AndroidManifest
中通过android:foregroundServiceType
属性配置,也可以在使用ServiceCompat.startForeground()
启动服务时配置。
- 在
AndroidManifest
中配置android:foregroundServiceType
。
xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
......
>
......
<service
android:name=".androidapi.targetsdk.ExampleLocationServices"
android:exported="false"
android:foregroundServiceType="location" />
</application>
</manifest>
- 通过
ServiceCompat.startForeground()
配置。
kotlin
class ExampleLocationServices : Service() {
private val locationListener = LocationListener { location ->
// 获取当前定位
}
override fun onCreate() {
super.onCreate()
val notification = NotificationCompat.Builder(this, "example_notification_channel")
.setSmallIcon(R.drawable.notification)
.setContentTitle("foreground services notification")
.setContentText("test foreground services notification")
.build()
ServiceCompat.startForeground(this,this.hashCode(),notification,ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
getSystemService(LocationManager::class.java).run {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// 结合多种数据源(传感器、定位)提供定位信息
requestLocationUpdates(LocationManager.FUSED_PROVIDER, 2000, 0f, locationListener)
} else {
if (isProviderEnabled(LocationManager.GPS_PROVIDER)) {
// 使用GPS提供定位信息
requestLocationUpdates(LocationManager.GPS_PROVIDER, 2000, 0f, locationListener)
} else {
// 使用网络提供定位信息
requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 2000, 0f, locationListener)
}
}
}
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onDestroy() {
getSystemService(LocationManager::class.java).removeUpdates(locationListener)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
}else{
stopForeground(true)
}
super.onDestroy()
}
}
如果没有在AndroidManifest
中配置android:foregroundServiceType
属性,也没有通过ServiceCompat.startForeground()
方法而是使用Services.startForeground()
方法启动服务,系统会抛出MissingForegroundServiceTypeException
。
2. 必须声明所选前台服务类型对应的权限
必须在AndroidManifest
中声明所选前台服务类型对应的权限,这些权限都是普通权限,声明后默认授予。
- 在
AndroidManifest
中声明location
前台服务权限。
xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<application
......
>
......
<service
android:name=".androidapi.targetsdk.ExampleLocationServices"
android:exported="false"
android:foregroundServiceType="location" />
</application>
</manifest>
如果在没有声明对应权限的情况下启动了前台服务,系统会抛出SecurityException
。
3. 检测运行时权限
不同类型的前台服务可能会使用一些需要权限的API,例如location
前台服务所用的定位API需要定位权限,所以在启动前台服务时务必先获取该类型使用的API所需的权限。
kotlin
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<application
......
>
......
<service
android:name=".androidapi.targetsdk.ExampleLocationServices"
android:exported="false"
android:foregroundServiceType="location" />
</application>
</manifest>
class TargetSdk14AdapterExampleActivity : AppCompatActivity() {
private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding
private lateinit var notificationManager: NotificationManagerCompat
private val exampleNotificationChannel = "example_notification_channel"
private val requestMultiplePermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions: Map<String, Boolean> ->
val noGrantedPermissions = ArrayList<String>()
permissions.entries.forEach {
if (!it.value) {
noGrantedPermissions.add(it.key)
}
}
if (noGrantedPermissions.isEmpty()) {
// 申请权限通过,可以启动定位前台服务
startService(Intent(this, ExampleLocationServices::class.java))
} else {
//未同意授权
noGrantedPermissions.forEach {
if (!shouldShowRequestPermissionRationale(it)) {
//用户拒绝权限并且系统不再弹出请求权限的弹窗
//这时需要我们自己处理,比如自定义弹窗告知用户为何必须要申请这个权限
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.includeTitle.tvTitle.text = "Adapt Android 14"
notificationManager = NotificationManagerCompat.from(this)
createNotificationChannel()
binding.btnForegroundServices.setOnClickListener {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
) {
startService(Intent(this, ExampleLocationServices::class.java))
} else {
requestMultiplePermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION))
}
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// 创建通知渠道
val applicationInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0))
} else {
packageManager.getApplicationInfo(packageName, 0)
}
val exampleChannel = NotificationChannel(exampleNotificationChannel, "${getText(applicationInfo.labelRes)} Notification Channel", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "The description of this notification channel"
}
notificationManager.createNotificationChannel(exampleChannel)
}
}
}
如果在没有获取到前台服务类型对应的运行时权限的情况下启动了前台服务,系统会抛出SecurityException
。
4. 前台服务类型
可选择的前台服务类型如下:
camera
connectedDevice
dataSync
health
location
mediaPlayback
mediaProjection
microphone
phoneCall
remoteMessaging
shortService
specialUse
systemExempted
可以一次声明一个或者多个组合。如果上述类型不满足需求,建议改为使用WorkManager或JobServices。
蓝牙API权限调整
当targetSdk设置为34时,BluetoothAdapter.getProfileConnectionState()
方法需要BLUETOOTH_CONNECT
权限。
如果要使用BluetoothAdapter.getProfileConnectionState()
方法,确保在AndroidManifest
中声明BLUETOOTH_CONNECT
权限,并在执行BluetoothAdapter.getProfileConnectionState()
方法前检测用户是否授予了BLUETOOTH_CONNECT
权限。
示例代码如下:
- 在
AndroidManifest
中声明BLUETOOTH_CONNECT
权限。
xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
</manifest>
- 执行
BluetoothAdapter.getProfileConnectionState()
方法前检测用户是否授予了BLUETOOTH_CONNECT
权限。
kotlin
class TargetSdk14AdapterExampleActivity : AppCompatActivity() {
private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding
private var requestPermissionName = ""
private val requestSinglePermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted: Boolean ->
if (granted) {
//同意授权,调用getProfileConnectionState方法
getSystemService(BluetoothManager::class.java).adapter.getProfileConnectionState(BluetoothProfile.A2DP)
} else {
//未同意授权
if (!shouldShowRequestPermissionRationale(requestPermissionName)) {
//用户拒绝权限并且系统不再弹出请求权限的弹窗
//这时需要我们自己处理,比如自定义弹窗告知用户为何必须要申请这个权限
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.includeTitle.tvTitle.text = "Adapt Android 14"
binding.btnBluetoothApi.setOnClickListener {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) {
getSystemService(BluetoothManager::class.java).adapter.getProfileConnectionState(BluetoothProfile.A2DP)
} else {
requestPermissionName = Manifest.permission.BLUETOOTH_CONNECT
requestSinglePermissionLauncher.launch(requestPermissionName)
}
}
}
}
如果在未获得BLUETOOTH_CONNECT
权限的情况下直接调用BluetoothAdapter.getProfileConnectionState()
方法,系统会抛出SecurityException
。
JobScheduler
行为变更
- 过长的执行时间会导致ANR。
当targetSdk设置为34时,如果JobServices
的onStartJob
或onStopJob
方法在主线程允许的执行时间内没有结束,系统会触发ANR并显示对应的错误消息。
举个例子,在JobServices
的onStartJob
中模拟耗时操作,代码如下:
kotlin
class ExampleJobServices : JobService() {
private var startTime = 0L
private var currentTime = 0L
override fun onStartJob(params: JobParameters?): Boolean {
startTime = System.currentTimeMillis()
currentTime = System.currentTimeMillis()
while (currentTime - startTime <= 10 * 1000) {
currentTime = System.currentTimeMillis()
}
return true
}
override fun onStopJob(params: JobParameters?): Boolean {
return false
}
}
class TargetSdk14AdapterExampleActivity : AppCompatActivity() {
private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.includeTitle.tvTitle.text = "Adapt Android 14"
binding.btnJobServices.setOnClickListener {
getSystemService(JobScheduler::class.java).run {
schedule(JobInfo.Builder(this.hashCode(), ComponentName(this@TargetSdk14AdapterExampleActivity,ExampleJobServices::class.java))
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
.build())
}
}
}
}
效果如图:
- 配置可执行任务的网路情况的约束条件需要对应权限。
当targetSdk设置为34时,如果没有在AndroidManifest
中声明ACCESS_NETWORK_STATE
权限,使用JobInfo.Builder.setRequiredNetworkType
或JobInfo.Builder.setRequiredNetwork
配置可执行任务的网路情况的约束条件时,系统会抛出SecurityException
。
安全
限制隐式Intent
和PendingIntent
当targetSdk设置为34时,通过隐式Intent
或隐式Intent
创建的PendingIntent
只能打开设置了android:exported="true"
的组件,如果android:exported
属性值为false
,系统会抛出异常。
举个例子,通过隐式Intent
打开ExampleIntentActivity
,代码如下:
AndroidManifest
xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
......
>
<activity
android:name=".androidapi.targetsdk.ExampleIntentActivity"
android:exported="false">
<intent-filter>
<action android:name="com.chenyihong.exampledemo.EXAMPLE_INTENT"/>
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>
示例页面
kotlin
class TargetSdk14AdapterExampleActivity : AppCompatActivity() {
private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.includeTitle.tvTitle.text = "Adapt Android 14"
binding.btnIntent.setOnClickListener {
startActivity(Intent("com.chenyihong.exampledemo.EXAMPLE_INTENT"))
}
}
}
通过Intent("com.chenyihong.exampledemo.EXAMPLE_INTENT")
打开ExampleIntentActivity
时,系统抛出如下异常:
如果需要打开android:exported="false"
的Activity
,改为使用显示Intent
,改动代码如下:
kotlin
class TargetSdk14AdapterExampleActivity : AppCompatActivity() {
private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.includeTitle.tvTitle.text = "Adapt Android 14"
binding.btnIntent.setOnClickListener {
startActivity(Intent("com.chenyihong.exampledemo.EXAMPLE_INTENT").apply {
setPackage(packageName)
})
}
}
}
效果如图:
通过Context
注册的广播接收者必须设置是否导出
当targetSdk设置为34时,通过Context
注册接收自定义广播的广播接收者时必须设置是否导出。通过Context
注册接收系统广播的广播接收者时不用设置是否导出。
举个例子,注册一个接收自定义Action
的广播接收者
kotlin
class TargetSdk14AdapterExampleActivity : AppCompatActivity() {
private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding
private val EXAMPLE_ACTION_LOGIN="com.chenyihong.exampledemo.LOGIN"
private val exampleBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
EXAMPLE_ACTION_LOGIN -> {
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.includeTitle.tvTitle.text = "Adapt Android 14"
binding.btnBroadcast.setOnClickListener {
registerReceiver(exampleBroadcastReceiver, IntentFilter().apply {
addAction(EXAMPLE_ACTION_LOGIN)
})
}
}
}
系统会抛出SecurityException
异常,如图:
正确注册方式代码如下:
kotlin
class TargetSdk14AdapterExampleActivity : AppCompatActivity() {
private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding
private val EXAMPLE_ACTION_LOGIN="com.chenyihong.exampledemo.LOGIN"
private val exampleBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
EXAMPLE_ACTION_LOGIN -> {
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.includeTitle.tvTitle.text = "Adapt Android 14"
binding.btnBroadcast.setOnClickListener {
// 需要接收系统广播或其他应用广播使用ContextCompat.RECEIVER_EXPORTED
// 否则使用ContextCompat.RECEIVER_NOT_EXPORTED
ContextCompat.registerReceiver(this, exampleBroadcastReceiver, IntentFilter().apply {
addAction(EXAMPLE_ACTION_LOGIN)
}, ContextCompat.RECEIVER_NOT_EXPORTED)
}
}
}
动态加载代码方式调整
当targetSdk设置为34时,必须将所有动态加载的文件标记为只读,否则系统会抛出异常。
限制在后台启动Activity
当targetSdk设置为34时,系统新增了对从后台启动Activity
行为的限制。
- 尝试使用
PendingIntent
从后台打开Activity
时,必须设置setPendingIntentBackgroundActivityStartMode(MODE_BACKGROUND_ACTIVITY_START_ALLOWED)
。
kotlin
val activityOptions = ActivityOptions.makeBasic().apply {
pendingIntentBackgroundActivityStartMode = ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
}
PendingIntent.getActivity(this, 0, Intent(this, TargetActivity::class.java), PendingIntent.FLAG_IMMUTABLE, activityOptions.toBundle())
- 当一个可见应用使用
bindService()
绑定另一个应用在后台运行的服务时,如果想要将后台启动Activity
的权限授予被绑定服务,需要在bindService()
方法中传入BIND_ALLOW_ACTIVITY_STARTS
。
scss
bindService(serviceIntent, serviceConnection, Context.BIND_ALLOW_ACTIVITY_STARTS);
压缩路径遍历漏洞优化
当targetSdk设置为34时,为了防止Zip路径遍历的漏洞,使用ZipFile(String)
或ZipInputStream.getNextEntry()
时,如果路径以".."或者"/"开头,系统会抛出ZipException
。
测试了一下,在Assets中存放一个test.zip文件然后将其移动到内部存储空间中并命名为testZipFile.zip,通过testZipFile.zip的相对路径来实例化ZipFile
或ZipInputStream
,代码如下:
kotlin
class TargetSdk14AdapterExampleActivity : AppCompatActivity() {
private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.includeTitle.tvTitle.text = "Adapt Android 14"
binding.btnUnzip.setOnClickListener {
val targetZipFileParent = if (Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()) {
File(getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), packageName)
} else {
File(filesDir, packageName)
}
val testZipFile = File(targetZipFileParent, "testZipFile.zip")
if (!testZipFile.exists()) {
val inputStream = assets.open("test.zip")
val fileOutputStream = FileOutputStream(testZipFile)
val buffer = ByteArray(1024)
try {
var length: Int
while (inputStream.read(buffer).also { length = it } != -1) {
fileOutputStream.write(buffer, 0, length)
}
inputStream.close()
fileOutputStream.close()
} catch (e: IOException) {
e.printStackTrace()
}
} else {
val zipFile = ZipFile(testZipFile.absolutePath)
// val zipInputStream = ZipInputStream(FileInputStream(testZipFile.absolutePath))
// val nextEntry = zipInputStream.nextEntry
}
}
}
}
testZipFile.zip的相对路径为:
bash
/storage/emulated/0/Android/data/com.chenyihong.exampledemo/files/Download/com.chenyihong.exampledemo/testZipFile.zip
结果App并不会崩溃。
MediaProjection行为变更
当targetSdk设置为34时,使用MediaProjection
进行屏幕捕获或录制时,下列情况会导致系统抛出异常。
- 调用
MediaProjection.createVirtualDisplay()
前不注册MediaProjection.Callback
回调,示例代码如下:
kotlin
// 错误代码
mediaProjection?.createVirtualDisplay("ScreenCapture", resources.displayMetrics.widthPixels, resources.displayMetrics.heightPixels, resources.displayMetrics.density.toInt(), DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, null, null, null)
// 正确代码
mediaProjection?.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
super.onStop()
// 释放资源
}
}, null)
mediaProjection?.createVirtualDisplay("ScreenCapture", resources.displayMetrics.widthPixels, resources.displayMetrics.heightPixels, resources.displayMetrics.density.toInt(), DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, null, null, null)
执行错误代码,系统会抛出IllegalStateException
,如下:
- 使用同一个
MediaProjection
对象多次调用createVirtualDisplay
方法,示例代码如下:
kotlin
class TargetSdk14AdapterExampleActivity : AppCompatActivity() {
private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding
private lateinit var notificationManager: NotificationManagerCompat
private val exampleNotificationChannel = "example_notification_channel"
private lateinit var mediaProjectionManager: MediaProjectionManager
private var mediaProjection: MediaProjection? = null
private val requestMediaProjectionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult ->
activityResult.data?.let {
mediaProjection = mediaProjectionManager.getMediaProjection(activityResult.resultCode, it)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.includeTitle.tvTitle.text = "Adapt Android 14"
notificationManager = NotificationManagerCompat.from(this)
createNotificationChannel()
mediaProjectionManager = getSystemService(MediaProjectionManager::class.java)
binding.btnMediaProjection.setOnClickListener {
startService(Intent(this, ExampleMediaProjectionServices::class.java))
if (mediaProjection == null) {
requestMediaProjectionLauncher.launch(mediaProjectionManager.createScreenCaptureIntent())
}
mediaProjection?.createVirtualDisplay("ScreenCapture", resources.displayMetrics.widthPixels, resources.displayMetrics.heightPixels, resources.displayMetrics.density.toInt(), DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, null,
object : VirtualDisplay.Callback() {
override fun onPaused() {
super.onPaused()
// 暂停
}
override fun onResumed() {
super.onResumed()
// 恢复
}
override fun onStopped() {
super.onStopped()
// 停止,此时可以释放资源
}
}, null)
}
}
}
判断MediaProjection
对象为空则创建,不为空就重复使用该对象。系统会抛出SecurityException
,如下:
通常来说,这种方式可以避免重复创建对象,是不错的做法。但是,为了避免App在仅获得用户一次同意之后,就无限制的使用同一个MediaProjection
来获取屏幕内容,系统限制了通过一个MediaProjection
只能调用一次createVirtualDisplay
。