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