/* * Copyright 2019 Fitbit, Inc. All rights reserved. * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ package com.fitbit.bluetooth.fbgatt; import com.fitbit.bluetooth.fbgatt.util.GattUtils; import android.bluetooth.BluetoothAdapter; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.os.Handler; import android.os.Looper; import android.os.SystemClock; import androidx.annotation.VisibleForTesting; import timber.log.Timber; /** * Responsible for listening to the broadcasts of the bluetooth radio status on the mobile device * and notifying a callback. This listener will also attempt to prevent flapping in the case that * the user is rapidly toggling bluetooth on and off. * <p> * Created by iowens on 8/27/18. */ class BluetoothRadioStatusListener { @VisibleForTesting static final long MIN_TURNING_OFF_CALLBACK_DELAY = 500; static final long MIN_TURNING_ON_CALLBACK_DELAY = 1000; private Handler mainHandler; private int currentState; private long lastEvent = SystemClock.elapsedRealtimeNanos(); @VisibleForTesting BluetoothRadioStatusListener(Context context, boolean shouldInitializeListening, Looper mockMainThreadLooper) { this.context = context; // this handler is to deliver the callbacks in the same way as they would usually // be delivered, but we want to avoid flapping ( user toggling on and off quickly ) // so that our protocol stacks do not get set up in a half state this.mainHandler = new Handler(mockMainThreadLooper); if (shouldInitializeListening) { Timber.d("Starting listener"); startListening(); } // we are testing, default to BT on currentState = BluetoothAdapter.STATE_ON; } /** * The delegate interface for changes on the radio status */ interface BluetoothOnListener { void bluetoothOff(); void bluetoothOn(); void bluetoothTurningOff(); void bluetoothTurningOn(); } /** * The android context */ Context context; /** * The delegate listener member var */ BluetoothOnListener listener; /** * The android system broadcast receiver */ @VisibleForTesting BroadcastReceiver receiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { if (BluetoothAdapter.ACTION_STATE_CHANGED.equals(intent.getAction())) { final int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR); Timber.d("Received BluetoothState: [%s]", parseBluetoothStatus(state)); performActionIfNecessaryAndUpdateState(state); } } private void performActionIfNecessaryAndUpdateState(int state) { // if a dev wants to know about flapping listen to turning on / off if (state == BluetoothAdapter.STATE_TURNING_ON || state == BluetoothAdapter.STATE_TURNING_OFF) { Timber.v("Turning off or turning on, passing through with no delay"); mainHandler.post(() -> { if (listener != null) { switch (state) { case BluetoothAdapter.STATE_TURNING_OFF: listener.bluetoothTurningOff(); break; case BluetoothAdapter.STATE_TURNING_ON: listener.bluetoothTurningOn(); break; default: Timber.w("The BT radio went into a state that we do not handle"); } } }); } else { boolean shouldCallback = shouldScheduleCallback(currentState, state); currentState = state; if (shouldCallback) { // if we got that we should callback, then we should cancel any existing pending // callback before starting the new one mainHandler.removeCallbacksAndMessages(null); Timber.v("Clearing old messages"); Timber.v("BT on or off, sending after %dms", (state == BluetoothAdapter.STATE_OFF) ? MIN_TURNING_OFF_CALLBACK_DELAY : MIN_TURNING_ON_CALLBACK_DELAY); mainHandler.postDelayed(() -> { if (listener != null) { switch (state) { case BluetoothAdapter.STATE_OFF: Timber.v("Notifying off"); listener.bluetoothOff(); break; case BluetoothAdapter.STATE_ON: Timber.v("Notifying on"); listener.bluetoothOn(); break; default: Timber.w("The BT radio went into a state that we do not handle"); } } }, ((state == BluetoothAdapter.STATE_OFF) ? MIN_TURNING_OFF_CALLBACK_DELAY : MIN_TURNING_ON_CALLBACK_DELAY)); } else { Timber.d("Not calling back, flapping"); } } } }; @VisibleForTesting void setCurrentState(int currentAdapterState) { this.currentState = currentAdapterState; } @VisibleForTesting void setLastEvent(long lastEventTime) { this.lastEvent = lastEventTime; } /** * To set a delegate for listening to bluetooth adapter state changes on this class * * @param onListener The listener for status changes */ void setListener(BluetoothOnListener onListener) { this.listener = onListener; } /** * Will remove the listener */ void removeListener() { this.listener = null; } /** * Constructor for the listener, if desired as a convenience can start listening immediately * * @param context The android context * @param shouldInitializeListening Whether to attach to the global broadcast immediately */ BluetoothRadioStatusListener(Context context, boolean shouldInitializeListening) { this.context = context; BluetoothAdapter adapter = new GattUtils().getBluetoothAdapter(context); // if we are in this condition, something is seriously wrong this.currentState = (adapter != null) ? adapter.getState() : BluetoothAdapter.STATE_OFF; // this handler is to deliver the callbacks in the same way as they would usually // be delivered, but we want to avoid flapping ( user toggling on and off quickly ) // so that our protocol stacks do not get set up in a half state this.mainHandler = new Handler(Looper.getMainLooper()); if (shouldInitializeListening) { Timber.d("Starting listener"); startListening(); } } /** * Will start listening to the global bluetooth adapter broadcasts */ void startListening() { IntentFilter filter = new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED); context.getApplicationContext().registerReceiver(receiver, filter); } /** * Will stop listening to the global bluetooth adapter status broadcasts */ void stopListening() { context.getApplicationContext().unregisterReceiver(receiver); } /** * Will parse out the bluetooth status integer into actual text to log * * @param status The bluetooth status value from the intent * @return The string value of the bluetooth status response */ private String parseBluetoothStatus(int status) { String statusString; switch (status) { case BluetoothAdapter.STATE_TURNING_ON: statusString = "Turning On"; break; case BluetoothAdapter.STATE_TURNING_OFF: statusString = "Turning Off"; break; case BluetoothAdapter.STATE_ON: statusString = "On"; break; case BluetoothAdapter.STATE_OFF: statusString = "Off"; break; default: statusString = "Unknown"; break; } return statusString; } /** * What this method does is to determine, based on a stream of inputs whether to schedule an * on / off callback ( we'll leave the turning on / turning off callbacks alone ). What we want * is to send an off callback immediately, but only send an on callback if the state has * settled into an on state for one second. * * Turning off and turning on are not considered by this logic * * @param previousState The previous bt state * @param currentState The current bt state * @return true if a callback should be scheduled based on the current state, false if nothing should occur */ @VisibleForTesting boolean shouldScheduleCallback(int previousState, int currentState) { boolean shouldReturn; long currentRt = SystemClock.elapsedRealtimeNanos(); // this method should not hold it's own state, but instead operate on the values provided // for now we will just write it out and then test the crap out of it to make sure that // all cases are handled. // check less than MIN_CALLBACK_DELAY, if less than say no, there is a minor edge case where // currentRt and lastEvent can be the same nanos, if this is true, just do nothing since // the app just launched and the toggle is already happening, this is flapping too. if (currentRt - lastEvent < ((currentState == BluetoothAdapter.STATE_OFF) ? MIN_TURNING_OFF_CALLBACK_DELAY : MIN_TURNING_ON_CALLBACK_DELAY)) { Timber.d("Time since last BT radio change is less than min, not doing anything"); return false; } if (previousState == BluetoothAdapter.STATE_ON && currentState == BluetoothAdapter.STATE_OFF) { // we always want to send a message when the adapter goes off Timber.v("The adapter is off"); shouldReturn = true; } else if (previousState == BluetoothAdapter.STATE_ON && currentState == BluetoothAdapter.STATE_ON) { // this is here for just completeness, realistically this should never happen Timber.v("The adapter was on and somehow we got on again, sad."); shouldReturn = false; } else if (previousState == BluetoothAdapter.STATE_OFF && currentState == BluetoothAdapter.STATE_OFF) { // likewise, this should never happen Timber.v("We are in a state that should never occur, this phone may have an untrustworthy BT stack"); shouldReturn = false; } else if (previousState == BluetoothAdapter.STATE_OFF && currentState == BluetoothAdapter.STATE_ON) { Timber.v("Adapter was off and is on"); shouldReturn = true; } else { Timber.v("Unknown state"); shouldReturn = false; } // we want elapsed real-time millis because the user could be doing funky things with // SystemClock sleep or whatever lastEvent = SystemClock.elapsedRealtimeNanos(); return shouldReturn; } }