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