1 /*
2 * Copyright 2023 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16 @file:JvmName("AirplaneModeListener")
17
18 package com.android.server.bluetooth.airplane
19
20 import android.bluetooth.BluetoothAdapter.STATE_ON
21 import android.bluetooth.BluetoothAdapter.STATE_TURNING_OFF
22 import android.bluetooth.BluetoothAdapter.STATE_TURNING_ON
23 import android.content.ContentResolver
24 import android.content.Context
25 import android.content.res.Resources
26 import android.os.Looper
27 import android.provider.Settings
28 import android.widget.Toast
29 import com.android.bluetooth.BluetoothStatsLog
30 import com.android.bluetooth.flags.Flags
31 import com.android.server.bluetooth.BluetoothAdapterState
32 import com.android.server.bluetooth.Log
33 import com.android.server.bluetooth.initializeRadioModeListener
34 import kotlin.time.Duration.Companion.minutes
35 import kotlin.time.TimeMark
36 import kotlin.time.TimeSource
37
38 private const val TAG = "AirplaneModeListener"
39
40 /** @return true if Bluetooth state is currently impacted by airplane mode */
41 public var isOnOverrode = false
42 private set
43
44 /**
45 * @return true if airplane is ON on the device.
46 *
47 * This need to be used instead of reading the settings properties to avoid race condition from
48 * within the BluetoothManagerService thread
49 */
50 public var isOn = false
51 private set
52
53 /**
54 * The airplane ModeListener handles system airplane mode change and checks whether it need to
55 * trigger the callback or not.
56 *
57 * <p>The information of airplane mode being turns on would not be passed when Bluetooth is on and
58 * one of the following situations is met:
59 * <ul>
60 * <li> "Airplane Enhancement Mode" is enabled and the user asked for Bluetooth to be on previously
61 * <li> A media profile is connected (one of A2DP | Hearing Aid | Le Audio)
62 * </ul>
63 */
64 @kotlin.time.ExperimentalTime
initializenull65 public fun initialize(
66 looper: Looper,
67 systemResolver: ContentResolver,
68 state: BluetoothAdapterState,
69 modeCallback: (m: Boolean) -> Unit,
70 notificationCallback: (state: String) -> Unit,
71 mediaCallback: () -> Boolean,
72 userCallback: () -> Context,
73 timeSource: TimeSource,
74 ) {
75
76 // Wifi got support for "Airplane Enhancement Mode" prior to Bluetooth.
77 // In order for Wifi to be aware that Bluetooth also support the feature, Bluetooth need to set
78 // the APM_ENHANCEMENT settings to `1`.
79 // Value will be set to DEFAULT_APM_ENHANCEMENT_STATE only if the APM_ENHANCEMENT is not set.
80 Settings.Global.putInt(
81 systemResolver,
82 APM_ENHANCEMENT,
83 Settings.Global.getInt(systemResolver, APM_ENHANCEMENT, DEFAULT_APM_ENHANCEMENT_STATE)
84 )
85
86 val airplaneModeAtBoot =
87 initializeRadioModeListener(
88 looper,
89 systemResolver,
90 Settings.Global.AIRPLANE_MODE_RADIOS,
91 Settings.Global.AIRPLANE_MODE_ON,
92 fun(newMode: Boolean) {
93 isOn = newMode
94 val previousMode = isOnOverrode
95 val isBluetoothOn = state.oneOf(STATE_ON, STATE_TURNING_ON, STATE_TURNING_OFF)
96 val isMediaConnected = isBluetoothOn && mediaCallback()
97
98 isOnOverrode =
99 airplaneModeValueOverride(
100 systemResolver,
101 newMode,
102 isBluetoothOn,
103 notificationCallback,
104 userCallback,
105 isMediaConnected,
106 )
107
108 AirplaneMetricSession.handleModeChange(
109 newMode,
110 isBluetoothOn,
111 notificationCallback,
112 userCallback,
113 isMediaConnected,
114 timeSource.markNow(),
115 )
116
117 val description =
118 "previousMode=$previousMode, isOn=$isOn, isOnOverrode=$isOnOverrode, isMediaConnected=$isMediaConnected"
119
120 if (previousMode == isOnOverrode) {
121 Log.d(TAG, "Ignore mode change to same state. $description")
122 return
123 } else if (
124 Flags.airplaneModeXBleOn() && isOnOverrode == false && state.oneOf(STATE_ON)
125 ) {
126 Log.d(TAG, "Ignore mode change as Bluetooth is ON. $description")
127 return
128 }
129
130 Log.i(TAG, "Trigger callback. $description")
131 modeCallback(isOnOverrode)
132 }
133 )
134
135 isOn = airplaneModeAtBoot
136 isOnOverrode =
137 airplaneModeValueOverride(
138 systemResolver,
139 airplaneModeAtBoot,
140 null, // Do not provide a Bluetooth on / off as we want to evaluate override
141 null, // Do not provide a notification callback as we want to keep the boot silent
142 userCallback,
143 false,
144 )
145
146 // Bluetooth is always off during initialize, and no media profile can be connected
147 AirplaneMetricSession.handleModeChange(
148 airplaneModeAtBoot,
149 false,
150 notificationCallback,
151 userCallback,
152 false,
153 timeSource.markNow(),
154 )
155 Log.i(TAG, "Init completed. isOn=$isOn, isOnOverrode=$isOnOverrode")
156 }
157
158 @kotlin.time.ExperimentalTime
notifyUserToggledBluetoothnull159 public fun notifyUserToggledBluetooth(
160 resolver: ContentResolver,
161 userContext: Context,
162 isBluetoothOn: Boolean,
163 ) {
164 AirplaneMetricSession.notifyUserToggledBluetooth(resolver, userContext, isBluetoothOn)
165 }
166
167 ////////////////////////////////////////////////////////////////////////////////////////////////////
168 ////////////////////////////////////////// PRIVATE METHODS /////////////////////////////////////////
169 ////////////////////////////////////////////////////////////////////////////////////////////////////
170
airplaneModeValueOverridenull171 private fun airplaneModeValueOverride(
172 resolver: ContentResolver,
173 currentAirplaneMode: Boolean,
174 currentBluetoothStatus: Boolean?,
175 sendAirplaneModeNotification: ((state: String) -> Unit)?,
176 getUser: () -> Context,
177 isMediaConnected: Boolean,
178 ): Boolean {
179 // Airplane mode is being disabled or bluetooth was not on: no override
180 if (!currentAirplaneMode || currentBluetoothStatus == false) {
181 return currentAirplaneMode
182 }
183 // If "Airplane Enhancement Mode" is on and the user already used the feature …
184 if (isApmEnhancementEnabled(resolver) && hasUserToggledApm(getUser())) {
185 // … Staying on only depend on its last action in airplane mode
186 if (isBluetoothOnAPM(getUser)) {
187 val isWifiOn = isWifiOnApm(resolver, getUser)
188 sendAirplaneModeNotification?.invoke(
189 if (isWifiOn) APM_WIFI_BT_NOTIFICATION else APM_BT_NOTIFICATION
190 )
191 Log.i(TAG, "Enhancement Mode: override and stays ON")
192 return false
193 }
194 Log.i(TAG, "Enhancement Mode: override and turns OFF")
195 return true
196 }
197 // … Else, staying on only depend on media profile being connected or not
198 //
199 // Note: Once the "Airplane Enhancement Mode" has been used, media override no longer apply
200 // This has been done on purpose to avoid complexe scenario like:
201 // 1. User wants Bt off according to "Airplane Enhancement Mode"
202 // 2. User switches airplane while there is media => so Bt stays on
203 // 3. User turns airplane off, stops media and toggles airplane back on
204 // Should we turn Bt off like asked initially ? Or keep it `on` like the toggle ?
205 if (isMediaConnected) {
206 Log.i(TAG, "Legacy Mode: override and stays ON since media profile are connected")
207 ToastNotification.displayIfNeeded(resolver, getUser)
208 return false
209 }
210 Log.i(TAG, "Legacy Mode: no override, turns OFF")
211 return true
212 }
213
214 internal class ToastNotification private constructor() {
215 companion object {
216 private const val TOAST_COUNT = "bluetooth_airplane_toast_count"
217 internal const val MAX_TOAST_COUNT = 10
218
userNeedToBeNotifiednull219 private fun userNeedToBeNotified(resolver: ContentResolver): Boolean {
220 val currentToastCount = Settings.Global.getInt(resolver, TOAST_COUNT, 0)
221 if (currentToastCount >= MAX_TOAST_COUNT) {
222 return false
223 }
224 Settings.Global.putInt(resolver, TOAST_COUNT, currentToastCount + 1)
225 return true
226 }
227
displayIfNeedednull228 fun displayIfNeeded(resolver: ContentResolver, getUser: () -> Context) {
229 if (!userNeedToBeNotified(resolver)) {
230 Log.d(TAG, "Dismissed Toast notification")
231 return
232 }
233 val userContext = getUser()
234 val r = userContext.getResources()
235 val text: CharSequence =
236 r.getString(
237 Resources.getSystem()
238 .getIdentifier("bluetooth_airplane_mode_toast", "string", "android")
239 )
240 Toast.makeText(userContext, text, Toast.LENGTH_LONG).show()
241 Log.d(TAG, "Displayed Toast notification")
242 }
243 }
244 }
245
246 @kotlin.time.ExperimentalTime
247 private class AirplaneMetricSession(
248 private val isBluetoothOnBeforeApmToggle: Boolean,
249 private val sendAirplaneModeNotification: (state: String) -> Unit,
250 private val isMediaProfileConnectedBeforeApmToggle: Boolean,
251 private val sessionStartTime: TimeMark,
252 ) {
253 companion object {
254 private var session: AirplaneMetricSession? = null
255
handleModeChangenull256 fun handleModeChange(
257 isAirplaneModeOn: Boolean,
258 isBluetoothOn: Boolean,
259 sendAirplaneModeNotification: (state: String) -> Unit,
260 getUser: () -> Context,
261 isMediaProfileConnected: Boolean,
262 startTime: TimeMark,
263 ) {
264 if (isAirplaneModeOn) {
265 session =
266 AirplaneMetricSession(
267 isBluetoothOn,
268 sendAirplaneModeNotification,
269 isMediaProfileConnected,
270 startTime,
271 )
272 } else {
273 session?.let { it.terminate(getUser, isBluetoothOn) }
274 session = null
275 }
276 }
277
notifyUserToggledBluetoothnull278 fun notifyUserToggledBluetooth(
279 resolver: ContentResolver,
280 userContext: Context,
281 isBluetoothOn: Boolean,
282 ) {
283 session?.let { it.notifyUserToggledBluetooth(resolver, userContext, isBluetoothOn) }
284 }
285 }
286
287 private val isBluetoothOnAfterApmToggle = !isOnOverrode
288 private var userToggledBluetoothDuringApm = false
289 private var userToggledBluetoothDuringApmWithinMinute = false
290
notifyUserToggledBluetoothnull291 fun notifyUserToggledBluetooth(
292 resolver: ContentResolver,
293 userContext: Context,
294 isBluetoothOn: Boolean,
295 ) {
296 val isFirstToggle = !userToggledBluetoothDuringApm
297 userToggledBluetoothDuringApm = true
298
299 if (isFirstToggle) {
300 val oneMinute = sessionStartTime + 1.minutes
301 userToggledBluetoothDuringApmWithinMinute = !oneMinute.hasPassedNow()
302 }
303
304 if (isApmEnhancementEnabled(resolver)) {
305 // Set "Airplane Enhancement Mode" settings for a specific user
306 setUserSettingsSecure(userContext, BLUETOOTH_APM_STATE, if (isBluetoothOn) 1 else 0)
307 setUserSettingsSecure(userContext, APM_USER_TOGGLED_BLUETOOTH, 1)
308
309 if (isBluetoothOn) {
310 sendAirplaneModeNotification(APM_BT_ENABLED_NOTIFICATION)
311 }
312 }
313 }
314
315 /** Log current airplaneSession. Session cannot be re-use */
terminatenull316 fun terminate(getUser: () -> Context, isBluetoothOn: Boolean) {
317 BluetoothStatsLog.write(
318 BluetoothStatsLog.AIRPLANE_MODE_SESSION_REPORTED,
319 BluetoothStatsLog.AIRPLANE_MODE_SESSION_REPORTED__PACKAGE_NAME__BLUETOOTH,
320 isBluetoothOnBeforeApmToggle,
321 isBluetoothOnAfterApmToggle,
322 isBluetoothOn,
323 hasUserToggledApm(getUser()),
324 userToggledBluetoothDuringApm,
325 userToggledBluetoothDuringApmWithinMinute,
326 isMediaProfileConnectedBeforeApmToggle,
327 )
328 }
329 }
330
331 // Notification Id for when the airplane mode is turn on but Bluetooth stay on
332 internal const val APM_BT_NOTIFICATION = "apm_bt_notification"
333
334 // Notification Id for when the airplane mode is turn on but Bluetooth and Wifi stay on
335 internal const val APM_WIFI_BT_NOTIFICATION = "apm_wifi_bt_notification"
336
337 // Notification Id for when the Bluetooth is turned back on durin airplane mode
338 internal const val APM_BT_ENABLED_NOTIFICATION = "apm_bt_enabled_notification"
339
340 // Whether the "Airplane Enhancement Mode" is enabled
341 internal const val APM_ENHANCEMENT = "apm_enhancement_enabled"
342
343 // Whether the user has already toggled and used the "Airplane Enhancement Mode" feature
344 internal const val APM_USER_TOGGLED_BLUETOOTH = "apm_user_toggled_bluetooth"
345
346 // Whether Bluetooth should remain on in airplane mode
347 internal const val BLUETOOTH_APM_STATE = "bluetooth_apm_state"
348
349 // Whether Wifi should remain on in airplane mode
350 internal const val WIFI_APM_STATE = "wifi_apm_state"
351
setUserSettingsSecurenull352 private fun setUserSettingsSecure(userContext: Context, name: String, value: Int) =
353 Settings.Secure.putInt(userContext.contentResolver, name, value)
354
355 // Define if the "Airplane Enhancement Mode" feature is enabled by default. `0` == disabled
356 private const val DEFAULT_APM_ENHANCEMENT_STATE = 1
357
358 /** Airplane Enhancement Mode: Indicate if the feature is enabled or not. */
359 private fun isApmEnhancementEnabled(resolver: ContentResolver) =
360 Settings.Global.getInt(resolver, APM_ENHANCEMENT, DEFAULT_APM_ENHANCEMENT_STATE) == 1
361
362 /** Airplane Enhancement Mode: Return true if the wifi should stays on during airplane mode */
363 private fun isWifiOnApm(resolver: ContentResolver, getUser: () -> Context) =
364 Settings.Global.getInt(resolver, Settings.Global.WIFI_ON, 0) != 0 &&
365 Settings.Secure.getInt(getUser().contentResolver, WIFI_APM_STATE, 0) == 1
366
367 /** Airplane Enhancement Mode: Return true if this user already toggled (aka used) the feature */
368 fun hasUserToggledApm(userContext: Context) =
369 Settings.Secure.getInt(userContext.contentResolver, APM_USER_TOGGLED_BLUETOOTH, 0) == 1
370
371 /** Airplane Enhancement Mode: Return true if the bluetooth should stays on during airplane mode */
372 private fun isBluetoothOnAPM(getUser: () -> Context) =
373 Settings.Secure.getInt(getUser().contentResolver, BLUETOOTH_APM_STATE, 0) == 1
374