123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561 |
- /*
- * Nextcloud Talk application
- *
- * @author Mario Danic
- * Copyright (C) 2017 Mario Danic <mario@lovelyhq.com>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- * Original code:
- *
- *
- * Copyright 2016 The WebRTC Project Authors. All rights reserved.
- *
- * Use of this source code is governed by a BSD-style license
- * that can be found in the LICENSE file in the root of the source
- * tree. An additional intellectual property rights grant can be found
- * in the file PATENTS. All contributing project authors may
- * be found in the AUTHORS file in the root of the source tree.
- */
- package com.nextcloud.talk.webrtc;
- import android.annotation.SuppressLint;
- import android.bluetooth.BluetoothAdapter;
- import android.bluetooth.BluetoothDevice;
- import android.bluetooth.BluetoothHeadset;
- import android.bluetooth.BluetoothProfile;
- import android.content.BroadcastReceiver;
- import android.content.Context;
- import android.content.Intent;
- import android.content.IntentFilter;
- import android.content.pm.PackageManager;
- import android.media.AudioManager;
- import android.os.Build;
- import android.os.Handler;
- import android.os.Looper;
- import android.os.Process;
- import android.util.Log;
- import org.webrtc.ThreadUtils;
- import java.util.List;
- import java.util.Set;
- public class MagicBluetoothManager {
- private static final String TAG = "MagicBluetoothManager";
- // Timeout interval for starting or stopping audio to a Bluetooth SCO device.
- private static final int BLUETOOTH_SCO_TIMEOUT_MS = 4000;
- // Maximum number of SCO connection attempts.
- private static final int MAX_SCO_CONNECTION_ATTEMPTS = 2;
- private final Context apprtcContext;
- private final MagicAudioManager apprtcAudioManager;
- private final AudioManager audioManager;
- private final Handler handler;
- private final BluetoothProfile.ServiceListener bluetoothServiceListener;
- private final BroadcastReceiver bluetoothHeadsetReceiver;
- int scoConnectionAttempts;
- private State bluetoothState;
- private BluetoothAdapter bluetoothAdapter;
- private BluetoothHeadset bluetoothHeadset;
- private BluetoothDevice bluetoothDevice;
- // Runs when the Bluetooth timeout expires. We use that timeout after calling
- // startScoAudio() or stopScoAudio() because we're not guaranteed to get a
- // callback after those calls.
- private final Runnable bluetoothTimeoutRunnable = new Runnable() {
- @Override
- public void run() {
- bluetoothTimeout();
- }
- };
- protected MagicBluetoothManager(Context context, MagicAudioManager audioManager) {
- Log.d(TAG, "ctor");
- ThreadUtils.checkIsOnMainThread();
- apprtcContext = context;
- apprtcAudioManager = audioManager;
- this.audioManager = getAudioManager(context);
- bluetoothState = State.UNINITIALIZED;
- bluetoothServiceListener = new BluetoothServiceListener();
- bluetoothHeadsetReceiver = new BluetoothHeadsetBroadcastReceiver();
- handler = new Handler(Looper.getMainLooper());
- }
- /**
- * Construction.
- */
- static MagicBluetoothManager create(Context context, MagicAudioManager audioManager) {
- return new MagicBluetoothManager(context, audioManager);
- }
- /**
- * Returns the internal state.
- */
- public State getState() {
- ThreadUtils.checkIsOnMainThread();
- return bluetoothState;
- }
- ;
- /**
- * Activates components required to detect Bluetooth devices and to enable
- * BT SCO (audio is routed via BT SCO) for the headset profile. The end
- * state will be HEADSET_UNAVAILABLE but a state machine has started which
- * will start a state change sequence where the final outcome depends on
- * if/when the BT headset is enabled.
- * Example of state change sequence when start() is called while BT device
- * is connected and enabled:
- * UNINITIALIZED --> HEADSET_UNAVAILABLE --> HEADSET_AVAILABLE -->
- * SCO_CONNECTING --> SCO_CONNECTED <==> audio is now routed via BT SCO.
- * Note that the MagicAudioManager is also involved in driving this state
- * change.
- */
- public void start() {
- ThreadUtils.checkIsOnMainThread();
- Log.d(TAG, "start");
- if (!hasPermission(apprtcContext, android.Manifest.permission.BLUETOOTH)) {
- Log.w(TAG, "Process (pid=" + Process.myPid() + ") lacks BLUETOOTH permission");
- return;
- }
- if (bluetoothState != State.UNINITIALIZED) {
- Log.w(TAG, "Invalid BT state");
- return;
- }
- bluetoothHeadset = null;
- bluetoothDevice = null;
- scoConnectionAttempts = 0;
- // Get a handle to the default local Bluetooth adapter.
- bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
- if (bluetoothAdapter == null) {
- Log.w(TAG, "Device does not support Bluetooth");
- return;
- }
- // Ensure that the device supports use of BT SCO audio for off call use cases.
- if (!audioManager.isBluetoothScoAvailableOffCall()) {
- Log.e(TAG, "Bluetooth SCO audio is not available off call");
- return;
- }
- logBluetoothAdapterInfo(bluetoothAdapter);
- // Establish a connection to the HEADSET profile (includes both Bluetooth Headset and
- // Hands-Free) proxy object and install a listener.
- if (!getBluetoothProfileProxy(
- apprtcContext, bluetoothServiceListener, BluetoothProfile.HEADSET)) {
- Log.e(TAG, "BluetoothAdapter.getProfileProxy(HEADSET) failed");
- return;
- }
- // Register receivers for BluetoothHeadset change notifications.
- IntentFilter bluetoothHeadsetFilter = new IntentFilter();
- // Register receiver for change in connection state of the Headset profile.
- bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED);
- // Register receiver for change in audio connection state of the Headset profile.
- bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED);
- registerReceiver(bluetoothHeadsetReceiver, bluetoothHeadsetFilter);
- Log.d(TAG, "HEADSET profile state: "
- + stateToString(bluetoothAdapter.getProfileConnectionState(BluetoothProfile.HEADSET)));
- Log.d(TAG, "Bluetooth proxy for headset profile has started");
- bluetoothState = State.HEADSET_UNAVAILABLE;
- Log.d(TAG, "start done: BT state=" + bluetoothState);
- }
- /**
- * Stops and closes all components related to Bluetooth audio.
- */
- public void stop() {
- ThreadUtils.checkIsOnMainThread();
- Log.d(TAG, "stop: BT state=" + bluetoothState);
- if (bluetoothAdapter == null) {
- return;
- }
- // Stop BT SCO connection with remote device if needed.
- stopScoAudio();
- // Close down remaining BT resources.
- if (bluetoothState == State.UNINITIALIZED) {
- return;
- }
- unregisterReceiver(bluetoothHeadsetReceiver);
- cancelTimer();
- if (bluetoothHeadset != null) {
- bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset);
- bluetoothHeadset = null;
- }
- bluetoothAdapter = null;
- bluetoothDevice = null;
- bluetoothState = State.UNINITIALIZED;
- Log.d(TAG, "stop done: BT state=" + bluetoothState);
- }
- /**
- * Starts Bluetooth SCO connection with remote device.
- * Note that the phone application always has the priority on the usage of the SCO connection
- * for telephony. If this method is called while the phone is in call it will be ignored.
- * Similarly, if a call is received or sent while an application is using the SCO connection,
- * the connection will be lost for the application and NOT returned automatically when the call
- * ends. Also note that: up to and including API version JELLY_BEAN_MR1, this method initiates a
- * virtual voice call to the Bluetooth headset. After API version JELLY_BEAN_MR2 only a raw SCO
- * audio connection is established.
- * TODO(henrika): should we add support for virtual voice call to BT headset also for JBMR2 and
- * higher. It might be required to initiates a virtual voice call since many devices do not
- * accept SCO audio without a "call".
- */
- public boolean startScoAudio() {
- ThreadUtils.checkIsOnMainThread();
- Log.d(TAG, "startSco: BT state=" + bluetoothState + ", "
- + "attempts: " + scoConnectionAttempts + ", "
- + "SCO is on: " + isScoOn());
- if (scoConnectionAttempts >= MAX_SCO_CONNECTION_ATTEMPTS) {
- Log.e(TAG, "BT SCO connection fails - no more attempts");
- return false;
- }
- if (bluetoothState != State.HEADSET_AVAILABLE) {
- Log.e(TAG, "BT SCO connection fails - no headset available");
- return false;
- }
- // Start BT SCO channel and wait for ACTION_AUDIO_STATE_CHANGED.
- Log.d(TAG, "Starting Bluetooth SCO and waits for ACTION_AUDIO_STATE_CHANGED...");
- // The SCO connection establishment can take several seconds, hence we cannot rely on the
- // connection to be available when the method returns but instead register to receive the
- // intent ACTION_SCO_AUDIO_STATE_UPDATED and wait for the state to be SCO_AUDIO_STATE_CONNECTED.
- bluetoothState = State.SCO_CONNECTING;
- audioManager.startBluetoothSco();
- audioManager.setBluetoothScoOn(true);
- scoConnectionAttempts++;
- startTimer();
- Log.d(TAG, "startScoAudio done: BT state=" + bluetoothState + ", "
- + "SCO is on: " + isScoOn());
- return true;
- }
- /**
- * Stops Bluetooth SCO connection with remote device.
- */
- public void stopScoAudio() {
- ThreadUtils.checkIsOnMainThread();
- Log.d(TAG, "stopScoAudio: BT state=" + bluetoothState + ", "
- + "SCO is on: " + isScoOn());
- if (bluetoothState != State.SCO_CONNECTING && bluetoothState != State.SCO_CONNECTED) {
- return;
- }
- cancelTimer();
- audioManager.stopBluetoothSco();
- audioManager.setBluetoothScoOn(false);
- bluetoothState = State.SCO_DISCONNECTING;
- Log.d(TAG, "stopScoAudio done: BT state=" + bluetoothState + ", "
- + "SCO is on: " + isScoOn());
- }
- /**
- * Use the BluetoothHeadset proxy object (controls the Bluetooth Headset
- * Service via IPC) to update the list of connected devices for the HEADSET
- * profile. The internal state will change to HEADSET_UNAVAILABLE or to
- * HEADSET_AVAILABLE and |bluetoothDevice| will be mapped to the connected
- * device if available.
- */
- public void updateDevice() {
- if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) {
- return;
- }
- Log.d(TAG, "updateDevice");
- // Get connected devices for the headset profile. Returns the set of
- // devices which are in state STATE_CONNECTED. The BluetoothDevice class
- // is just a thin wrapper for a Bluetooth hardware address.
- List<BluetoothDevice> devices = bluetoothHeadset.getConnectedDevices();
- if (devices.isEmpty()) {
- bluetoothDevice = null;
- bluetoothState = State.HEADSET_UNAVAILABLE;
- Log.d(TAG, "No connected bluetooth headset");
- } else {
- // Always use first device in list. Android only supports one device.
- bluetoothDevice = devices.get(0);
- bluetoothState = State.HEADSET_AVAILABLE;
- Log.d(TAG, "Connected bluetooth headset: "
- + "name=" + bluetoothDevice.getName() + ", "
- + "state=" + stateToString(bluetoothHeadset.getConnectionState(bluetoothDevice))
- + ", SCO audio=" + bluetoothHeadset.isAudioConnected(bluetoothDevice));
- }
- Log.d(TAG, "updateDevice done: BT state=" + bluetoothState);
- }
- /**
- * Stubs for test mocks.
- */
- protected AudioManager getAudioManager(Context context) {
- return (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
- }
- protected void registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
- apprtcContext.registerReceiver(receiver, filter);
- }
- protected void unregisterReceiver(BroadcastReceiver receiver) {
- apprtcContext.unregisterReceiver(receiver);
- }
- protected boolean getBluetoothProfileProxy(
- Context context, BluetoothProfile.ServiceListener listener, int profile) {
- return bluetoothAdapter.getProfileProxy(context, listener, profile);
- }
- protected boolean hasPermission(Context context, String permission) {
- return apprtcContext.checkPermission(permission, Process.myPid(), Process.myUid())
- == PackageManager.PERMISSION_GRANTED;
- }
- /**
- * Logs the state of the local Bluetooth adapter.
- */
- @SuppressLint("HardwareIds")
- protected void logBluetoothAdapterInfo(BluetoothAdapter localAdapter) {
- Log.d(TAG, "BluetoothAdapter: "
- + "enabled=" + localAdapter.isEnabled() + ", "
- + "state=" + stateToString(localAdapter.getState()) + ", "
- + "name=" + localAdapter.getName());
- // Log the set of BluetoothDevice objects that are bonded (paired) to the local adapter.
- Set<BluetoothDevice> pairedDevices = localAdapter.getBondedDevices();
- if (!pairedDevices.isEmpty()) {
- Log.d(TAG, "paired devices:");
- for (BluetoothDevice device : pairedDevices) {
- Log.d(TAG, " name=" + device.getName() + ", address=" + device.getAddress());
- }
- }
- }
- /**
- * Ensures that the audio manager updates its list of available audio devices.
- */
- private void updateAudioDeviceState() {
- ThreadUtils.checkIsOnMainThread();
- Log.d(TAG, "updateAudioDeviceState");
- apprtcAudioManager.updateAudioDeviceState();
- }
- /**
- * Starts timer which times out after BLUETOOTH_SCO_TIMEOUT_MS milliseconds.
- */
- private void startTimer() {
- ThreadUtils.checkIsOnMainThread();
- Log.d(TAG, "startTimer");
- handler.postDelayed(bluetoothTimeoutRunnable, BLUETOOTH_SCO_TIMEOUT_MS);
- }
- /**
- * Cancels any outstanding timer tasks.
- */
- private void cancelTimer() {
- ThreadUtils.checkIsOnMainThread();
- Log.d(TAG, "cancelTimer");
- handler.removeCallbacks(bluetoothTimeoutRunnable);
- }
- /**
- * Called when start of the BT SCO channel takes too long time. Usually
- * happens when the BT device has been turned on during an ongoing call.
- */
- private void bluetoothTimeout() {
- ThreadUtils.checkIsOnMainThread();
- if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) {
- return;
- }
- Log.d(TAG, "bluetoothTimeout: BT state=" + bluetoothState + ", "
- + "attempts: " + scoConnectionAttempts + ", "
- + "SCO is on: " + isScoOn());
- if (bluetoothState != State.SCO_CONNECTING) {
- return;
- }
- // Bluetooth SCO should be connecting; check the latest result.
- boolean scoConnected = false;
- List<BluetoothDevice> devices = bluetoothHeadset.getConnectedDevices();
- if (devices.size() > 0) {
- bluetoothDevice = devices.get(0);
- if (bluetoothHeadset.isAudioConnected(bluetoothDevice)) {
- Log.d(TAG, "SCO connected with " + bluetoothDevice.getName());
- scoConnected = true;
- } else {
- Log.d(TAG, "SCO is not connected with " + bluetoothDevice.getName());
- }
- }
- if (scoConnected) {
- // We thought BT had timed out, but it's actually on; updating state.
- bluetoothState = State.SCO_CONNECTED;
- scoConnectionAttempts = 0;
- } else {
- // Give up and "cancel" our request by calling stopBluetoothSco().
- Log.w(TAG, "BT failed to connect after timeout");
- stopScoAudio();
- }
- updateAudioDeviceState();
- Log.d(TAG, "bluetoothTimeout done: BT state=" + bluetoothState);
- }
- /**
- * Checks whether audio uses Bluetooth SCO.
- */
- private boolean isScoOn() {
- return audioManager.isBluetoothScoOn();
- }
- /**
- * Converts BluetoothAdapter states into local string representations.
- */
- private String stateToString(int state) {
- switch (state) {
- case BluetoothAdapter.STATE_DISCONNECTED:
- return "DISCONNECTED";
- case BluetoothAdapter.STATE_CONNECTED:
- return "CONNECTED";
- case BluetoothAdapter.STATE_CONNECTING:
- return "CONNECTING";
- case BluetoothAdapter.STATE_DISCONNECTING:
- return "DISCONNECTING";
- case BluetoothAdapter.STATE_OFF:
- return "OFF";
- case BluetoothAdapter.STATE_ON:
- return "ON";
- case BluetoothAdapter.STATE_TURNING_OFF:
- // Indicates the local Bluetooth adapter is turning off. Local clients should immediately
- // attempt graceful disconnection of any remote links.
- return "TURNING_OFF";
- case BluetoothAdapter.STATE_TURNING_ON:
- // Indicates the local Bluetooth adapter is turning on. However local clients should wait
- // for STATE_ON before attempting to use the adapter.
- return "TURNING_ON";
- default:
- return "INVALID";
- }
- }
- // Bluetooth connection state.
- public enum State {
- // Bluetooth is not available; no adapter or Bluetooth is off.
- UNINITIALIZED,
- // Bluetooth error happened when trying to start Bluetooth.
- ERROR,
- // Bluetooth proxy object for the Headset profile exists, but no connected headset devices,
- // SCO is not started or disconnected.
- HEADSET_UNAVAILABLE,
- // Bluetooth proxy object for the Headset profile connected, connected Bluetooth headset
- // present, but SCO is not started or disconnected.
- HEADSET_AVAILABLE,
- // Bluetooth audio SCO connection with remote device is closing.
- SCO_DISCONNECTING,
- // Bluetooth audio SCO connection with remote device is initiated.
- SCO_CONNECTING,
- // Bluetooth audio SCO connection with remote device is established.
- SCO_CONNECTED
- }
- /**
- * Implementation of an interface that notifies BluetoothProfile IPC clients when they have been
- * connected to or disconnected from the service.
- */
- private class BluetoothServiceListener implements BluetoothProfile.ServiceListener {
- @Override
- // Called to notify the client when the proxy object has been connected to the service.
- // Once we have the profile proxy object, we can use it to monitor the state of the
- // connection and perform other operations that are relevant to the headset profile.
- public void onServiceConnected(int profile, BluetoothProfile proxy) {
- if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) {
- return;
- }
- Log.d(TAG, "BluetoothServiceListener.onServiceConnected: BT state=" + bluetoothState);
- // Android only supports one connected Bluetooth Headset at a time.
- bluetoothHeadset = (BluetoothHeadset) proxy;
- updateAudioDeviceState();
- Log.d(TAG, "onServiceConnected done: BT state=" + bluetoothState);
- }
- @Override
- /** Notifies the client when the proxy object has been disconnected from the service. */
- public void onServiceDisconnected(int profile) {
- if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) {
- return;
- }
- Log.d(TAG, "BluetoothServiceListener.onServiceDisconnected: BT state=" + bluetoothState);
- stopScoAudio();
- bluetoothHeadset = null;
- bluetoothDevice = null;
- bluetoothState = State.HEADSET_UNAVAILABLE;
- updateAudioDeviceState();
- Log.d(TAG, "onServiceDisconnected done: BT state=" + bluetoothState);
- }
- }
- // Intent broadcast receiver which handles changes in Bluetooth device availability.
- // Detects headset changes and Bluetooth SCO state changes.
- private class BluetoothHeadsetBroadcastReceiver extends BroadcastReceiver {
- @Override
- public void onReceive(Context context, Intent intent) {
- if (bluetoothState == State.UNINITIALIZED) {
- return;
- }
- final String action = intent.getAction();
- // Change in connection state of the Headset profile. Note that the
- // change does not tell us anything about whether we're streaming
- // audio to BT over SCO. Typically received when user turns on a BT
- // headset while audio is active using another audio device.
- if (action.equals(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)) {
- final int state =
- intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED);
- Log.d(TAG, "BluetoothHeadsetBroadcastReceiver.onReceive: "
- + "a=ACTION_CONNECTION_STATE_CHANGED, "
- + "s=" + stateToString(state) + ", "
- + "sb=" + isInitialStickyBroadcast() + ", "
- + "BT state: " + bluetoothState);
- if (state == BluetoothHeadset.STATE_CONNECTED) {
- scoConnectionAttempts = 0;
- updateAudioDeviceState();
- } else if (state == BluetoothHeadset.STATE_CONNECTING) {
- // No action needed.
- } else if (state == BluetoothHeadset.STATE_DISCONNECTING) {
- // No action needed.
- } else if (state == BluetoothHeadset.STATE_DISCONNECTED) {
- // Bluetooth is probably powered off during the call.
- stopScoAudio();
- updateAudioDeviceState();
- }
- // Change in the audio (SCO) connection state of the Headset profile.
- // Typically received after call to startScoAudio() has finalized.
- } else if (action.equals(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)) {
- final int state = intent.getIntExtra(
- BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_DISCONNECTED);
- Log.d(TAG, "BluetoothHeadsetBroadcastReceiver.onReceive: "
- + "a=ACTION_AUDIO_STATE_CHANGED, "
- + "s=" + stateToString(state) + ", "
- + "sb=" + isInitialStickyBroadcast() + ", "
- + "BT state: " + bluetoothState);
- if (state == BluetoothHeadset.STATE_AUDIO_CONNECTED) {
- cancelTimer();
- if (bluetoothState == State.SCO_CONNECTING) {
- Log.d(TAG, "+++ Bluetooth audio SCO is now connected");
- bluetoothState = State.SCO_CONNECTED;
- scoConnectionAttempts = 0;
- updateAudioDeviceState();
- } else {
- Log.w(TAG, "Unexpected state BluetoothHeadset.STATE_AUDIO_CONNECTED");
- }
- } else if (state == BluetoothHeadset.STATE_AUDIO_CONNECTING) {
- Log.d(TAG, "+++ Bluetooth audio SCO is now connecting...");
- } else if (state == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) {
- Log.d(TAG, "+++ Bluetooth audio SCO is now disconnected");
- if (isInitialStickyBroadcast()) {
- Log.d(TAG, "Ignore STATE_AUDIO_DISCONNECTED initial sticky broadcast.");
- return;
- }
- updateAudioDeviceState();
- }
- }
- Log.d(TAG, "onReceive done: BT state=" + bluetoothState);
- }
- }
- }
|