| /* |
| * Copyright (C) 2020 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.google.android.connecteddevice.ui; |
| |
| import static com.google.android.connecteddevice.api.RemoteFeature.ASSOCIATED_DEVICE_DATA_NAME_EXTRA; |
| import static com.google.android.connecteddevice.util.SafeLog.logd; |
| import static com.google.android.connecteddevice.util.SafeLog.loge; |
| |
| import android.annotation.SuppressLint; |
| import android.app.Application; |
| import android.bluetooth.BluetoothAdapter; |
| import android.bluetooth.BluetoothManager; |
| import android.content.BroadcastReceiver; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.IntentFilter; |
| import android.os.ParcelUuid; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.VisibleForTesting; |
| import androidx.lifecycle.AndroidViewModel; |
| import androidx.lifecycle.LiveData; |
| import androidx.lifecycle.MutableLiveData; |
| import androidx.lifecycle.ViewModel; |
| import com.google.android.connecteddevice.api.CompanionConnector; |
| import com.google.android.connecteddevice.api.Connector; |
| import com.google.android.connecteddevice.api.IAssociationCallback; |
| import com.google.android.connecteddevice.api.IOnAssociatedDevicesRetrievedListener; |
| import com.google.android.connecteddevice.model.AssociatedDevice; |
| import com.google.android.connecteddevice.model.ConnectedDevice; |
| import com.google.android.connecteddevice.model.StartAssociationResponse; |
| import com.google.android.connecteddevice.ui.AssociatedDeviceDetails.ConnectionState; |
| import java.time.Duration; |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.UUID; |
| import java.util.concurrent.CopyOnWriteArrayList; |
| |
| /** |
| * Implementation {@link ViewModel} for sharing associated devices data between the companion |
| * platform and UI elements. |
| */ |
| public class AssociatedDeviceViewModel extends AndroidViewModel { |
| |
| private static final String TAG = "AssociatedDeviceViewModel"; |
| |
| private static final Duration DISCOVERABLE_DURATION = Duration.ofMinutes(2); |
| |
| /** States of association process. */ |
| public enum AssociationState { |
| NONE, |
| PENDING, |
| STARTING, |
| STARTED, |
| COMPLETED, |
| ERROR |
| } |
| |
| private final List<AssociatedDevice> associatedDevices = new CopyOnWriteArrayList<>(); |
| |
| private final MutableLiveData<List<AssociatedDeviceDetails>> associatedDevicesDetails = |
| new MutableLiveData<>(new ArrayList<>()); |
| private final MutableLiveData<String> advertisedCarName = new MutableLiveData<>(null); |
| private final MutableLiveData<StartAssociationResponse> associationResponse = |
| new MutableLiveData<>(null); |
| private final MutableLiveData<String> pairingCode = new MutableLiveData<>(null); |
| private final MutableLiveData<Integer> bluetoothState = |
| new MutableLiveData<>(BluetoothAdapter.STATE_OFF); |
| private final MutableLiveData<AssociationState> associationState = |
| new MutableLiveData<>(AssociationState.NONE); |
| private final MutableLiveData<AssociatedDevice> removedDevice = new MutableLiveData<>(null); |
| private final MutableLiveData<Boolean> isServiceConnected = new MutableLiveData<>(false); |
| private final boolean isSppEnabled; |
| private final boolean isPassengerEnabled; |
| private final String bleDeviceNamePrefix; |
| private final BluetoothAdapter bluetoothAdapter; |
| |
| private final Connector connector; |
| |
| private ParcelUuid associationIdentifier; |
| |
| public AssociatedDeviceViewModel( |
| @NonNull Application application, |
| boolean isSppEnabled, |
| String bleDeviceNamePrefix, |
| boolean isPassengerEnabled |
| ) { |
| this( |
| application, |
| isSppEnabled, |
| bleDeviceNamePrefix, |
| isPassengerEnabled, |
| new CompanionConnector( |
| application, /* isForegroundProcess= */ true, /* userType= */ Connector.USER_TYPE_ALL)); |
| } |
| |
| @VisibleForTesting |
| @SuppressLint("UnprotectedReceiver") // Broadcasts are protected. |
| AssociatedDeviceViewModel( |
| @NonNull Application application, |
| boolean isSppEnabled, |
| String bleDeviceNamePrefix, |
| boolean isPassengerEnabled, |
| Connector connector) { |
| super(application); |
| this.isSppEnabled = isSppEnabled; |
| this.bleDeviceNamePrefix = bleDeviceNamePrefix; |
| this.connector = connector; |
| this.isPassengerEnabled = isPassengerEnabled; |
| |
| connector.setCallback(connectorCallback); |
| |
| IntentFilter filter = new IntentFilter(); |
| filter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED); |
| getApplication().registerReceiver(receiver, filter); |
| bluetoothAdapter = |
| application.getApplicationContext().getSystemService(BluetoothManager.class).getAdapter(); |
| bluetoothState.postValue(BluetoothAdapter.STATE_ON); |
| |
| // Registers for device callbacks |
| this.connector.setFeatureId(new ParcelUuid(UUID.randomUUID())); |
| |
| this.connector.setCallback(connectorCallback); |
| this.connector.connect(); |
| } |
| |
| @Override |
| protected void onCleared() { |
| super.onCleared(); |
| connector.setCallback(null); |
| connector.disconnect(); |
| getApplication().unregisterReceiver(receiver); |
| } |
| |
| /** Confirms that the pairing code matches. */ |
| public void acceptVerification() { |
| pairingCode.postValue(null); |
| connector.acceptVerification(); |
| } |
| |
| /** Stops association. */ |
| public void stopAssociation() { |
| AssociationState state = associationState.getValue(); |
| if (state != AssociationState.STARTING && state != AssociationState.STARTED) { |
| return; |
| } |
| advertisedCarName.postValue(null); |
| pairingCode.postValue(null); |
| connector.stopAssociation(); |
| associationState.postValue(AssociationState.NONE); |
| } |
| |
| /** Retries association. */ |
| public void retryAssociation() { |
| stopAssociation(); |
| startAssociationInternal(); |
| } |
| |
| /** Removes the association of the given device. */ |
| public void removeDevice(@NonNull AssociatedDevice device) { |
| connector.removeAssociatedDevice(device.getDeviceId()); |
| } |
| |
| /** Toggles connection of the given associated device. */ |
| public void toggleConnectionStatusForDevice(@NonNull AssociatedDevice device) { |
| if (device.isConnectionEnabled()) { |
| connector.disableAssociatedDeviceConnection(device.getDeviceId()); |
| } else { |
| connector.enableAssociatedDeviceConnection(device.getDeviceId()); |
| } |
| } |
| |
| /** Mark the given device as belonging to the active driver. */ |
| public void claimDevice(@NonNull AssociatedDevice device) { |
| connector.claimAssociatedDevice(device.getDeviceId()); |
| } |
| |
| /** Mark the given device as unclaimed by any user. */ |
| public void removeClaimOnDevice(@NonNull AssociatedDevice device) { |
| connector.removeAssociatedDeviceClaim(device.getDeviceId()); |
| } |
| |
| /** |
| * Gets a list of details for all associated device. |
| * |
| * <p>The list will always be non-{@code null}, and will be empty if there are no associated |
| * devices. |
| */ |
| public LiveData<List<AssociatedDeviceDetails>> getAssociatedDevicesDetails() { |
| return associatedDevicesDetails; |
| } |
| |
| /** Starts feature activity for the given associated device. */ |
| public void startFeatureActivityForDevice( |
| @NonNull String action, @NonNull AssociatedDevice device) { |
| Intent intent = new Intent(action); |
| intent.putExtra(ASSOCIATED_DEVICE_DATA_NAME_EXTRA, device); |
| intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); |
| getApplication().startActivity(intent); |
| } |
| |
| /** Resets the value of {@link #associationState} and {@link #associationResponse}. */ |
| public void resetAssociationState() { |
| associationState.postValue(AssociationState.NONE); |
| associationResponse.postValue(null); |
| } |
| |
| /** Gets the name that is being advertised by the car. */ |
| public LiveData<String> getAdvertisedCarName() { |
| return advertisedCarName; |
| } |
| |
| /** Gets the response from a successful request to start association. */ |
| public LiveData<StartAssociationResponse> getAssociationResponse() { |
| return associationResponse; |
| } |
| |
| /** Gets the generated pairing code. */ |
| public LiveData<String> getPairingCode() { |
| return pairingCode; |
| } |
| |
| /** Returns the value of a device whose association has been removed. */ |
| public LiveData<AssociatedDevice> getRemovedDevice() { |
| return removedDevice; |
| } |
| |
| /** Gets the current {@link AssociationState}. */ |
| public LiveData<AssociationState> getAssociationState() { |
| return associationState; |
| } |
| |
| /** Gets the current Bluetooth state. */ |
| public LiveData<Integer> getBluetoothState() { |
| return bluetoothState; |
| } |
| |
| /** Value is {@code true} if the service connection is alive. */ |
| public LiveData<Boolean> isServiceConnected() { |
| return isServiceConnected; |
| } |
| |
| /** Starts adding associated device with the given identifier. */ |
| public void startAssociation(@NonNull ParcelUuid identifier) { |
| associationIdentifier = identifier; |
| startAssociationInternal(); |
| } |
| |
| /** Starts adding associated device. */ |
| public void startAssociation() { |
| associationIdentifier = null; |
| startAssociationInternal(); |
| } |
| |
| private void startAssociationInternal() { |
| associationState.postValue(AssociationState.PENDING); |
| if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) { |
| return; |
| } |
| |
| if (isSppEnabled |
| && bluetoothAdapter.getScanMode() != BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE) { |
| Intent discoverableIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE); |
| discoverableIntent.putExtra( |
| BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, (int) DISCOVERABLE_DURATION.getSeconds()); |
| discoverableIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); |
| getApplication().startActivity(discoverableIntent); |
| } |
| |
| if (associationIdentifier != null) { |
| connector.startAssociation(associationIdentifier, associationCallback); |
| } else { |
| connector.startAssociation(associationCallback); |
| } |
| |
| associationState.postValue(AssociationState.STARTING); |
| } |
| |
| private void updateDeviceDetails() { |
| List<AssociatedDeviceDetails> associatedDevicesDetails = new ArrayList<>(); |
| for (AssociatedDevice device : associatedDevices) { |
| associatedDevicesDetails.add(new AssociatedDeviceDetails(device, getConnectionState(device))); |
| } |
| this.associatedDevicesDetails.postValue(associatedDevicesDetails); |
| } |
| |
| private ConnectionState getConnectionState(@NonNull AssociatedDevice device) { |
| logd(TAG, "Getting connection state for device " + device.getDeviceId() + "."); |
| ConnectedDevice connectedDevice = connector.getConnectedDeviceById(device.getDeviceId()); |
| if (connectedDevice == null) { |
| logd(TAG, "Device is not detected."); |
| return ConnectionState.NOT_DETECTED; |
| } |
| if (connectedDevice.hasSecureChannel()) { |
| logd(TAG, "Device is connected."); |
| return ConnectionState.CONNECTED; |
| } |
| logd(TAG, "Device is detected."); |
| return ConnectionState.DETECTED; |
| } |
| |
| private void setAssociatedDevices(@NonNull List<AssociatedDevice> associatedDevices) { |
| this.associatedDevices.clear(); |
| this.associatedDevices.addAll(associatedDevices); |
| updateDeviceDetails(); |
| } |
| |
| private void addOrUpdateAssociatedDevice(@NonNull AssociatedDevice device) { |
| associatedDevices.remove(device); |
| associatedDevices.add(device); |
| updateDeviceDetails(); |
| } |
| |
| private void removeAssociatedDevice(AssociatedDevice device) { |
| if (associatedDevices.remove(device)) { |
| removedDevice.postValue(device); |
| updateDeviceDetails(); |
| } |
| } |
| |
| private final Connector.Callback connectorCallback = |
| new Connector.Callback() { |
| @Override |
| public void onConnected() { |
| logd(TAG, "Connected to platform."); |
| isServiceConnected.postValue(true); |
| |
| if (isPassengerEnabled) { |
| connector.retrieveAssociatedDevices(associatedDevicesRetrievedListener); |
| } else { |
| // If passenger is disabled, then there should only be one device and that will belong |
| // to the driver. |
| connector.retrieveAssociatedDevicesForDriver(associatedDevicesRetrievedListener); |
| } |
| } |
| |
| @Override |
| public void onDisconnected() { |
| logd(TAG, "Disconnected from the platform."); |
| isServiceConnected.postValue(false); |
| connector.connect(); |
| } |
| |
| @Override |
| public void onAssociatedDeviceAdded(@NonNull AssociatedDevice device) { |
| resetAssociationState(); |
| addOrUpdateAssociatedDevice(device); |
| } |
| |
| @Override |
| public void onAssociatedDeviceRemoved(@NonNull AssociatedDevice device) { |
| removeAssociatedDevice(device); |
| } |
| |
| @Override |
| public void onAssociatedDeviceUpdated(AssociatedDevice device) { |
| addOrUpdateAssociatedDevice(device); |
| } |
| |
| @Override |
| public void onDeviceConnected(ConnectedDevice connectedDevice) { |
| logd(TAG, "Device " + connectedDevice.getDeviceId() + " has connected."); |
| updateDeviceDetails(); |
| } |
| |
| @Override |
| public void onDeviceDisconnected(ConnectedDevice connectedDevice) { |
| logd(TAG, "Device " + connectedDevice.getDeviceId() + " has disconnected."); |
| updateDeviceDetails(); |
| } |
| |
| @Override |
| public void onSecureChannelEstablished(@NonNull ConnectedDevice device) { |
| logd(TAG, "Device " + device.getDeviceId() + " has established a secure channel."); |
| updateDeviceDetails(); |
| } |
| }; |
| |
| private final IAssociationCallback associationCallback = |
| new IAssociationCallback.Stub() { |
| @Override |
| public void onAssociationStartSuccess(StartAssociationResponse response) { |
| associationResponse.postValue(response); |
| associationState.postValue(AssociationState.STARTED); |
| String deviceName = response.getDeviceName(); |
| if (!deviceName.isEmpty()) { |
| // Name prefix is only needed under BLE mode. |
| deviceName = bleDeviceNamePrefix + deviceName; |
| } |
| advertisedCarName.postValue(deviceName); |
| } |
| |
| @Override |
| public void onAssociationStartFailure() { |
| associationState.postValue(AssociationState.ERROR); |
| loge(TAG, "Failed to start association."); |
| } |
| |
| @Override |
| public void onAssociationError(int error) { |
| associationState.postValue(AssociationState.ERROR); |
| loge(TAG, "Error during association: " + error + "."); |
| } |
| |
| @Override |
| public void onVerificationCodeAvailable(String code) { |
| advertisedCarName.postValue(null); |
| pairingCode.postValue(code); |
| } |
| |
| @Override |
| public void onAssociationCompleted() { |
| associationState.postValue(AssociationState.COMPLETED); |
| associationIdentifier = null; |
| } |
| }; |
| |
| private final IOnAssociatedDevicesRetrievedListener associatedDevicesRetrievedListener = |
| new IOnAssociatedDevicesRetrievedListener.Stub() { |
| @Override |
| public void onAssociatedDevicesRetrieved(List<AssociatedDevice> devices) { |
| setAssociatedDevices(devices); |
| } |
| }; |
| |
| private final BroadcastReceiver receiver = |
| new BroadcastReceiver() { |
| @Override |
| public void onReceive(Context context, Intent intent) { |
| String action = intent.getAction(); |
| if (!BluetoothAdapter.ACTION_STATE_CHANGED.equals(action)) { |
| return; |
| } |
| int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1); |
| if (state != BluetoothAdapter.STATE_ON |
| && state != BluetoothAdapter.STATE_OFF |
| && state != BluetoothAdapter.ERROR) { |
| // No need to convey any other state. |
| return; |
| } |
| bluetoothState.postValue(state); |
| if (state == BluetoothAdapter.STATE_ON |
| && associationState.getValue() == AssociationState.PENDING |
| && connector.isConnected()) { |
| startAssociationInternal(); |
| } |
| } |
| }; |
| } |