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